makearmy-app/components/forms/SettingsSubmit.tsx

540 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/forms/SettingsSubmit.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form";
import { useRouter, useSearchParams } from "next/navigation";
/** CO₂ Galvoonly version (fresh submit path) */
type Target = "settings_co2gal"; // ← limited on purpose
type Opt = { id: string; label: string };
type Me = {
id: string;
username?: string;
email?: string;
};
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
// ─────────────────────────────────────────────────────────────
// UI bits
// ─────────────────────────────────────────────────────────────
function FilterableSelect({
label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
required = false,
}: {
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]);
const filtered = useMemo(() => {
if (!filter) return options;
const f = filter.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(f));
}, [options, filter]);
return (
<div>
<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)}
/>
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
</div>
);
}
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>
);
}
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>
);
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function shortId(s?: string) {
if (!s) return "";
return s.length <= 12 ? s : `${s.slice(0, 8)}${s.slice(-4)}`;
}
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 useOptions(path: string, forceIncludeId?: string) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
(async () => {
let url = "";
let normalize: (rows: any[]) => Opt[] = (rows) =>
rows.map((r) => ({ id: String(r.id ?? r.submission_id ?? r.value), label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id) }));
if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`;
else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`;
else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`;
else if (path === "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 (path === "laser_software") url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
else if (path === "laser_source") url = `${API}/items/laser_source?fields=submission_id,make,model&limit=2000&sort=make,model`, normalize = (rows) => rows.map((r) => ({ id: String(r.submission_id), label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id) }));
else if (path === "laser_scan_lens") url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
// the three fixed lists you showed (labels per your request)
else if (path === "laser_scan_lens_config") url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`;
else if (path === "laser_scan_lens_apt") url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
else if (path === "laser_scan_lens_exp") url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
else { setOpts([]); setLoading(false); return; }
// special label for scan lenses
if (path === "laser_scan_lens") {
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) };
});
};
}
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 ?? [];
let mapped = normalize(rows);
// include currently selected ID if not in page/filter
if (forceIncludeId && !mapped.some((o: any) => String(o.id) === String(forceIncludeId))) {
mapped = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped];
}
const needle = (q || "").trim().toLowerCase();
const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped;
if (alive) setOpts(filtered);
})()
.catch(() => { if (alive) setOpts([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [path, q, forceIncludeId]);
return { opts, loading, setQ };
}
function normalizeForReset(iv: any) {
return {
...iv,
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),
lens_conf: idToString(iv.lens_conf),
lens_apt: idToString(iv.lens_apt),
lens_exp: idToString(iv.lens_exp),
};
}
type EditInitialValues = {
submission_id: string | number;
setting_title?: string;
setting_notes?: string;
photo?: string | { id?: string } | null;
screen?: string | { 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;
lens_conf?: any; lens_apt?: any; lens_exp?: any;
fill_settings?: any[] | null;
line_settings?: any[] | null;
raster_settings?: any[] | null;
};
// ─────────────────────────────────────────────────────────────
// Component (CO₂ Galvo only)
// ─────────────────────────────────────────────────────────────
export default function SettingsSubmit({
mode,
submissionId,
initialValues,
}: {
mode?: "edit";
submissionId?: string | number;
initialValues?: EditInitialValues;
}) {
const router = useRouter();
const sp = useSearchParams();
const target: Target = "settings_co2gal"; // locked
// files
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string>("");
const [screenPreview, setScreenPreview] = useState<string>("");
// errors / me
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [me, setMe] = useState<Me | null>(null);
const [meErr, setMeErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
fetch(`/api/me`, { cache: "no-store", credentials: "include" })
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((j) => { if (alive) setMe(j || null); })
.catch(() => { if (alive) setMeErr("not-signed-in"); });
return () => { alive = false; };
}, []);
const isEdit = mode === "edit" && submissionId != null;
const current = useMemo(() => (isEdit && initialValues ? normalizeForReset(initialValues) : null), [isEdit, initialValues]);
// options (galvo)
const mats = useOptions("material", current?.mat);
const coats = useOptions("material_coating", current?.mat_coat);
const colors= useOptions("material_color", current?.mat_color);
const opacs = useOptions("material_opacity", current?.mat_opacity);
const soft = useOptions("laser_software", current?.laser_soft);
const srcs = useOptions("laser_source", current?.source);
const lens = useOptions("laser_scan_lens", current?.lens);
// three fixed lists (labels per your request)
const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf); // "Lens Configuration"
const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt); // "Scan Head Aperture"
const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp); // "Beam Expander"
// form
const {
register,
handleSubmit,
control,
reset,
setValue,
getValues,
formState: { isSubmitting },
} = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
// required relations
mat: "", mat_coat: "", mat_color: "", mat_opacity: "",
source: "", lens: "", laser_soft: "",
lens_conf: "", lens_apt: "", lens_exp: "",
// numerics
focus: "", repeat_all: "", mat_thickness: "",
// repeaters (kept, but not required)
fill_settings: [], line_settings: [], raster_settings: [],
},
});
const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" });
// prefill for edit
useEffect(() => {
if (!isEdit || !current) return;
reset({
setting_title: current.setting_title ?? "",
setting_notes: current.setting_notes ?? "",
photo: current.photo ?? null,
screen: current.screen ?? null,
mat: current.mat ?? "",
mat_coat: current.mat_coat ?? "",
mat_color: current.mat_color ?? "",
mat_opacity: current.mat_opacity ?? "",
mat_thickness: current.mat_thickness ?? "",
source: current.source ?? "",
lens: current.lens ?? "",
focus: current.focus ?? "",
laser_soft: current.laser_soft ?? "",
repeat_all: current.repeat_all ?? "",
lens_conf: current.lens_conf ?? "",
lens_apt: current.lens_apt ?? "",
lens_exp: current.lens_exp ?? "",
fill_settings: current.fill_settings ?? [],
line_settings: current.line_settings ?? [],
raster_settings: current.raster_settings ?? [],
});
}, [isEdit, current, reset]);
// keep selects stable when options hydrate
useEffect(() => {
if (!isEdit || !current) return;
const names = ["mat","mat_coat","mat_color","mat_opacity","source","lens","laser_soft","lens_conf","lens_apt","lens_exp"] as const;
const values = getValues();
names.forEach((n) => {
const cur = (current as any)[n];
const now = (values as any)[n];
if (cur && (now == null || now === "")) setValue(n as any, cur, { shouldDirty: false, shouldValidate: false });
});
}, [isEdit, current, getValues, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, srcs.opts, lens.opts, soft.opts, lensConf.opts, lensApt.opts, lensExp.opts]);
// numeric / bool coercers
const num = (v: any) => (v === "" || v == null ? null : Number(v));
const bool = (v: any) => !!v;
// submit
async function onSubmit(values: any) {
setSubmitErr(null);
// required-photo logic: need a file unless edit already has a photo id
const currentPhotoId = isEdit && typeof current?.photo === "string" ? current!.photo as string : null;
if (!currentPhotoId && !photoFile) {
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
return;
}
// build payload with only required + notes/images + repeaters
const payload: any = {
target,
setting_title: values.setting_title,
setting_notes: values.setting_notes || "",
// required fields per your list
source: values.source || null,
lens: values.lens || null,
lens_conf: values.lens_conf || null,
lens_apt: values.lens_apt || null,
lens_exp: values.lens_exp || null,
focus: num(values.focus),
mat: values.mat || null,
mat_coat: values.mat_coat || null,
mat_color: values.mat_color || null,
mat_opacity: values.mat_opacity || null,
laser_soft: values.laser_soft || null,
repeat_all: num(values.repeat_all),
// nice-to-have
mat_thickness: num(values.mat_thickness),
// server auto-sets uploader from owner, but include a mirror if available
...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}),
// repeaters (optional pass-through)
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: bool(r.cut), skip: bool(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),
})),
};
// edit meta
if (isEdit && submissionId != null) {
payload.mode = "edit";
payload.submission_id = submissionId;
}
try {
const form = new FormData();
form.set("payload", JSON.stringify(payload)); // server route reads "payload"
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
const res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" });
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.");
throw new Error((data as any)?.error || "Submission failed");
}
if (!isEdit) {
reset();
setPhotoFile(null); setScreenFile(null);
setPhotoPreview(""); setScreenPreview("");
}
const id = (data as any)?.id ? String((data as any).id) : String(submissionId ?? "");
if (isEdit) {
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)}`);
}
} catch (e: any) {
setSubmitErr(e?.message || "Submission failed");
}
}
function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) {
setFile(file);
if (!file) { setPreview(""); return; }
const reader = new FileReader();
reader.onload = () => setPreview(String(reader.result || ""));
reader.readAsDataURL(file);
}
const currentPhotoId = isEdit && typeof current?.photo === "string" ? (current!.photo as string) : null;
const currentScreenId = isEdit && typeof current?.screen === "string" ? (current!.screen as string) : null;
return (
<div className="max-w-3xl mx-auto space-y-4">
{/* Banner */}
{me ? (
<div className="text-sm text-muted-foreground">
Submitting as <span className="font-medium">{me.username || me.email || `User ${shortId(me.id)}`}</span>.
</div>
) : 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.
</div>
) : null}
{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>
<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>
{/* Images */}
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm mb-1">Result Photo {!currentPhotoId ? <span className="text-red-600">*</span> : null}</label>
{currentPhotoId && <p className="text-xs text-muted-foreground mb-1">Current: <span className="font-mono">{shortId(currentPhotoId)}</span></p>}
<input type="file" accept="image/*" data-role="photo" required={!currentPhotoId && !photoFile} onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} />
<p className="text-xs text-muted-foreground mt-1">{photoFile ? <>Selected: <span className="font-mono">{photoFile.name}</span></> : "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></p>}
<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 ? <>Selected: <span className="font-mono">{screenFile.name}</span></> : "Max 25 MB. JPG/PNG/WebP recommended."}</p>
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
</div>
</div>
{/* 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>
{/* Required Selects (materials, 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>
{/* Numeric requireds */}
<div className="grid md:grid-cols-3 gap-3">
<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 />
<LabeledInput label="Material Thickness (mm)" name="mat_thickness" type="number" step="0.01" register={register} />
<p className="text-xs text-muted-foreground md:col-span-3">0 = in focus. Negative = closer. Positive = further.</p>
</div>
{/* Lens Configuration block (all required) */}
<fieldset className="border rounded p-3 space-y-2">
<legend className="font-semibold">Lens Options</legend>
<div className="grid md:grid-cols-3 gap-3">
<FilterableSelect label="Lens Configuration" name="lens_conf" register={register} options={lensConf.opts} loading={lensConf.loading} onQuery={lensConf.setQ} required />
<FilterableSelect label="Scan Head Aperture" name="lens_apt" register={register} options={lensApt.opts} loading={lensApt.loading} onQuery={lensApt.setQ} required />
<FilterableSelect label="Beam Expander" name="lens_exp" register={register} options={lensExp.opts} loading={lensExp.loading} onQuery={lensExp.setQ} required />
</div>
</fieldset>
{/* Optional repeaters kept for parity (not required) */}
{/* Feel free to hide these until after MVP if you want */}
<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>
);
}