prefilled settings edits

This commit is contained in:
makearmy 2025-10-03 22:54:05 -04:00
parent 7e9b5adfa8
commit 494f5525af
2 changed files with 259 additions and 227 deletions

View file

@ -10,9 +10,10 @@ type Rec = {
setting_title?: string | null;
setting_notes?: string | null;
photo?: { id?: string } | string | number | null;
screen?: { id?: string } | string | number | null;
photo?: { id?: string } | string | null;
screen?: { id?: string } | string | null;
// ids & readable fields
mat?: { id?: string | number; name?: string | null } | null;
mat_coat?: { id?: string | number; name?: string | null } | null;
mat_color?: { id?: string | number; name?: string | null } | null;
@ -23,7 +24,7 @@ type Rec = {
lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null;
focus?: number | null;
laser_soft?: any;
laser_soft?: { id?: string | number; name?: string | null } | string | number | null;
repeat_all?: number | null;
fill_settings?: any[] | null;
@ -32,6 +33,7 @@ type Rec = {
owner?: { id?: string | number; username?: string | null } | string | number | null;
uploader?: string | null;
last_modified_date?: string | null;
};
@ -40,22 +42,35 @@ async function readJson(res: Response) {
try { return text ? JSON.parse(text) : null; } catch { throw new Error(`Unexpected response (HTTP ${res.status})`); }
}
const ownerLabel = (o: Rec["owner"]) =>
!o ? "—" : (typeof o === "string" || typeof o === "number") ? String(o) : (o.username || String(o.id ?? "—"));
function ownerLabel(o: Rec["owner"]) {
if (!o) return "—";
if (typeof o === "string" || typeof o === "number") return String(o);
return o.username || String(o.id ?? "—");
}
const isMine = (owner: Rec["owner"], meId: string | null) =>
!!meId && !!owner && ((typeof owner === "string" || typeof owner === "number") ? String(owner) === meId : (owner.id != null && String(owner.id) === meId));
const resolveFileId = (v: Rec["photo"]): string | null =>
v == null ? null : (typeof v === "string" || typeof v === "number") ? String(v) : v.id ? String(v.id) : null;
// ✅ Use public Directus assets (works on your stack). Fallback to proxy if no base configured.
// Prefer public assets if available (avoids auth cookie issues in <img>)
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
function fileUrl(id?: string) {
if (!id) return "";
return API_BASE ? `${API_BASE}/assets/${id}` : `/api/dx/assets/${id}`;
}
function ZoomableSquareImage(props: { src: string; alt: string; onOpen: () => void }) {
const { src, alt, onOpen } = props;
return (
<div className="border rounded overflow-hidden" style={{ aspectRatio: "1 / 1" }}>
<img
src={src}
alt={alt}
className="w-full h-full object-cover cursor-zoom-in"
loading="lazy"
onClick={onOpen}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
);
}
export default function CO2GalvoDetail({
id,
mode,
@ -78,10 +93,8 @@ export default function CO2GalvoDetail({
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
// Current user id to gate the Edit button
const [meId, setMeId] = useState<string | null>(null);
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
// me id (for owner-gated Edit button)
useEffect(() => {
let dead = false;
(async () => {
@ -89,14 +102,22 @@ export default function CO2GalvoDetail({
const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
if (!r.ok) return;
const j = await readJson(r);
const mid = j?.data?.id ?? j?.id ?? null;
if (!dead) setMeId(mid ? String(mid) : null);
const id = j?.data?.id ?? j?.id ?? null;
if (!dead) setMeId(id ? String(id) : null);
} catch { /* ignore */ }
})();
return () => { dead = true; };
}, []);
// load record (readable fields)
// lightbox
const [viewerSrc, setViewerSrc] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setViewerSrc(null); };
if (viewerSrc) window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [viewerSrc]);
// load record (with human-readable fields, +laser_soft.name)
useEffect(() => {
if (!id) return;
let dead = false;
@ -107,19 +128,49 @@ export default function CO2GalvoDetail({
setErr(null);
const fields = [
"submission_id","setting_title","setting_notes",
"photo.id","screen.id",
"mat.id","mat.name","mat_coat.id","mat_coat.name","mat_color.id","mat_color.name","mat_opacity.id","mat_opacity.opacity","mat_thickness",
"source.submission_id","source.make","source.model","source.nm",
"lens.id","lens.field_size","lens.focal_length",
"focus","laser_soft","repeat_all",
"fill_settings","line_settings","raster_settings",
"owner.id","owner.username","uploader","last_modified_date",
"submission_id",
"setting_title",
"setting_notes",
"photo.id",
"screen.id",
"mat.id",
"mat.name",
"mat_coat.id",
"mat_coat.name",
"mat_color.id",
"mat_color.name",
"mat_opacity.id",
"mat_opacity.opacity",
"mat_thickness",
"source.submission_id",
"source.make",
"source.model",
"source.nm",
"lens.id",
"lens.field_size",
"lens.focal_length",
"focus",
"laser_soft.id",
"laser_soft.name",
"repeat_all",
"fill_settings",
"line_settings",
"raster_settings",
"owner.id",
"owner.username",
"uploader",
"last_modified_date",
].join(",");
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(
fields
)}&filter[submission_id][_eq]=${encodeURIComponent(String(id))}&limit=1`;
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&filter[submission_id][_eq]=${encodeURIComponent(
String(id)
)}&limit=1`;
const r = await fetch(url, { cache: "no-store", credentials: "include" });
if (!r.ok) {
@ -140,13 +191,12 @@ export default function CO2GalvoDetail({
return () => { dead = true; };
}, [id]);
// initial values for edit form
const initialValues = useMemo(() => {
if (!rec) return null;
const toId = (v: any) => (v == null ? null : typeof v === "object" ? v.id ?? v.submission_id ?? null : v);
const toId = (v: any) => (v == null ? null : (typeof v === "object" ? (v.id ?? v.submission_id ?? null) : v));
const photoId = resolveFileId(rec.photo);
const screenId = resolveFileId(rec.screen);
const photoId = typeof rec.photo === "string" || typeof rec.photo === "number" ? String(rec.photo) : rec.photo?.id ?? null;
const screenId = typeof rec.screen === "string" || typeof rec.screen === "number" ? String(rec.screen) : rec.screen?.id ?? null;
const matId = toId(rec.mat);
const coatId = toId(rec.mat_coat);
@ -158,35 +208,29 @@ export default function CO2GalvoDetail({
return {
setting_title: rec.setting_title ?? "",
setting_notes: rec.setting_notes ?? "",
photo: photoId,
photo: photoId,
screen: screenId,
mat: matId ? String(matId) : null,
mat_coat: coatId ? String(coatId) : null,
mat_color: colorId ? String(colorId) : null,
mat_opacity: opacityId ? String(opacityId) : null,
mat_thickness: rec.mat_thickness ?? null,
source: sourceId != null ? String(sourceId) : null,
lens: lensId != null ? String(lensId) : null,
focus: rec.focus ?? null,
laser_soft: rec.laser_soft ?? null,
laser_soft: (typeof rec.laser_soft === "object" ? rec.laser_soft?.id : (rec.laser_soft as any)) ?? null,
repeat_all: rec.repeat_all ?? null,
fill_settings: rec.fill_settings ?? [],
line_settings: rec.line_settings ?? [],
fill_settings: rec.fill_settings ?? [],
line_settings: rec.line_settings ?? [],
raster_settings: rec.raster_settings ?? [],
};
}, [rec]);
// derive image URLs directly (no proxy/blob)
const photoUrl = useMemo(() => {
const pid = resolveFileId(rec?.photo ?? null);
return pid ? fileUrl(pid) : null;
}, [rec?.photo]);
const screenUrl = useMemo(() => {
const sid = resolveFileId(rec?.screen ?? null);
return sid ? fileUrl(sid) : null;
}, [rec?.screen]);
function clearEditParam() {
const params = new URLSearchParams(sp.toString());
params.delete("edit");
@ -201,15 +245,13 @@ export default function CO2GalvoDetail({
);
if (!rec) return <p className="p-6">Setting not found.</p>;
// EDIT mode
// ── EDIT MODE ───────────────────────────────────────────────
if (editMode && initialValues) {
return (
<main className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl lg:text-2xl font-semibold">Edit CO Galvo Setting</h1>
<button className="px-2 py-1 border rounded text-xs" onClick={() => (onBack ? onBack() : clearEditParam())}>
Cancel
</button>
<button className="px-2 py-1 border rounded" onClick={() => (onBack ? onBack() : clearEditParam())}>Cancel</button>
</div>
<SettingsSubmit
mode="edit"
@ -221,9 +263,24 @@ export default function CO2GalvoDetail({
);
}
// VIEW mode
// ── VIEW MODE (readable) ───────────────────────────────────
const ownerDisplay = ownerLabel(rec.owner);
const canEdit = showOwnerEdit && isMine(rec.owner, meId);
const ownerId = typeof rec.owner === "object" ? (rec.owner?.id != null ? String(rec.owner.id) : null) : (rec.owner != null ? String(rec.owner) : null);
const isMine = meId && ownerId ? meId === ownerId : false;
const photoId = typeof rec.photo === "object" ? rec.photo?.id : (rec.photo as any);
const screenId = typeof rec.screen === "object" ? rec.screen?.id : (rec.screen as any);
const photoSrc = photoId ? fileUrl(String(photoId)) : "";
const screenSrc = screenId ? fileUrl(String(screenId)) : "";
const softName = typeof rec.laser_soft === "object" ? (rec.laser_soft?.name ?? "—") : "—";
function openEdit() {
const q = new URLSearchParams(sp.toString());
q.set("edit", "1");
router.replace(`?${q.toString()}`, { scroll: false });
}
return (
<div className="space-y-6">
@ -233,7 +290,6 @@ export default function CO2GalvoDetail({
</header>
<section className="grid md:grid-cols-2 gap-6">
{/* Meta */}
<div className="space-y-2">
<div className="text-sm">
<div><span className="font-medium">Owner:</span> {ownerDisplay}</div>
@ -246,7 +302,7 @@ export default function CO2GalvoDetail({
<div><span className="font-medium">Laser Source:</span> {[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") || "—"}{rec.source?.nm ? ` (${rec.source.nm})` : ""}</div>
<div><span className="font-medium">Scan Lens:</span> {rec.lens?.field_size || "—"}{rec.lens?.focal_length ? ` / ${rec.lens.focal_length} mm` : ""}</div>
<div><span className="font-medium">Focus (mm):</span> {rec.focus ?? "—"}</div>
<div><span className="font-medium">Software:</span> {rec.laser_soft ?? "—"}</div>
<div><span className="font-medium">Software:</span> {softName}</div>
<div><span className="font-medium">Repeat All:</span> {rec.repeat_all ?? "—"}</div>
</div>
@ -257,58 +313,24 @@ export default function CO2GalvoDetail({
</div>
) : null}
{canEdit && (
{showOwnerEdit && isMine && (
<div className="pt-2">
<button
type="button"
onClick={() =>
router.push(`/portal/laser-settings?t=co2-galvo&view=detail&id=${rec.submission_id}&edit=1`)
}
className="inline-flex items-center rounded border px-2 py-1 text-xs hover:bg-muted"
>
Edit
</button>
<button onClick={openEdit} className="px-2 py-1 border rounded text-sm">Edit</button>
</div>
)}
</div>
{/* Images: 1:1 thumbs + click to enlarge; only render when we have a URL */}
<div className="grid grid-cols-2 gap-4">
{photoUrl ? (
<figure className="border rounded overflow-hidden">
<button
type="button"
onClick={() => setLightbox({ src: photoUrl, alt: "Result" })}
className="block w-full max-w-[320px] aspect-square"
>
<img
src={photoUrl}
alt="Result"
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</button>
<figcaption className="text-xs p-1 text-muted-foreground">Result</figcaption>
{photoSrc ? (
<figure className="space-y-1">
<ZoomableSquareImage src={photoSrc} alt="Result" onOpen={() => setViewerSrc(photoSrc)} />
<figcaption className="text-xs text-muted-foreground">Result</figcaption>
</figure>
) : null}
{screenUrl ? (
<figure className="border rounded overflow-hidden">
<button
type="button"
onClick={() => setLightbox({ src: screenUrl, alt: "Settings Screenshot" })}
className="block w-full max-w-[320px] aspect-square"
>
<img
src={screenUrl}
alt="Settings Screenshot"
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</button>
<figcaption className="text-xs p-1 text-muted-foreground">Settings Screenshot</figcaption>
{screenSrc ? (
<figure className="space-y-1">
<ZoomableSquareImage src={screenSrc} alt="Settings Screenshot" onOpen={() => setViewerSrc(screenSrc)} />
<figcaption className="text-xs text-muted-foreground">Settings Screenshot</figcaption>
</figure>
) : null}
</div>
@ -420,19 +442,9 @@ export default function CO2GalvoDetail({
</section>
)}
{lightbox && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setLightbox(null)}
role="dialog"
aria-modal="true"
>
<img
src={lightbox.src}
alt={lightbox.alt}
className="max-w-[90vw] max-h-[90vh] object-contain rounded"
onClick={(e) => e.stopPropagation()}
/>
{viewerSrc && (
<div className="fixed inset-0 z-50 bg-black/80 p-4 flex items-center justify-center" onClick={() => setViewerSrc(null)}>
<img src={viewerSrc} alt="" className="max-w-full max-h-full cursor-zoom-out" onClick={(e) => e.stopPropagation()} />
</div>
)}
</div>

View file

@ -167,10 +167,12 @@ function useOptions(path: string) {
}));
};
} 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) => {
@ -187,6 +189,7 @@ function useOptions(path: string) {
};
}
} else {
// unknown path → empty
setOpts([]);
setLoading(false);
return;
@ -198,6 +201,7 @@ function useOptions(path: string) {
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;
@ -281,6 +285,43 @@ function BoolBox({ label, name, register }: { label: string; name: string; regis
);
}
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>
);
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
@ -289,11 +330,12 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
const sp = useSearchParams();
const isEdit = isEditProps(props);
const edit = isEdit ? props : null;
const edit = isEdit ? props : null; // strongly-typed local when edit
const initialFromQuery = (sp.get("target") as Target) || props.initialTarget || "settings_fiber";
const [target, setTarget] = useState<Target>(initialFromQuery);
// Map collection -> slug used by options selectors
const typeForOptions = useMemo(() => {
switch (target) {
case "settings_fiber":
@ -325,6 +367,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
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) => {
@ -347,12 +390,13 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
const coats = useOptions("material_coating");
const colors = useOptions("material_color");
const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software");
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)
// 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) => {} };
@ -376,7 +420,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
lens: "",
focus: "",
laser_soft: "",
repeat_all: "",
repeat_all: "", // on all targets
fill_settings: [],
line_settings: [],
raster_settings: [],
@ -387,30 +431,29 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" });
// ✅ Prefill when editing
// Prefill the form in edit mode
useEffect(() => {
if (!isEdit || !edit?.initialValues) return;
const S = (v: any) => (v == null ? "" : String(v));
reset({
setting_title: edit.initialValues.setting_title ?? "",
setting_notes: edit.initialValues.setting_notes ?? "",
mat: S(edit.initialValues.mat),
mat_coat: S(edit.initialValues.mat_coat),
mat_color: S(edit.initialValues.mat_color),
mat_opacity: S(edit.initialValues.mat_opacity),
mat_thickness: edit.initialValues.mat_thickness ?? "",
source: S(edit.initialValues.source),
lens: S(edit.initialValues.lens),
focus: edit.initialValues.focus ?? "",
laser_soft: S(edit.initialValues.laser_soft),
repeat_all: edit.initialValues.repeat_all ?? "",
fill_settings: edit.initialValues.fill_settings ?? [],
line_settings: edit.initialValues.line_settings ?? [],
raster_settings: edit.initialValues.raster_settings ?? [],
});
// we keep previews empty; “Current: <id>” is shown below file inputs
if (isEdit && edit?.initialValues) {
reset({
setting_title: edit.initialValues.setting_title ?? "",
setting_notes: edit.initialValues.setting_notes ?? "",
photo: edit.initialValues.photo ?? null,
screen: edit.initialValues.screen ?? null,
mat: edit.initialValues.mat ?? "",
mat_coat: edit.initialValues.mat_coat ?? "",
mat_color: edit.initialValues.mat_color ?? "",
mat_opacity: edit.initialValues.mat_opacity ?? "",
mat_thickness: edit.initialValues.mat_thickness ?? "",
source: edit.initialValues.source ?? "",
lens: edit.initialValues.lens ?? "",
focus: edit.initialValues.focus ?? "",
laser_soft: edit.initialValues.laser_soft ?? "",
repeat_all: edit.initialValues.repeat_all ?? "",
fill_settings: edit.initialValues.fill_settings ?? [],
line_settings: edit.initialValues.line_settings ?? [],
raster_settings: edit.initialValues.raster_settings ?? [],
});
}
}, [isEdit, edit?.initialValues, reset]);
function num(v: any) {
@ -421,10 +464,10 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
async function onSubmit(values: any) {
setSubmitErr(null);
// In edit mode, allow keeping the existing photo (no new file) if one exists.
const hasExistingPhotoId =
!!(isEdit && typeof edit?.initialValues?.photo === "string" && edit?.initialValues?.photo);
if (!photoFile && !hasExistingPhotoId) {
!!(isEdit && typeof edit!.initialValues?.photo === "string" && edit!.initialValues.photo);
if (!photoFile && !hasExistingPhotoId && !isEdit) {
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
return;
}
@ -441,8 +484,8 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
source: values.source || null,
lens: values.lens || null,
focus: num(values.focus),
laser_soft: values.laser_soft || null,
repeat_all: num(values.repeat_all),
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),
@ -493,10 +536,10 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
})),
};
// ✅ Tell the API we're editing and which record to patch
if (isEdit) {
// Ensure EDIT gets routed as a PATCH via the API
if (isEdit && edit?.submissionId != null) {
payload.mode = "edit";
payload.submission_id = edit!.submissionId;
payload.submission_id = edit.submissionId;
}
try {
@ -520,11 +563,12 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
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");
throw new Error((data as any)?.error || "Submission failed");
}
// Reset only on create; for edit, keep values visible
// Success
if (!isEdit) {
// reset only on create
reset();
setPhotoFile(null);
setScreenFile(null);
@ -532,11 +576,16 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
setScreenPreview("");
}
const id = data?.id ? String(data.id) : String(edit?.submissionId ?? "");
const next = isEdit
? `/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`
: `/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`;
router.push(next);
const id = (data as any)?.id ? String((data as any).id) : String(edit?.submissionId ?? "");
// back to success (create) or view (edit)
if (isEdit) {
// remove ?edit=1
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");
}
@ -553,14 +602,15 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
reader.readAsDataURL(file);
}
// Convenience strings for “Current:” (edit mode)
const currentPhotoId =
isEdit && typeof edit?.initialValues?.photo === "string" ? edit!.initialValues!.photo : null;
isEdit && typeof edit.initialValues?.photo === "string" ? (edit.initialValues.photo as string) : null;
const currentScreenId =
isEdit && typeof edit?.initialValues?.screen === "string" ? edit!.initialValues!.screen : null;
isEdit && typeof edit.initialValues?.screen === "string" ? (edit.initialValues.screen as string) : null;
return (
<div className="max-w-3xl mx-auto space-y-4">
{/* Target + Software */}
{/* 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>
@ -619,7 +669,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
<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>
Result Photo {isEdit ? null : <span className="text-red-600">*</span>}
</label>
{currentPhotoId && (
@ -632,14 +682,12 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
type="file"
accept="image/*"
data-role="photo"
required={!currentPhotoId}
required={!isEdit && !currentPhotoId}
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>
</>
<>Selected: <span className="font-mono">{photoFile.name}</span></>
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
@ -663,9 +711,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
/>
<p className="text-xs text-muted-foreground mt-1">
{screenFile ? (
<>
Selected: <span className="font-mono">{screenFile.name}</span>
</>
<>Selected: <span className="font-mono">{screenFile.name}</span></>
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
@ -740,49 +786,23 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
{/* 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>
<LabeledInput label="Material Thickness (mm)" name="mat_thickness" type="number" step="0.01" register={register} />
<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 />
<p className="text-xs text-muted-foreground md:col-span-3">0 = in focus. Negative = focus closer. Positive = focus further.</p>
</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" })}
>
<button type="button" className="px-2 py-1 border rounded" onClick={() => fills.append({ type: "uni" })}>
+ 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`)} />
<LabeledInput label="Name" name={`fill_settings.${i}.name`} register={register} />
<FilterableSelect
label="Type"
name={`fill_settings.${i}.type`}
@ -792,14 +812,14 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
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`)} />
<LabeledInput label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`fill_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<LabeledInput label="Power (%)" name={`fill_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`fill_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Interval (mm)" name={`fill_settings.${i}.interval`} type="number" step="0.001" register={register} />
<LabeledInput label="Pass" name={`fill_settings.${i}.pass`} type="number" step="1" register={register} />
<LabeledInput label="Angle (°)" name={`fill_settings.${i}.angle`} type="number" step="1" register={register} />
<LabeledInput label="Increment" name={`fill_settings.${i}.increment`} type="number" step="0.001" register={register} />
<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} />
@ -826,17 +846,17 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
</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`)} />
<LabeledInput label="Name" name={`line_settings.${i}.name`} register={register} />
<LabeledInput label="Frequency (kHz)" name={`line_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`line_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<LabeledInput label="Power (%)" name={`line_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`line_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Perf" name={`line_settings.${i}.perf`} register={register} />
<LabeledInput label="Cut" name={`line_settings.${i}.cut`} register={register} />
<LabeledInput label="Skip" name={`line_settings.${i}.skip`} register={register} />
<LabeledInput label="Pass" name={`line_settings.${i}.pass`} type="number" step="1" register={register} />
<LabeledInput label="Step" name={`line_settings.${i}.step`} type="number" step="0.001" register={register} />
<LabeledInput label="Size" name={`line_settings.${i}.size`} type="number" step="0.001" register={register} />
<BoolBox label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
<BoolBox label="Air" name={`line_settings.${i}.air`} register={register} />
@ -861,9 +881,9 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
</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`)} />
<LabeledInput label="Name" name={`raster_settings.${i}.name`} register={register} />
<LabeledInput label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`raster_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<FilterableSelect
label="Type"
name={`raster_settings.${i}.type`}
@ -882,13 +902,13 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
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`)} />
<LabeledInput label="Power (%)" name={`raster_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`raster_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} type="number" step="1" register={register} />
<LabeledInput label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} type="number" step="1" register={register} />
<LabeledInput label="Interval (mm)" name={`raster_settings.${i}.interval`} type="number" step="0.001" register={register} />
<LabeledInput label="Dot" name={`raster_settings.${i}.dot`} type="number" step="0.1" register={register} />
<LabeledInput label="Pass" name={`raster_settings.${i}.pass`} type="number" step="1" register={register} />
<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} />