makearmy-app/components/forms/SettingsSubmit.tsx

1087 lines
41 KiB
TypeScript
Raw Normal View History

2025-10-03 21:45:57 -04:00
// components/forms/SettingsSubmit.tsx
2025-09-22 10:37:53 -04:00
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form";
2025-09-22 16:11:08 -04:00
import { useRouter, useSearchParams } from "next/navigation";
2025-09-22 10:37:53 -04:00
2025-10-02 23:19:17 -04:00
type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv";
2025-09-22 10:37:53 -04:00
type Opt = { id: string; label: string };
type Me = {
id: string;
2025-09-28 07:04:42 -04:00
username?: string;
display_name?: string;
first_name?: string;
last_name?: string;
email?: string;
};
2025-09-22 10:37:53 -04:00
2025-09-28 14:54:52 -04:00
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
// Local enums (no schema introspection)
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
const FILL_TYPE_OPTIONS = [
{ label: "UniDirectional", value: "uni" },
{ label: "BiDirectional", value: "bi" },
{ label: "Offset Fill", value: "offset" },
];
const RASTER_TYPE_OPTIONS = [
{ label: "UniDirectional", value: "uni" },
{ label: "BiDirectional", value: "bi" },
{ label: "Offset Fill", value: "offset" },
];
const RASTER_DITHER_OPTIONS = [
{ label: "Threshold", value: "threshold" },
{ label: "Ordered", value: "ordered" },
{ label: "Atkinson", value: "atkinson" },
{ label: "Dither", value: "dither" },
{ label: "Stucki", value: "stucki" },
{ label: "Jarvis", value: "jarvis" },
{ label: "Newsprint", value: "newsprint" },
{ label: "Halftone", value: "halftone" },
{ label: "Sketch", value: "sketch" },
{ label: "Grayscale", value: "grayscale" },
];
const toOpts = (arr: { label: string; value: string }[]): Opt[] =>
arr.map((x) => ({ id: x.value, label: x.label }));
2025-09-28 11:15:37 -04:00
function shortId(s?: string) {
if (!s) return "";
return s.length <= 12 ? s : `${s.slice(0, 8)}${s.slice(-4)}`;
}
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-10-04 20:19:53 -04:00
/** Normalizers for edit-mode prefill (IDs + enums) */
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-10-04 14:02:01 -04:00
function idToString(v: any): string {
if (v == null || v === "") return "";
if (typeof v === "object") {
if ((v as any).id != null) return String((v as any).id);
if ((v as any).submission_id != null) return String((v as any).submission_id);
}
return String(v);
}
function normalizeEnums(value: any, allowed: string[], fallback: string) {
const v = value == null ? "" : String(value).toLowerCase();
return allowed.includes(v) ? v : fallback;
}
2025-10-02 23:19:17 -04:00
type BaseProps = { initialTarget?: Target };
type EditInitialValues = {
setting_title?: string;
setting_notes?: string;
photo?: string | File | { id?: string } | null;
screen?: string | File | { id?: string } | null;
mat?: any;
mat_coat?: any;
mat_color?: any;
mat_opacity?: any;
mat_thickness?: number | null;
source?: any;
lens?: any;
focus?: number | null;
laser_soft?: any;
repeat_all?: number | null;
fill_settings?: any[] | null;
line_settings?: any[] | null;
raster_settings?: any[] | null;
};
2025-10-04 14:02:01 -04:00
function normalizeForReset(iv: EditInitialValues) {
return {
...iv,
// Single-selects → string IDs
mat: idToString(iv.mat),
mat_coat: idToString(iv.mat_coat),
mat_color: idToString(iv.mat_color),
mat_opacity: idToString(iv.mat_opacity),
source: idToString(iv.source),
lens: idToString(iv.lens),
laser_soft: idToString(iv.laser_soft),
// Arrays: coerce dropdown-ish fields to internal enum keys
fill_settings: (iv.fill_settings ?? []).map((r: any) => ({
...r,
type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"),
})),
raster_settings: (iv.raster_settings ?? []).map((r: any) => ({
...r,
type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"),
dither: normalizeEnums(
r?.dither,
[
"threshold",
"ordered",
"atkinson",
"dither",
"stucki",
"jarvis",
"newsprint",
"halftone",
"sketch",
"grayscale",
],
"threshold"
),
})),
line_settings: (iv.line_settings ?? []).map((r: any) => ({ ...r })),
};
}
// ─────────────────────────────────────────────────────────────
// Props (Create vs Edit) + type guard
// ─────────────────────────────────────────────────────────────
2025-10-02 23:19:17 -04:00
type CreateProps = BaseProps & {
mode?: "create";
submissionId?: never;
initialValues?: never;
};
type EditProps = BaseProps & {
mode: "edit";
submissionId: string | number;
initialValues: EditInitialValues;
};
function isEditProps(p: CreateProps | EditProps): p is EditProps {
return (p as any)?.mode === "edit";
}
// ─────────────────────────────────────────────────────────────
// Options loader (materials, lenses, etc.)
// ─────────────────────────────────────────────────────────────
2025-10-04 14:02:01 -04:00
function useOptions(path: string, forceIncludeId?: string, opts?: { disableNmFilter?: boolean }) {
const [optsState, setOptsState] = useState<Opt[]>([]);
2025-09-22 10:37:53 -04:00
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
2025-10-04 14:02:01 -04:00
const disableNmFilter = opts?.disableNmFilter === true;
2025-09-28 14:54:52 -04:00
// helpers for the "laser_source" nm filtering
const parseNum = (v: any): number | null => {
if (v == null) return null;
const m = String(v).match(/(\d+(\.\d+)?)/);
return m ? Number(m[1]) : null;
};
2025-09-29 00:06:39 -04:00
2025-09-28 14:54:52 -04:00
const nmRangeFor = (target?: string | null): [number, number] | null => {
if (!target) return null;
const t = target.toLowerCase();
if (t === "fiber") return [1000, 9000];
if (t === "uv") return [300, 400];
if (t === "co2-gantry" || t === "co2-galvo") return [10000, 11000];
return null;
};
2025-09-22 10:37:53 -04:00
useEffect(() => {
let alive = true;
setLoading(true);
2025-09-28 14:54:52 -04:00
(async () => {
const [rawPath, qs] = path.split("?", 2);
const params = new URLSearchParams(qs || "");
const target = params.get("target") || "";
let url = "";
let normalize: (rows: any[]) => Opt[] = (rows) =>
rows.map((r) => ({
id: String(r.id),
label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id),
}));
if (rawPath === "material") {
url = `${API}/items/material?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "material_color") {
url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "material_coating") {
url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "material_opacity") {
url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`;
normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) }));
} else if (rawPath === "laser_software") {
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "laser_source") {
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
2025-10-04 14:02:01 -04:00
const range = disableNmFilter ? null : nmRangeFor(target);
2025-09-28 14:54:52 -04:00
normalize = (rows) => {
const filtered = range
? rows.filter((r: any) => {
const nm = parseNum(r.nm);
return nm != null && nm >= range[0] && nm <= range[1];
})
: rows;
return filtered.map((r: any) => ({
id: String(r.submission_id),
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id),
}));
};
} else if (rawPath === "lens") {
2025-10-03 22:54:05 -04:00
// CO2 gantry uses focus lenses; all others use scan lenses
2025-09-28 14:54:52 -04:00
if (target === "co2-gantry") {
2025-09-28 17:13:03 -04:00
url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000`;
2025-09-28 14:54:52 -04:00
normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.name ?? r.id) }));
} else {
2025-10-03 22:54:05 -04:00
// SCAN LENSES (fiber, uv, co2-galvo): sort numerically by focal_length
2025-09-28 17:13:03 -04:00
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
normalize = (rows) => {
const toNum = (v: any) => {
const m = String(v ?? "").match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY;
};
const sorted = [...rows].sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length));
return sorted.map((r) => {
const fs = r.field_size != null ? `${r.field_size}` : "";
const fl = r.focal_length != null ? `${r.focal_length}` : "";
const composed = [fs && `${fs} mm`, fl && `${fl} mm`].filter(Boolean).join(" — ");
return { id: String(r.id), label: composed || String(r.id) };
});
};
2025-09-28 12:17:12 -04:00
}
2025-09-28 18:16:26 -04:00
} else {
2025-10-03 22:54:05 -04:00
// unknown path → empty
2025-10-04 14:02:01 -04:00
setOptsState([]);
2025-09-28 18:16:26 -04:00
setLoading(false);
2025-09-28 14:54:52 -04:00
return;
}
2025-09-28 12:17:12 -04:00
2025-09-28 14:54:52 -04:00
const res = await fetch(url, { cache: "no-store", credentials: "include" });
if (!res.ok) throw new Error(`Directus ${res.status} fetching ${url}`);
const json = await res.json();
const rows = json?.data ?? [];
const mapped = normalize(rows);
2025-09-28 12:17:12 -04:00
2025-10-04 14:02:01 -04:00
// Ensure the currently selected value is present even if filters/pagination miss it
let ensured = mapped;
if (forceIncludeId && !mapped.some((o: any) => String(o.id) === String(forceIncludeId))) {
ensured = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped];
}
2025-10-03 22:54:05 -04:00
// client-side text filter
2025-09-28 14:54:52 -04:00
const needle = (q || "").trim().toLowerCase();
2025-10-04 14:02:01 -04:00
const filtered = needle ? ensured.filter((o) => o.label.toLowerCase().includes(needle)) : ensured;
2025-09-28 14:54:52 -04:00
2025-10-04 14:02:01 -04:00
if (alive) setOptsState(filtered);
2025-09-28 14:54:52 -04:00
})()
2025-10-04 14:02:01 -04:00
.catch(() => alive && setOptsState([]))
2025-09-28 07:41:15 -04:00
.finally(() => alive && setLoading(false));
2025-09-28 14:54:52 -04:00
2025-09-28 07:41:15 -04:00
return () => {
alive = false;
};
2025-10-04 14:02:01 -04:00
}, [path, q, forceIncludeId, disableNmFilter]);
2025-09-22 10:37:53 -04:00
2025-10-04 14:02:01 -04:00
return { opts: optsState, loading, setQ };
2025-09-22 10:37:53 -04:00
}
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
// Small UI bits
// ─────────────────────────────────────────────────────────────
2025-09-22 10:37:53 -04:00
function FilterableSelect({
label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
required = false,
2025-09-22 10:37:53 -04:00
}: {
label: string;
name: string;
register: UseFormRegister<any>;
options: Opt[];
loading?: boolean;
onQuery?: (q: string) => void;
placeholder?: string;
required?: boolean;
}) {
const [filter, setFilter] = useState("");
useEffect(() => {
onQuery?.(filter);
}, [filter, onQuery]);
2025-09-22 10:37:53 -04:00
const filtered = useMemo(() => {
if (!filter) return options;
const f = filter.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(f));
}, [options, filter]);
return (
<div>
2025-09-22 16:11:08 -04:00
<label className="block text-sm mb-1">
{label} {required ? <span className="text-red-600">*</span> : null}
</label>
<input
className="w-full border rounded px-2 py-1 mb-1"
placeholder="Type to filter…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
2025-09-28 11:15:37 -04:00
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
2025-10-02 23:19:17 -04:00
<option value="">
{placeholder}
{loading ? " (loading…)" : ""}
</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
2025-09-22 10:37:53 -04:00
</div>
);
}
2025-09-28 11:15:37 -04:00
function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister<any> }) {
return (
<label className="flex items-center gap-1 text-sm">
<input type="checkbox" {...register(name)} /> {label}
</label>
);
}
2025-10-03 22:54:05 -04:00
function LabeledInput({
label,
name,
type = "text",
step,
register,
required = false,
min,
max,
}: {
label: string;
name: string;
type?: "text" | "number";
step?: string | number;
register: UseFormRegister<any>;
required?: boolean;
min?: number;
max?: number;
}) {
return (
<div>
<label className="block text-xs mb-1">
{label}
{required ? " *" : ""}
</label>
<input
type={type}
step={step}
min={min}
max={max}
className="w-full border rounded px-2 py-1"
{...register(name, { required })}
/>
</div>
);
}
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function SettingsSubmit(props: CreateProps | EditProps) {
2025-09-22 16:11:08 -04:00
const router = useRouter();
const sp = useSearchParams();
2025-10-02 23:12:24 -04:00
2025-10-02 23:19:17 -04:00
const isEdit = isEditProps(props);
2025-10-03 22:54:05 -04:00
const edit = isEdit ? props : null; // strongly-typed local when edit
2025-10-02 23:12:24 -04:00
2025-10-04 14:02:01 -04:00
// Initialize as CO2-galvo in edit mode (unless explicitly overridden)
const initialFromQuery =
(sp.get("target") as Target) ||
props.initialTarget ||
(isEdit ? "settings_co2gal" : "settings_fiber");
2025-10-02 23:19:17 -04:00
const [target, setTarget] = useState<Target>(initialFromQuery);
2025-09-22 16:11:08 -04:00
2025-10-04 14:02:01 -04:00
useEffect(() => {
if (isEdit && props.initialTarget && props.initialTarget !== target) {
setTarget(props.initialTarget);
}
}, [isEdit, props.initialTarget, target]);
2025-10-03 22:54:05 -04:00
// Map collection -> slug used by options selectors
2025-09-28 07:23:31 -04:00
const typeForOptions = useMemo(() => {
switch (target) {
case "settings_fiber":
return "fiber";
case "settings_uv":
return "uv";
case "settings_co2gan":
return "co2-gantry";
2025-09-28 11:15:37 -04:00
case "settings_co2gal":
return "co2-galvo";
default:
return "fiber";
2025-09-28 07:23:31 -04:00
}
}, [target]);
2025-09-28 07:41:15 -04:00
// Image inputs
2025-09-22 16:11:08 -04:00
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string>("");
const [screenPreview, setScreenPreview] = useState<string>("");
2025-09-22 10:37:53 -04:00
2025-09-28 11:15:37 -04:00
// UX error for auth/submit
const [submitErr, setSubmitErr] = useState<string | null>(null);
2025-09-22 10:37:53 -04:00
2025-09-28 11:15:37 -04:00
// Current signed-in user (banner only)
const [me, setMe] = useState<Me | null>(null);
const [meErr, setMeErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
2025-10-03 22:54:05 -04:00
// use our bearer-only API
fetch(`/api/me`, { cache: "no-store", credentials: "include" })
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((j) => {
if (!alive) return;
setMe(j || null);
})
.catch(() => {
if (alive) setMeErr("not-signed-in");
});
return () => {
alive = false;
};
}, []);
2025-10-03 21:49:04 -04:00
const meLabel = me?.username ?? "";
2025-09-28 11:44:34 -04:00
2025-10-04 14:02:01 -04:00
// For edit-mode, compute normalized current values once to seed option lists
const current = useMemo(
() => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null),
[isEdit, edit?.initialValues]
);
2025-09-28 14:54:52 -04:00
// Options
2025-10-04 14:02:01 -04:00
const mats = useOptions("material", current?.mat || undefined);
const coats = useOptions("material_coating", current?.mat_coat || undefined);
const colors = useOptions("material_color", current?.mat_color || undefined);
const opacs = useOptions("material_opacity", current?.mat_opacity || undefined);
const soft = useOptions("laser_software", current?.laser_soft || undefined); // required for ALL targets
const srcs = useOptions(`laser_source?target=${typeForOptions}`, current?.source || undefined, {
disableNmFilter: isEdit,
2025-10-04 14:02:01 -04:00
});
const lens = useOptions(`lens?target=${typeForOptions}`, current?.lens || undefined);
2025-09-28 14:54:52 -04:00
2025-10-03 22:54:05 -04:00
// Repeater choice options (LOCAL now, no network)
const fillType = { opts: toOpts(FILL_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} };
const rasterType = { opts: toOpts(RASTER_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} };
const rasterDither = { opts: toOpts(RASTER_DITHER_OPTIONS), loading: false, setQ: (_: string) => {} };
2025-09-28 14:54:52 -04:00
const {
register,
handleSubmit,
control,
reset,
2025-10-04 20:19:53 -04:00
setValue, // used for prefill select syncing
2025-10-04 20:14:47 -04:00
getValues,
2025-09-28 14:54:52 -04:00
formState: { isSubmitting },
2025-10-02 23:19:17 -04:00
} = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
mat: "",
mat_coat: "",
mat_color: "",
mat_opacity: "",
mat_thickness: "",
source: "",
lens: "",
focus: "",
laser_soft: "",
2025-10-04 19:54:31 -04:00
repeat_all: "", // on all targets
2025-10-02 23:19:17 -04:00
fill_settings: [],
line_settings: [],
raster_settings: [],
},
2025-09-28 14:54:52 -04:00
});
2025-10-02 23:19:17 -04:00
const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" });
2025-09-28 14:54:52 -04:00
2025-10-03 22:54:05 -04:00
// Prefill the form in edit mode
2025-10-03 21:45:57 -04:00
useEffect(() => {
2025-10-03 22:54:05 -04:00
if (isEdit && edit?.initialValues) {
2025-10-04 14:02:01 -04:00
const iv = normalizeForReset(edit.initialValues);
2025-10-03 22:54:05 -04:00
reset({
2025-10-04 14:02:01 -04:00
setting_title: iv.setting_title ?? "",
setting_notes: iv.setting_notes ?? "",
photo: iv.photo ?? null,
screen: iv.screen ?? null,
mat: iv.mat ?? "",
mat_coat: iv.mat_coat ?? "",
mat_color: iv.mat_color ?? "",
mat_opacity: iv.mat_opacity ?? "",
mat_thickness: iv.mat_thickness ?? "",
source: iv.source ?? "",
lens: iv.lens ?? "",
focus: iv.focus ?? "",
laser_soft: iv.laser_soft ?? "",
repeat_all: iv.repeat_all ?? "",
fill_settings: iv.fill_settings ?? [],
line_settings: iv.line_settings ?? [],
raster_settings: iv.raster_settings ?? [],
2025-10-03 22:54:05 -04:00
});
}
2025-10-03 21:45:57 -04:00
}, [isEdit, edit?.initialValues, reset]);
2025-09-28 14:54:52 -04:00
2025-10-04 20:19:53 -04:00
// After reset, force RHF values once
useEffect(() => {
if (!isEdit || !current) return;
const fieldNames = [
"laser_soft",
"mat",
"mat_coat",
"mat_color",
"mat_opacity",
"source",
"lens",
] as const;
const values = getValues();
fieldNames.forEach((name) => {
const cur = (current as any)[name];
const now = (values as any)[name];
if (cur && (now == null || now === "")) {
setValue(name as any, cur, { shouldDirty: false, shouldValidate: false });
}
});
}, [isEdit, current, getValues, setValue]);
2025-10-04 19:54:31 -04:00
// When options hydrate/change, re-apply the current ids so the select shows them
useEffect(() => {
if (!isEdit || !current) return;
const apply = (name: keyof typeof current) => {
const cur = (current as any)[name];
if (cur) setValue(name as any, cur, { shouldDirty: false, shouldValidate: false });
};
apply("mat");
apply("mat_coat");
apply("mat_color");
apply("mat_opacity");
apply("laser_soft");
apply("source");
apply("lens");
}, [
isEdit,
current,
setValue,
mats.opts,
coats.opts,
colors.opts,
opacs.opts,
soft.opts,
srcs.opts,
lens.opts,
]);
2025-10-02 23:19:17 -04:00
function num(v: any) {
return v === "" || v == null ? null : Number(v);
}
const bool = (v: any) => !!v;
2025-09-28 14:54:52 -04:00
2025-10-02 23:19:17 -04:00
async function onSubmit(values: any) {
setSubmitErr(null);
2025-09-28 07:41:15 -04:00
2025-10-03 22:54:05 -04:00
// In edit mode, allow keeping the existing photo (no new file) if one exists.
2025-10-02 23:19:17 -04:00
const hasExistingPhotoId =
2025-10-03 22:54:05 -04:00
!!(isEdit && typeof edit!.initialValues?.photo === "string" && edit!.initialValues.photo);
if (!photoFile && !hasExistingPhotoId && !isEdit) {
2025-10-02 23:19:17 -04:00
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
return;
}
2025-09-28 07:41:15 -04:00
2025-10-04 20:14:47 -04:00
// map UI target -> backend slug as helper
const target_slug = typeForOptions;
2025-10-02 23:19:17 -04:00
const payload: any = {
2025-10-04 20:14:47 -04:00
target, // keep original selector value (e.g. "settings_co2gal")
2025-10-04 20:19:53 -04:00
target_slug, // slug for server convenience (e.g. "co2-galvo")
2025-10-02 23:19:17 -04:00
setting_title: values.setting_title,
setting_notes: values.setting_notes || "",
mat: values.mat || null,
mat_coat: values.mat_coat || null,
mat_color: values.mat_color || null,
mat_opacity: values.mat_opacity || null,
mat_thickness: num(values.mat_thickness),
source: values.source || null,
lens: values.lens || null,
focus: num(values.focus),
2025-10-04 19:54:31 -04:00
laser_soft: values.laser_soft || null, // all targets
repeat_all: num(values.repeat_all), // all targets
2025-10-02 23:19:17 -04:00
fill_settings: (values.fill_settings || []).map((r: any) => ({
name: r.name || "",
power: num(r.power),
speed: num(r.speed),
interval: num(r.interval),
pass: num(r.pass),
type: r.type || "",
frequency: num(r.frequency),
pulse: num(r.pulse),
angle: num(r.angle),
auto: bool(r.auto),
increment: num(r.increment),
cross: bool(r.cross),
flood: bool(r.flood),
air: bool(r.air),
})),
line_settings: (values.line_settings || []).map((r: any) => ({
name: r.name || "",
power: num(r.power),
speed: num(r.speed),
perf: bool(r.perf),
cut: r.cut || "",
skip: r.skip || "",
pass: num(r.pass),
air: bool(r.air),
frequency: num(r.frequency),
pulse: num(r.pulse),
wobble: bool(r.wobble),
step: num(r.step),
size: num(r.size),
})),
raster_settings: (values.raster_settings || []).map((r: any) => ({
name: r.name || "",
power: num(r.power),
speed: num(r.speed),
type: r.type || "",
dither: r.dither || "",
halftone_cell: num(r.halftone_cell),
halftone_angle: num(r.halftone_angle),
inversion: bool(r.inversion),
interval: num(r.interval),
dot: num(r.dot),
pass: num(r.pass),
air: bool(r.air),
frequency: num(r.frequency),
pulse: num(r.pulse),
cross: bool(r.cross),
})),
};
2025-10-02 23:12:24 -04:00
2025-10-03 22:54:05 -04:00
// Ensure EDIT gets routed as a PATCH via the API
if (isEdit && edit?.submissionId != null) {
2025-10-03 21:45:57 -04:00
payload.mode = "edit";
2025-10-03 22:54:05 -04:00
payload.submission_id = edit.submissionId;
2025-10-03 21:45:57 -04:00
}
2025-10-02 23:19:17 -04:00
try {
let res: Response;
if (photoFile || screenFile) {
const form = new FormData();
2025-10-04 19:54:31 -04:00
// Directus multipart expects JSON in "data"
form.set("data", JSON.stringify(payload));
2025-10-04 20:14:47 -04:00
// Also include top-level fields for our API route compatibility
form.set("target", payload.target);
form.set("target_slug", payload.target_slug);
2025-10-02 23:19:17 -04:00
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" });
} else {
res = await fetch("/api/submit/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
2025-10-04 19:54:31 -04:00
// Directus JSON expects { data: { ... } }
2025-10-04 20:14:47 -04:00
// Also include top-level fields for our API route compatibility
body: JSON.stringify({ data: payload, target: payload.target, target_slug: payload.target_slug }),
2025-10-02 23:19:17 -04:00
credentials: "include",
});
}
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 || res.status === 403) throw new Error("You must be signed in to submit settings.");
2025-10-03 22:54:05 -04:00
throw new Error((data as any)?.error || "Submission failed");
2025-10-02 23:12:24 -04:00
}
2025-10-02 23:19:17 -04:00
2025-10-03 22:54:05 -04:00
// Success
2025-10-03 21:45:57 -04:00
if (!isEdit) {
2025-10-04 19:54:31 -04:00
// reset only on create
2025-10-03 21:45:57 -04:00
reset();
setPhotoFile(null);
setScreenFile(null);
setPhotoPreview("");
setScreenPreview("");
}
2025-10-02 23:19:17 -04:00
2025-10-03 22:54:05 -04:00
const id = (data as any)?.id ? String((data as any).id) : String(edit?.submissionId ?? "");
2025-10-04 19:54:31 -04:00
// back to success (create) or view (edit)
2025-10-03 22:54:05 -04:00
if (isEdit) {
2025-10-04 19:54:31 -04:00
// remove ?edit=1
2025-10-03 22:54:05 -04:00
const q = new URLSearchParams(sp.toString());
q.delete("edit");
router.replace(`/portal/laser-settings?${q.toString()}`);
} else {
router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`);
}
2025-10-02 23:19:17 -04:00
} catch (e: any) {
setSubmitErr(e?.message || "Submission failed");
2025-09-28 14:54:52 -04:00
}
2025-10-02 23:19:17 -04:00
}
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) {
setFile(file);
if (!file) {
setPreview("");
return;
2025-10-02 23:12:24 -04:00
}
2025-10-02 23:19:17 -04:00
const reader = new FileReader();
reader.onload = () => setPreview(String(reader.result || ""));
reader.readAsDataURL(file);
}
2025-09-28 07:41:15 -04:00
2025-10-03 22:54:05 -04:00
// Convenience strings for “Current:” (edit mode)
2025-10-02 23:19:17 -04:00
const currentPhotoId =
2025-10-03 22:54:05 -04:00
isEdit && typeof edit.initialValues?.photo === "string" ? (edit.initialValues.photo as string) : null;
2025-10-02 23:19:17 -04:00
const currentScreenId =
2025-10-03 22:54:05 -04:00
isEdit && typeof edit.initialValues?.screen === "string" ? (edit.initialValues.screen as string) : null;
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
return (
<div className="max-w-3xl mx-auto space-y-4">
2025-10-03 22:54:05 -04:00
{/* Target + Software (Software required for ALL targets) */}
2025-10-02 23:19:17 -04:00
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="block text-sm mb-1">Target</label>
<select
className="border rounded px-2 py-1"
value={target}
onChange={(e) => setTarget(e.target.value as Target)}
>
<option value="settings_fiber">Fiber</option>
<option value="settings_co2gan">CO Gantry</option>
<option value="settings_co2gal">CO Galvo</option>
<option value="settings_uv">UV</option>
</select>
</div>
<div className="flex-1 min-w-[220px]">
<FilterableSelect
label="Software"
name="laser_soft"
register={register}
options={soft.opts}
loading={soft.loading}
onQuery={soft.setQ}
required={true}
/>
</div>
</div>
2025-09-22 10:37:53 -04:00
2025-10-02 23:19:17 -04:00
{/* Submitting-as banner */}
{me ? (
<div className="text-sm text-muted-foreground">
Submitting as <span className="font-medium">{meLabel}</span>.
2025-10-02 23:12:24 -04:00
</div>
2025-10-02 23:19:17 -04:00
) : meErr ? (
<div className="border border-yellow-600 bg-yellow-50 text-yellow-800 rounded p-2 text-sm">
Youre not signed in. Submissions will fail until you sign in.
2025-10-02 23:12:24 -04:00
</div>
2025-10-02 23:19:17 -04:00
) : null}
2025-09-22 10:37:53 -04:00
2025-10-02 23:19:17 -04:00
{submitErr ? (
<div className="border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm">{submitErr}</div>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Title */}
<div className="grid md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="block text-sm mb-1">
Title <span className="text-red-600">*</span>
</label>
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div>
</div>
{/* Images */}
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm mb-1">
2025-10-03 22:54:05 -04:00
Result Photo {isEdit ? null : <span className="text-red-600">*</span>}
2025-10-02 23:19:17 -04:00
</label>
{currentPhotoId && (
<p className="text-xs text-muted-foreground mb-1">
Current: <span className="font-mono">{shortId(currentPhotoId)}</span>
2025-10-02 23:12:24 -04:00
</p>
2025-10-02 23:19:17 -04:00
)}
2025-09-28 14:54:52 -04:00
2025-10-02 23:19:17 -04:00
<input
type="file"
accept="image/*"
data-role="photo"
2025-10-03 22:54:05 -04:00
required={!isEdit && !currentPhotoId}
2025-10-02 23:19:17 -04:00
onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)}
/>
<p className="text-xs text-muted-foreground mt-1">
{photoFile ? (
2025-10-04 14:02:01 -04:00
<>
Selected: <span className="font-mono">{photoFile.name}</span>
</>
2025-10-02 23:19:17 -04:00
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
</p>
{photoPreview ? <img src={photoPreview} alt="Result preview" className="mt-2 rounded border" /> : null}
</div>
<div>
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
{currentScreenId && (
<p className="text-xs text-muted-foreground mb-1">
Current: <span className="font-mono">{shortId(currentScreenId)}</span>
2025-10-02 23:12:24 -04:00
</p>
2025-10-02 23:19:17 -04:00
)}
2025-09-22 10:37:53 -04:00
2025-10-02 23:19:17 -04:00
<input
type="file"
accept="image/*"
onChange={(e) => onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)}
/>
<p className="text-xs text-muted-foreground mt-1">
{screenFile ? (
2025-10-04 14:02:01 -04:00
<>
Selected: <span className="font-mono">{screenFile.name}</span>
</>
2025-10-02 23:19:17 -04:00
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
</p>
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
</div>
</div>
2025-10-02 23:12:24 -04:00
2025-10-02 23:19:17 -04:00
{/* Notes */}
<div>
<label className="block text-sm mb-1">Notes</label>
<textarea rows={4} className="w-full border rounded px-2 py-1" {...register("setting_notes")} />
</div>
{/* Material / Source / Lens */}
<div className="grid md:grid-cols-2 gap-3">
<FilterableSelect
label="Material"
name="mat"
register={register}
options={mats.opts}
loading={mats.loading}
onQuery={mats.setQ}
required
/>
<FilterableSelect
label="Coating"
name="mat_coat"
register={register}
options={coats.opts}
loading={coats.loading}
onQuery={coats.setQ}
required
/>
<FilterableSelect
label="Color"
name="mat_color"
register={register}
options={colors.opts}
loading={colors.loading}
onQuery={colors.setQ}
required
/>
<FilterableSelect
label="Opacity"
name="mat_opacity"
register={register}
options={opacs.opts}
loading={opacs.loading}
onQuery={opacs.setQ}
required
/>
<FilterableSelect
label="Laser Source"
name="source"
register={register}
options={srcs.opts}
loading={srcs.loading}
onQuery={srcs.setQ}
required
/>
<FilterableSelect
label="Lens"
name="lens"
register={register}
options={lens.opts}
loading={lens.loading}
onQuery={lens.setQ}
required
/>
</div>
{/* Focus, thickness, repeat_all */}
<div className="grid md:grid-cols-3 gap-3">
2025-10-03 22:54:05 -04:00
<LabeledInput label="Material Thickness (mm)" name="mat_thickness" type="number" step="0.01" register={register} />
<LabeledInput label="Focus (mm)" name="focus" type="number" min={-10} max={10} step="1" register={register} required />
<LabeledInput label="Repeat All" name="repeat_all" type="number" step="1" register={register} required />
2025-10-04 14:02:01 -04:00
<p className="text-xs text-muted-foreground md:col-span-3">
0 = in focus. Negative = focus closer. Positive = focus further.
</p>
2025-10-02 23:19:17 -04:00
</div>
{/* FILL */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Fill Settings</legend>
2025-10-03 22:54:05 -04:00
<button type="button" className="px-2 py-1 border rounded" onClick={() => fills.append({ type: "uni" })}>
2025-10-02 23:19:17 -04:00
+ Add
</button>
</div>
{fills.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
2025-10-03 22:54:05 -04:00
<LabeledInput label="Name" name={`fill_settings.${i}.name`} register={register} />
<FilterableSelect
2025-10-02 23:19:17 -04:00
label="Type"
name={`fill_settings.${i}.type`}
register={register}
2025-10-02 23:19:17 -04:00
options={fillType.opts}
loading={false}
onQuery={() => {}}
placeholder="Select type"
/>
2025-10-03 22:54:05 -04:00
<LabeledInput label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`fill_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<LabeledInput label="Power (%)" name={`fill_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`fill_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Interval (mm)" name={`fill_settings.${i}.interval`} type="number" step="0.001" register={register} />
<LabeledInput label="Pass" name={`fill_settings.${i}.pass`} type="number" step="1" register={register} />
<LabeledInput label="Angle (°)" name={`fill_settings.${i}.angle`} type="number" step="1" register={register} />
<LabeledInput label="Increment" name={`fill_settings.${i}.increment`} type="number" step="0.001" register={register} />
2025-10-02 23:19:17 -04:00
<div className="flex items-center gap-3">
<BoolBox label="Auto" name={`fill_settings.${i}.auto`} register={register} />
<BoolBox label="Cross" name={`fill_settings.${i}.cross`} register={register} />
2025-10-02 23:12:24 -04:00
</div>
2025-10-02 23:19:17 -04:00
<div className="flex items-center gap-3">
<BoolBox label="Flood" name={`fill_settings.${i}.flood`} register={register} />
<BoolBox label="Air" name={`fill_settings.${i}.air`} register={register} />
2025-09-28 07:41:15 -04:00
</div>
2025-10-02 23:19:17 -04:00
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => fills.remove(i)}>
Remove
</button>
2025-09-28 08:47:34 -04:00
</div>
2025-10-02 23:19:17 -04:00
))}
</fieldset>
{/* LINE */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Line Settings</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => lines.append({})}>
+ Add
</button>
</div>
2025-10-04 20:19:53 -04:00
{lines.fields.map((f, i) => (
2025-10-02 23:19:17 -04:00
<div key={f.id} className="grid md:grid-cols-4 gap-2">
2025-10-03 22:54:05 -04:00
<LabeledInput label="Name" name={`line_settings.${i}.name`} register={register} />
<LabeledInput label="Frequency (kHz)" name={`line_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`line_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<LabeledInput label="Power (%)" name={`line_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`line_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Perf" name={`line_settings.${i}.perf`} register={register} />
<LabeledInput label="Cut" name={`line_settings.${i}.cut`} register={register} />
<LabeledInput label="Skip" name={`line_settings.${i}.skip`} register={register} />
<LabeledInput label="Pass" name={`line_settings.${i}.pass`} type="number" step="1" register={register} />
<LabeledInput label="Step" name={`line_settings.${i}.step`} type="number" step="0.001" register={register} />
<LabeledInput label="Size" name={`line_settings.${i}.size`} type="number" step="0.001" register={register} />
2025-10-02 23:19:17 -04:00
<BoolBox label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
<BoolBox label="Air" name={`line_settings.${i}.air`} register={register} />
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => lines.remove(i)}>
Remove
2025-10-02 23:12:24 -04:00
</button>
</div>
2025-10-02 23:19:17 -04:00
))}
</fieldset>
{/* RASTER */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Raster Settings</legend>
<button
type="button"
className="px-2 py-1 border rounded"
2025-10-03 21:45:57 -04:00
onClick={() => rasters.append({ type: "uni", dither: "threshold" })}
2025-10-02 23:19:17 -04:00
>
+ Add
</button>
</div>
{rasters.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
2025-10-03 22:54:05 -04:00
<LabeledInput label="Name" name={`raster_settings.${i}.name`} register={register} />
<LabeledInput label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`raster_settings.${i}.pulse`} type="number" step="0.1" register={register} />
2025-10-02 23:19:17 -04:00
<FilterableSelect
label="Type"
name={`raster_settings.${i}.type`}
register={register}
options={rasterType.opts}
loading={false}
onQuery={() => {}}
placeholder="Select type"
/>
<FilterableSelect
label="Dither"
name={`raster_settings.${i}.dither`}
register={register}
options={rasterDither.opts}
loading={false}
onQuery={() => {}}
placeholder="Select dither"
/>
2025-10-03 22:54:05 -04:00
<LabeledInput label="Power (%)" name={`raster_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`raster_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} type="number" step="1" register={register} />
<LabeledInput label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} type="number" step="1" register={register} />
<LabeledInput label="Interval (mm)" name={`raster_settings.${i}.interval`} type="number" step="0.001" register={register} />
<LabeledInput label="Dot" name={`raster_settings.${i}.dot`} type="number" step="0.1" register={register} />
<LabeledInput label="Pass" name={`raster_settings.${i}.pass`} type="number" step="1" register={register} />
2025-10-02 23:19:17 -04:00
<BoolBox label="Cross" name={`raster_settings.${i}.cross`} register={register} />
<div className="flex items-center gap-3">
<BoolBox label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
<BoolBox label="Air" name={`raster_settings.${i}.air`} register={register} />
2025-10-02 23:12:24 -04:00
</div>
2025-10-02 23:19:17 -04:00
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => rasters.remove(i)}>
Remove
2025-10-02 23:12:24 -04:00
</button>
</div>
2025-10-02 23:19:17 -04:00
))}
</fieldset>
<button
disabled={isSubmitting}
className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50"
>
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
</button>
</form>
</div>
);
2025-09-28 11:15:37 -04:00
}