805 lines
31 KiB
TypeScript
805 lines
31 KiB
TypeScript
"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;
|
||
};
|
||
|
||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Local enums (no schema introspection)
|
||
// ─────────────────────────────────────────────────────────────
|
||
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 }));
|
||
|
||
function shortId(s?: string) {
|
||
if (!s) return "";
|
||
return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`;
|
||
}
|
||
|
||
function useOptions(path: string) {
|
||
const [opts, setOpts] = useState<Opt[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [q, setQ] = useState("");
|
||
|
||
// 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;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
setLoading(true);
|
||
|
||
(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") {
|
||
// fetch all and client-filter by nm until/if a numeric mirror field exists
|
||
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
|
||
const range = nmRangeFor(target);
|
||
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") {
|
||
// CO2 gantry uses focus lenses; all others use scan lenses
|
||
if (target === "co2-gantry") {
|
||
url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000`;
|
||
normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.name ?? r.id) }));
|
||
} else {
|
||
// SCAN LENSES (fiber, uv, co2-galvo): sort numerically by focal_length
|
||
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) };
|
||
});
|
||
};
|
||
}
|
||
} else {
|
||
// unknown path → empty
|
||
setOpts([]);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
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);
|
||
|
||
// client-side text filter
|
||
const needle = (q || "").trim().toLowerCase();
|
||
const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped;
|
||
|
||
if (alive) setOpts(filtered);
|
||
})()
|
||
.catch(() => alive && setOpts([]))
|
||
.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 selectors
|
||
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;
|
||
|
||
// 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;
|
||
};
|
||
}, []);
|
||
|
||
const meLabel =
|
||
me?.display_name?.trim() ||
|
||
[me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() ||
|
||
me?.username?.trim() ||
|
||
me?.email?.trim() ||
|
||
(me?.id ? `User ${me.id.slice(0, 8)}…${me.id.slice(-4)}` : "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"); // required for ALL targets
|
||
|
||
// these two need ?target=
|
||
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
|
||
const lens = useOptions(`lens?target=${typeForOptions}`);
|
||
|
||
// 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) => {} };
|
||
|
||
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: "", // on all targets
|
||
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" });
|
||
|
||
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, // all targets
|
||
repeat_all: num(values.repeat_all), // 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 || "", // now driven by local enum
|
||
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 || "", // now driven by local enum
|
||
dither: r.dither || "", // now driven by local enum
|
||
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),
|
||
})),
|
||
};
|
||
|
||
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 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">
|
||
You’re 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>
|
||
<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({ type: "uni" })} // default ensures value populated
|
||
>
|
||
+ 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={false}
|
||
onQuery={() => {}}
|
||
placeholder="Select type"
|
||
/>
|
||
<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`)} />
|
||
<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`)} />
|
||
<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`)} />
|
||
<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({ type: "uni", dither: "threshold" })} // defaults ensure values are set
|
||
>
|
||
+ 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`)} />
|
||
<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={false}
|
||
onQuery={() => {}}
|
||
placeholder="Select type"
|
||
/>
|
||
<FilterableSelect
|
||
label="Dither"
|
||
name={`raster_settings.${i}.dither`}
|
||
register={register}
|
||
options={rasterDither.opts}
|
||
loading={false}
|
||
onQuery={() => {}}
|
||
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`)} />
|
||
<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>
|
||
);
|
||
}
|