makearmy-app/app/components/forms/SettingsSubmit.tsx

642 lines
25 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;
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) setOpts((j?.data as Opt[]) ?? []);
})
.finally(() => {
if (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);
// Image inputs (for preview + multipart submit)
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 (for 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; };
}, []);
const meLabel =
me?.display_name ||
[me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() ||
me?.email ||
me?.id ||
"";
// Generic lists (alphabetical)
const mats = useOptions("material");
const coats = useOptions("material_coating");
const colors = useOptions("material_color");
const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software"); // only visible for fiber
// Target-driven lists
const srcs = useOptions(`laser_source?target=${target}`); // wavelength filter server-side
const lens = useOptions(`lens?target=${target}`); // scan vs focus lens by target
// Repeater select choices from Directus field config
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);
// Result photo required on all
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),
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 || "",
// present on fiber/uv/co2gal
frequency: num(r.frequency),
pulse: num(r.pulse),
angle: num(r.angle),
auto: bool(r.auto),
increment: num(r.increment),
cross: bool(r.cross),
// present on all
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),
// extra on fiber/uv/co2gal
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),
// extras on fiber/uv/co2gal
frequency: num(r.frequency),
pulse: num(r.pulse),
cross: bool(r.cross),
})),
};
if (isFiber) {
payload.laser_soft = values.laser_soft || null;
payload.repeat_all = num(values.repeat_all);
}
let res: Response;
try {
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");
}
// Clear form + previews
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");
}
}
// File input helpers
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 + (fiber) software */}
<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>
{isFiber && (
<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 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">
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 />
<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>
{isFiber && (
<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 */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Fill Settings</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => fills.append({})}>
+ Add
</button>
</div>
{fills.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<input placeholder="Name" className="border rounded px-2 py-1 md:col-span-2" {...register(`fill_settings.${i}.name`)} />
<FilterableSelect
label="Type"
name={`fill_settings.${i}.type`}
register={register}
options={fillType.opts}
loading={fillType.loading}
onQuery={fillType.setQ}
placeholder="Select type"
/>
{!isGantry && (
<>
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.frequency`)} />
<input placeholder="Pulse (ns)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.pulse`)} />
</>
)}
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.power`)} />
<input placeholder="Speed (mm/s)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.speed`)} />
<input placeholder="Interval (mm)" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.interval`)} />
<input placeholder="Pass" type="number" step="1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.pass`)} />
{!isGantry && (
<>
<input placeholder="Angle (°)" type="number" step="1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.angle`)} />
<input placeholder="Increment" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.increment`)} />
<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} />
</div>
</>
)}
<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} />
</div>
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => fills.remove(i)}>
Remove
</button>
</div>
))}
</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>
{lines.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<input placeholder="Name" className="border rounded px-2 py-1 md:col-span-2" {...register(`line_settings.${i}.name`)} />
{!isGantry && (
<>
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.frequency`)} />
<input placeholder="Pulse (ns)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.pulse`)} />
</>
)}
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.power`)} />
<input placeholder="Speed (mm/s)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.speed`)} />
<input placeholder="Perf" className="border rounded px-2 py-1" {...register(`line_settings.${i}.perf`)} />
<input placeholder="Cut" className="border rounded px-2 py-1" {...register(`line_settings.${i}.cut`)} />
<input placeholder="Skip" className="border rounded px-2 py-1" {...register(`line_settings.${i}.skip`)} />
<input placeholder="Pass" type="number" step="1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.pass`)} />
{!isGantry && (
<>
<input placeholder="Step" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`line_settings.${i}.step`)} />
<input placeholder="Size" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`line_settings.${i}.size`)} />
<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
</button>
</div>
))}
</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" onClick={() => rasters.append({})}>
+ Add
</button>
</div>
{rasters.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<input placeholder="Name" className="border rounded px-2 py-1 md:col-span-2" {...register(`raster_settings.${i}.name`)} />
{!isGantry && (
<>
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.frequency`)} />
<input placeholder="Pulse (ns)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.pulse`)} />
</>
)}
<FilterableSelect
label="Type"
name={`raster_settings.${i}.type`}
register={register}
options={rasterType.opts}
loading={rasterType.loading}
onQuery={rasterType.setQ}
placeholder="Select type"
/>
<FilterableSelect
label="Dither"
name={`raster_settings.${i}.dither`}
register={register}
options={rasterDither.opts}
loading={rasterDither.loading}
onQuery={rasterDither.setQ}
placeholder="Select dither"
/>
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.power`)} />
<input placeholder="Speed (mm/s)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.speed`)} />
<input placeholder="Halftone Cell" type="number" step="1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.halftone_cell`)} />
<input placeholder="Halftone Angle" type="number" step="1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.halftone_angle`)} />
<input placeholder="Interval (mm)" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.interval`)} />
<input placeholder="Dot" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.dot`)} />
<input placeholder="Pass" type="number" step="1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.pass`)} />
{!isGantry && <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} />
</div>
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => rasters.remove(i)}>
Remove
</button>
</div>
))}
</fieldset>
<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>
);
}