diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 00000000..a89a79fc --- /dev/null +++ b/app/api/me/route.ts @@ -0,0 +1,16 @@ +// app/api/me/route.ts +import { NextResponse } from "next/server"; + +export async function GET(req: Request) { + const cookie = req.headers.get("cookie") || ""; + const base = process.env.NEXT_PUBLIC_API_BASE_URL!; + const url = `${base}/users/me?fields=id,display_name,first_name,last_name,email`; + + const res = await fetch(url, { headers: { cookie }, cache: "no-store" }); + const body = await res.json().catch(() => ({})); + + return new NextResponse(JSON.stringify(body), { + status: res.status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/app/components/forms/SettingsSubmit.tsx b/app/components/forms/SettingsSubmit.tsx index 2aaafa50..e2732215 100644 --- a/app/components/forms/SettingsSubmit.tsx +++ b/app/components/forms/SettingsSubmit.tsx @@ -6,6 +6,13 @@ 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([]); @@ -16,11 +23,17 @@ function useOptions(path: string) { let alive = true; setLoading(true); const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`; - fetch(url, { cache: "no-store" }) + 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; }; + .then((j) => { + if (alive) setOpts((j?.data as Opt[]) ?? []); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; }, [path, q]); return { opts, loading, setQ }; @@ -60,7 +73,9 @@ function FilterableSelect({ /> ); @@ -88,435 +103,540 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const [photoPreview, setPhotoPreview] = useState(""); const [screenPreview, setScreenPreview] = useState(""); - // 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 + // UX error for auth/submit + const [submitErr, setSubmitErr] = useState(null); - // 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 + // Current signed-in user (for banner only) + const [me, setMe] = useState(null); + const [meErr, setMeErr] = useState(null); - // 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`); + 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 { register, handleSubmit, control, reset, formState: { isSubmitting } } = useForm({ - defaultValues: { - setting_title: "", uploader: "", 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 meLabel = + me?.display_name || + [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() || + me?.email || + me?.id || + ""; - const fills = useFieldArray({ control, name: "fill_settings" }); - const lines = useFieldArray({ control, name: "line_settings" }); - const rasters = useFieldArray({ control, name: "raster_settings" }); +// 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 - const isGantry = target === "settings_co2gan"; - const isFiber = target === "settings_fiber"; - // const isUV = target === "settings_uv"; - // const isCO2Gal = target === "settings_co2gal"; +// 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 - function num(v: any) { return (v === "" || v == null) ? null : Number(v); } - const bool = (v: any) => !!v; +// 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`); - async function onSubmit(values: any) { - // Browser validation for required photo - if (!photoFile) { - // Let the native required attribute handle focus/UX, - // but this is a safety net in case the browser skips it. - (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); - return; - } +const { register, handleSubmit, control, reset, formState: { isSubmitting } } = useForm({ + 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 payload: any = { - target, - setting_title: values.setting_title, - uploader: values.uploader, - 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), +const fills = useFieldArray({ control, name: "fill_settings" }); +const lines = useFieldArray({ control, name: "line_settings" }); +const rasters = useFieldArray({ control, name: "raster_settings" }); - 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), - })), +const isGantry = target === "settings_co2gan"; +const isFiber = target === "settings_fiber"; - 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), - })), +function num(v: any) { return (v === "" || v == null) ? null : Number(v); } +const bool = (v: any) => !!v; - 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), - })), - }; +async function onSubmit(values: any) { + setSubmitErr(null); - if (isFiber) { - payload.laser_soft = values.laser_soft || null; - payload.repeat_all = num(values.repeat_all); - } + // Result photo required on all + if (!photoFile) { + (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); + return; + } - // Decide JSON vs multipart (include images when present) - let res: Response; + 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 }); + 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) throw new Error(data?.error || "Submission failed"); + 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 + file previews + // Clear form + previews reset(); setPhotoFile(null); setScreenFile(null); setPhotoPreview(""); setScreenPreview(""); - // Redirect to success page (don’t show alert) const id = data?.id ? String(data.id) : ""; - router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(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 ( -
- {/* Target + (fiber) software */} -
-
- - -
- - {isFiber && ( -
- -
- )} -
- -
- {/* Title / Uploader */} -
-
- - -
-
- - -
-
- - {/* Images */} -
-
- - onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} - /> -

- {photoFile ? <>Selected: {photoFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."} -

- {photoPreview ? ( - Result preview - ) : null} -
-
- - onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} - /> -

- {screenFile ? <>Selected: {screenFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."} -

- {screenPreview ? ( - Settings preview - ) : null} -
-
- - {/* Notes */} -
- -