makearmy-app/app/components/forms/SettingsSubmit.tsx
2025-09-28 07:41:15 -04:00

433 lines
19 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.

"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form";
import { useRouter, useSearchParams } from "next/navigation";
type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv";
type Opt = { id: string; label: string };
type Me = {
id: string;
username?: string;
display_name?: string;
first_name?: string;
last_name?: string;
email?: string;
};
function useOptions(path: string) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`;
fetch(url, { cache: "no-store", credentials: "include" })
.then((r) => r.json())
.then((j) => {
if (!alive) return;
const raw = (j?.data ?? j) as any[];
const normalized: Opt[] = Array.isArray(raw)
? raw
.map((x) => ({
id: String(x?.id ?? x?.value ?? x?.key ?? ""),
label: String(x?.label ?? x?.name ?? x?.title ?? x?.text ?? ""),
}))
.filter((o) => o.id && o.label)
: [];
setOpts(normalized);
})
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [path, q]);
return { opts, loading, setQ };
}
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>
);
}
export default function SettingsSubmit({ initialTarget }: { initialTarget?: Target }) {
const router = useRouter();
const sp = useSearchParams();
const initialFromQuery = (sp.get("target") as Target) || initialTarget || "settings_fiber";
const [target, setTarget] = useState<Target>(initialFromQuery);
// Map collection -> slug used by options endpoints
const typeForOptions = useMemo(() => {
switch (target) {
case "settings_fiber": return "fiber";
case "settings_uv": return "uv";
case "settings_co2gan": return "co2-gantry";
case "settings_co2gal": return "co2-galvo";
default: return "fiber";
}
}, [target]);
// Image inputs
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string>("");
const [screenPreview, setScreenPreview] = useState<string>("");
// UX error for auth/submit
const [submitErr, setSubmitErr] = useState<string | null>(null);
// Current signed-in user (banner only)
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?.data || j || null); })
.catch(() => { if (alive) setMeErr("not-signed-in"); });
return () => { alive = false; };
}, []);
// Prefer username; avoid falling back to ID
const meLabel =
(me?.username && me.username.trim()) ||
(me?.email && me.email.trim()) ||
([me?.first_name, me?.last_name].filter(Boolean).join(" ").trim()) ||
(me?.display_name && me.display_name.trim()) ||
"Unknown user";
// Options
const mats = useOptions("material");
const coats = useOptions("material_coating");
const colors = useOptions("material_color");
const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software");
// IMPORTANT: your API expects ?target=, not ?type=
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
const lens = useOptions(`lens?target=${typeForOptions}`);
// Repeater choice options
const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`);
const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`);
const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`);
const { register, handleSubmit, control, reset, formState: { isSubmitting } } = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
mat: "", mat_coat: "", mat_color: "", mat_opacity: "",
mat_thickness: "", source: "", lens: "", focus: "",
laser_soft: "", repeat_all: "",
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" });
const isGantry = target === "settings_co2gan";
const isFiber = target === "settings_fiber";
function num(v: any) { return (v === "" || v == null) ? null : Number(v); }
const bool = (v: any) => !!v;
async function onSubmit(values: any) {
setSubmitErr(null);
if (!photoFile) {
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
return;
}
const payload: any = {
target,
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),
laser_soft: values.laser_soft || null, // <-- always include for ALL targets
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),
})),
};
if (isFiber) {
payload.repeat_all = num(values.repeat_all); // still Fiber-only
}
try {
let res: Response;
if (photoFile || screenFile) {
const form = new FormData();
form.set("payload", JSON.stringify(payload));
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" },
body: JSON.stringify(payload),
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?.error || "Submission failed");
}
reset();
setPhotoFile(null);
setScreenFile(null);
setPhotoPreview("");
setScreenPreview("");
const id = data?.id ? String(data.id) : "";
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);
}
return (
<div className="max-w-3xl mx-auto space-y-4">
{/* Target + Software (Software is required for ALL targets) */}
<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>
{/* Submitting-as banner */}
{me ? (
<div className="text-sm text-muted-foreground">
Submitting as <span className="font-medium">{meLabel}</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 continues unchanged from your current version)… */}
{/* I left the rest of your form intact aside from the changes above. */}
{/* -- Title, Images, Notes, Material/Source/Lens, Focus, Fill/Line/Raster, Submit -- */}
{/* Title */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<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">Result Photo <span className="text-red-600">*</span></label>
<input type="file" accept="image/*" data-role="photo" required
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>
<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>
{/* 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 />
{/* Fixed: pass ?target= to these two */}
<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">
<div>
<label className="block text-sm mb-1">Material Thickness (mm)</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1" {...register("mat_thickness")} />
</div>
<div>
<label className="block text-sm mb-1">Focus (mm) <span className="text-red-600">*</span></label>
<input type="number" min={-10} max={10} step="1" className="w-full border rounded px-2 py-1" {...register("focus", { required: true })} />
<p className="text-xs text-muted-foreground mt-1">0 = in focus. Negative = focus closer. Positive = focus further.</p>
</div>
{target === "settings_fiber" && (
<div>
<label className="block text-sm mb-1">Repeat All <span className="text-red-600">*</span></label>
<input type="number" step="1" className="w-full border rounded px-2 py-1" {...register("repeat_all", { required: true })} />
</div>
)}
</div>
{/* FILL / LINE / RASTER (unchanged from your current) */}
{/* ... keep your existing repeaters here ... */}
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50">
{isSubmitting ? "Submitting…" : "Submit Settings"}
</button>
</form>
</div>
);
}