615 lines
25 KiB
TypeScript
615 lines
25 KiB
TypeScript
// components/forms/SettingsSubmit.tsx
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useForm, useFieldArray, useWatch, type UseFormRegister } from "react-hook-form";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
type Target = "settings_co2gal";
|
|
|
|
type Opt = { id: string; label: string };
|
|
type Me = { id: string; username?: string; email?: string } | null;
|
|
|
|
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
|
|
|
type EditInitialValues = {
|
|
submission_id: string | number;
|
|
|
|
setting_title?: string;
|
|
setting_notes?: string;
|
|
|
|
photo?: string | null;
|
|
screen?: string | null;
|
|
|
|
// Material
|
|
mat?: string | null;
|
|
mat_coat?: string | null;
|
|
mat_color?: string | null;
|
|
mat_opacity?: string | null;
|
|
mat_thickness?: number | null;
|
|
|
|
// Rig & Optics
|
|
laser_soft?: string | null;
|
|
source?: string | null; // submission_id
|
|
lens?: string | null;
|
|
focus?: number | null;
|
|
|
|
// CO2 Galvo triplet
|
|
lens_conf?: string | null;
|
|
lens_apt?: string | null;
|
|
lens_exp?: string | null;
|
|
|
|
repeat_all?: number | null;
|
|
|
|
// Repeaters
|
|
fill_settings?: any[] | null;
|
|
line_settings?: any[] | null;
|
|
raster_settings?: any[] | null;
|
|
};
|
|
|
|
type BaseProps = { mode?: "create" | "edit"; submissionId?: string | number; initialValues?: EditInitialValues | null };
|
|
export default function SettingsSubmit({ mode = "create", submissionId, initialValues }: BaseProps) {
|
|
const router = useRouter();
|
|
const isEdit = mode === "edit";
|
|
|
|
const [me, setMe] = useState<Me>(null);
|
|
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
|
|
|
// Robust current-user fetch
|
|
useEffect(() => {
|
|
let alive = true;
|
|
(async () => {
|
|
try {
|
|
const r1 = await fetch(`/api/me`, { cache: "no-store", credentials: "include" });
|
|
if (r1.ok) {
|
|
const j = await r1.json().catch(() => null);
|
|
if (alive && j) {
|
|
const id = j?.id ?? j?.data?.id ?? null;
|
|
const username = j?.username ?? j?.data?.username ?? j?.name ?? null;
|
|
const email = j?.email ?? j?.data?.email ?? null;
|
|
setMe(id ? { id, username: username ?? undefined, email: email ?? undefined } : null);
|
|
return;
|
|
}
|
|
}
|
|
const r2 = await fetch(`/api/dx/users/me?fields=id,username,email`, { cache: "no-store", credentials: "include" });
|
|
if (alive && r2.ok) {
|
|
const j2 = await r2.json().catch(() => null);
|
|
const d = j2?.data ?? j2 ?? null;
|
|
setMe(d?.id ? { id: d.id, username: d.username ?? undefined, email: d.email ?? undefined } : null);
|
|
}
|
|
} catch {
|
|
if (alive) setMe(null);
|
|
}
|
|
})();
|
|
return () => { alive = false; };
|
|
}, []);
|
|
|
|
// Options loaders (Directus reads)
|
|
function useOptions(path: string, includeId?: string | null) {
|
|
const [opts, setOpts] = useState<Opt[]>([]);
|
|
useEffect(() => {
|
|
let live = true;
|
|
|
|
(async () => {
|
|
let url = "";
|
|
let map = (rows: any[]) => rows.map((r) => ({ id: String(r.id ?? r.submission_id), label: String(r.name ?? r.model ?? r.opacity ?? r.id) }));
|
|
|
|
if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`;
|
|
else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`;
|
|
else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`;
|
|
else if (path === "material_opacity") {
|
|
url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`;
|
|
map = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) }));
|
|
} else if (path === "laser_software") {
|
|
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
|
|
} else if (path === "laser_source_co2_galvo") {
|
|
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
|
|
type Row = { submission_id?: string | number; make?: string; model?: string; nm?: string | number | null };
|
|
const toNum = (v: unknown): number | null => {
|
|
if (typeof v === "number") return globalThis.Number.isFinite(v) ? v : null;
|
|
if (typeof v === "string") {
|
|
const m = v.match(/-?\d+(\.\d+)?/);
|
|
const n = m ? globalThis.Number(m[0]) : NaN;
|
|
return globalThis.Number.isFinite(n) ? n : null;
|
|
}
|
|
return null;
|
|
};
|
|
map = (rows: Row[]) =>
|
|
rows
|
|
.filter((r) => {
|
|
const nmVal = toNum(r.nm);
|
|
return nmVal !== null && nmVal >= 10000 && nmVal <= 11000;
|
|
})
|
|
.map((r) => ({
|
|
id: String(r.submission_id ?? ""),
|
|
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id ?? ""),
|
|
}));
|
|
} else if (path === "laser_scan_lens") {
|
|
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
|
|
map = (rows) =>
|
|
rows
|
|
.slice()
|
|
.sort((a, b) => (parseFloat(a.focal_length ?? "99999") || 99999) - (parseFloat(b.focal_length ?? "99999") || 99999))
|
|
.map((r) => {
|
|
const fs = r.field_size ? `${r.field_size} mm` : "";
|
|
const fl = r.focal_length ? `${r.focal_length} mm` : "";
|
|
const label = [fs, fl].filter(Boolean).join(" — ") || String(r.id);
|
|
return { id: String(r.id), label };
|
|
});
|
|
} else if (path === "laser_scan_lens_config") {
|
|
url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`;
|
|
} else if (path === "laser_scan_lens_apt") {
|
|
url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
|
|
} else if (path === "laser_scan_lens_exp") {
|
|
url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
|
|
} else {
|
|
setOpts([]);
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(url, { cache: "no-store", credentials: "include" });
|
|
const j = await res.json().catch(() => ({}));
|
|
const rows = Array.isArray(j?.data) ? j.data : [];
|
|
let list = map(rows);
|
|
|
|
if (includeId && !list.some((o) => String(o.id) === String(includeId))) {
|
|
list = [{ id: String(includeId), label: "(current selection)" }, ...list];
|
|
}
|
|
if (live) setOpts(list);
|
|
})().catch(() => live && setOpts([]));
|
|
|
|
return () => { live = false; };
|
|
}, [path, includeId]);
|
|
|
|
return { opts };
|
|
}
|
|
|
|
// Enumerations
|
|
const FILL_TYPES: Opt[] = [
|
|
{ id: "uni", label: "UniDirectional" },
|
|
{ id: "bi", label: "BiDirectional" },
|
|
{ id: "offset", label: "Offset Fill" },
|
|
];
|
|
const RASTER_TYPES = FILL_TYPES;
|
|
const RASTER_DITHER: Opt[] = [
|
|
"threshold", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale",
|
|
].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) }));
|
|
|
|
// react-hook-form
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
reset,
|
|
getValues, // ⬅ added
|
|
setValue, // ⬅ added
|
|
formState: { isSubmitting },
|
|
} = useForm<any>({
|
|
defaultValues: {
|
|
setting_title: "",
|
|
setting_notes: "",
|
|
// Material
|
|
mat: "",
|
|
mat_coat: "",
|
|
mat_color: "",
|
|
mat_opacity: "",
|
|
mat_thickness: "",
|
|
// Rig & Optics
|
|
laser_soft: "",
|
|
source: "",
|
|
// keep these blank so Select shows "—"
|
|
lens_conf: "",
|
|
lens_apt: "",
|
|
lens_exp: "",
|
|
lens: "",
|
|
focus: "",
|
|
repeat_all: "",
|
|
// Repeaters
|
|
fill_settings: [],
|
|
line_settings: [],
|
|
raster_settings: [],
|
|
},
|
|
});
|
|
|
|
// Repeaters
|
|
const fills = useFieldArray({ control, name: "fill_settings" });
|
|
const lines = useFieldArray({ control, name: "line_settings" });
|
|
const rasters = useFieldArray({ control, name: "raster_settings" });
|
|
|
|
// Prefill (edit)
|
|
useEffect(() => {
|
|
if (!isEdit || !initialValues) return;
|
|
reset({
|
|
setting_title: initialValues.setting_title ?? "",
|
|
setting_notes: initialValues.setting_notes ?? "",
|
|
// Material
|
|
mat: initialValues.mat ?? "",
|
|
mat_coat: initialValues.mat_coat ?? "",
|
|
mat_color: initialValues.mat_color ?? "",
|
|
mat_opacity: initialValues.mat_opacity ?? "",
|
|
mat_thickness: initialValues.mat_thickness ?? "",
|
|
// Rig & Optics
|
|
laser_soft: initialValues.laser_soft ?? "",
|
|
source: initialValues.source ?? "",
|
|
lens_conf: initialValues.lens_conf ?? "",
|
|
lens_apt: initialValues.lens_apt ?? "",
|
|
lens_exp: initialValues.lens_exp ?? "",
|
|
lens: initialValues.lens ?? "",
|
|
focus: initialValues.focus ?? "",
|
|
repeat_all: initialValues.repeat_all ?? "",
|
|
// Repeaters
|
|
fill_settings: initialValues.fill_settings ?? [],
|
|
line_settings: initialValues.line_settings ?? [],
|
|
raster_settings: initialValues.raster_settings ?? [],
|
|
});
|
|
}, [isEdit, initialValues, reset]);
|
|
|
|
// Re-apply select values when options hydrate (fixes stubborn placeholder issue)
|
|
useEffect(() => {
|
|
if (!isEdit || !initialValues) return;
|
|
|
|
const ensure = (name: string, currentId: string | null | undefined, opts: Opt[]) => {
|
|
if (!currentId) return;
|
|
const cur = getValues(name as any);
|
|
const curStr = cur == null ? "" : String(cur);
|
|
if (!curStr || !opts.some((o) => String(o.id) === curStr)) {
|
|
setValue(name as any, String(currentId), { shouldDirty: false, shouldValidate: false });
|
|
}
|
|
};
|
|
|
|
ensure("mat", initialValues.mat, mats.opts);
|
|
ensure("mat_coat", initialValues.mat_coat, coats.opts);
|
|
ensure("mat_color", initialValues.mat_color, colors.opts);
|
|
ensure("mat_opacity", initialValues.mat_opacity, opacs.opts);
|
|
|
|
ensure("laser_soft", initialValues.laser_soft, soft.opts);
|
|
ensure("source", initialValues.source, srcs.opts);
|
|
|
|
ensure("lens_conf", initialValues.lens_conf, conf.opts);
|
|
ensure("lens_apt", initialValues.lens_apt, apt.opts);
|
|
ensure("lens_exp", initialValues.lens_exp, exp.opts);
|
|
|
|
ensure("lens", initialValues.lens, lens.opts);
|
|
}, [
|
|
isEdit,
|
|
initialValues,
|
|
mats.opts, coats.opts, colors.opts, opacs.opts,
|
|
soft.opts, srcs.opts,
|
|
conf.opts, apt.opts, exp.opts,
|
|
lens.opts,
|
|
getValues, setValue,
|
|
]);
|
|
|
|
// Option lists (include current IDs to guarantee a visible option)
|
|
const mats = useOptions("material", initialValues?.mat ?? null);
|
|
const coats = useOptions("material_coating", initialValues?.mat_coat ?? null);
|
|
const colors = useOptions("material_color", initialValues?.mat_color ?? null);
|
|
const opacs = useOptions("material_opacity", initialValues?.mat_opacity ?? null);
|
|
const soft = useOptions("laser_software", initialValues?.laser_soft ?? null);
|
|
const srcs = useOptions("laser_source_co2_galvo", initialValues?.source ?? null);
|
|
const lens = useOptions("laser_scan_lens", initialValues?.lens ?? null);
|
|
const conf = useOptions("laser_scan_lens_config", initialValues?.lens_conf ?? null);
|
|
const apt = useOptions("laser_scan_lens_apt", initialValues?.lens_apt ?? null);
|
|
const exp = useOptions("laser_scan_lens_exp", initialValues?.lens_exp ?? null);
|
|
|
|
// Image files
|
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
|
const [screenFile, setScreenFile] = useState<File | null>(null);
|
|
const onPick = (setter: (f: File | null) => void) => (e: React.ChangeEvent<HTMLInputElement>) => setter(e.target.files?.[0] ?? null);
|
|
|
|
const onSubmit = async (values: any) => {
|
|
setSubmitErr(null);
|
|
const payload: any = {
|
|
target: "settings_co2gal" as Target,
|
|
...(isEdit ? { mode: "edit" as const, submission_id: submissionId } : {}),
|
|
setting_title: values.setting_title,
|
|
setting_notes: values.setting_notes || "",
|
|
// Material
|
|
mat: values.mat || null,
|
|
mat_coat: values.mat_coat || null,
|
|
mat_color: values.mat_color || null,
|
|
mat_opacity: values.mat_opacity || null,
|
|
mat_thickness: values.mat_thickness === "" ? null : globalThis.Number(values.mat_thickness),
|
|
// Rig & Optics
|
|
laser_soft: values.laser_soft || null,
|
|
source: values.source || null,
|
|
lens_conf: values.lens_conf || null,
|
|
lens_apt: values.lens_apt || null,
|
|
lens_exp: values.lens_exp || null,
|
|
lens: values.lens || null,
|
|
focus: values.focus === "" ? null : globalThis.Number(values.focus),
|
|
repeat_all: values.repeat_all === "" ? null : globalThis.Number(values.repeat_all),
|
|
// Repeaters (raw pass-through; api will normalize nums/bools)
|
|
fill_settings: values.fill_settings || [],
|
|
line_settings: values.line_settings || [],
|
|
raster_settings: values.raster_settings || [],
|
|
// If editing with existing asset IDs, the API will accept them
|
|
...(initialValues?.photo ? { photo: initialValues.photo } : {}),
|
|
...(initialValues?.screen ? { screen: initialValues.screen } : {}),
|
|
};
|
|
|
|
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");
|
|
|
|
const res = await fetch("/api/settings", { method: "POST", body: form, credentials: "include" });
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setSubmitErr(j?.error || `Submit failed (${res.status})`);
|
|
return;
|
|
}
|
|
|
|
if (isEdit) {
|
|
router.back();
|
|
} else {
|
|
reset();
|
|
setPhotoFile(null);
|
|
setScreenFile(null);
|
|
router.push(`/submit/settings/success?id=${encodeURIComponent(String(j?.id ?? ""))}`);
|
|
}
|
|
};
|
|
|
|
const meLabel = me?.username || me?.email || "";
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<header className="space-y-1">
|
|
{/* Hide local H1 in edit mode to avoid duplicate page title */}
|
|
{!isEdit && <h1 className="text-xl font-semibold">Submit CO₂ Galvo Setting</h1>}
|
|
{meLabel ? <p className="text-sm text-muted-foreground">Submitting as {meLabel}</p> : null}
|
|
{submitErr ? <div className="border border-red-500 bg-red-50 text-red-700 rounded px-3 py-2 text-sm">{submitErr}</div> : null}
|
|
</header>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
{/* Info */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Info</h2>
|
|
<div className="grid gap-3">
|
|
<div>
|
|
<label className="block text-sm mb-1">Title</label>
|
|
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Images */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Images</h2>
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm mb-1">Result Photo</label>
|
|
<input type="file" accept="image/*" onChange={onPick(setPhotoFile)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
|
|
<input type="file" accept="image/*" onChange={onPick(setScreenFile)} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Material */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Material</h2>
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
<Select label="Material" {...{ name: "mat", register, options: mats.opts, required: true }} />
|
|
<Select label="Coating" {...{ name: "mat_coat", register, options: coats.opts, required: true }} />
|
|
<Select label="Color" {...{ name: "mat_color", register, options: colors.opts, required: true }} />
|
|
<Select label="Opacity" {...{ name: "mat_opacity", register, options: opacs.opts, required: true }} />
|
|
<Number label="Material Thickness (mm)" name="mat_thickness" register={register} step="0.01" />
|
|
</div>
|
|
</section>
|
|
|
|
{/* Rig & Optics (order per spec) */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Rig & Optics</h2>
|
|
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
<Select label="Laser Software" {...{ name: "laser_soft", register, options: soft.opts, required: true }} />
|
|
<Select label="Laser Source" {...{ name: "source", register, options: srcs.opts, required: true }} />
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-3 gap-3">
|
|
<Select label="Lens Configuration" {...{ name: "lens_conf", register, options: conf.opts, required: true }} />
|
|
<Select label="Scan Head Aperture" {...{ name: "lens_apt", register, options: apt.opts, required: true }} />
|
|
<Select label="Beam Expander" {...{ name: "lens_exp", register, options: exp.opts, required: true }} />
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-3 gap-3">
|
|
<Select label="Scan Lens" {...{ name: "lens", register, options: lens.opts, required: true }} />
|
|
<Number label="Focus (mm)" name="focus" register={register} step="1" />
|
|
<Number label="Repeat All" name="repeat_all" register={register} step="1" />
|
|
</div>
|
|
</section>
|
|
|
|
{/* Process Settings */}
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-semibold">Process Settings</h2>
|
|
|
|
{/* Fill */}
|
|
<Repeater
|
|
title="Fill"
|
|
fields={fills.fields}
|
|
onAdd={() => fills.append({ type: "" })}
|
|
onRemove={(i) => fills.remove(i)}
|
|
render={(i) => {
|
|
const autoRotate = !!useWatch({ control, name: `fill_settings.${i}.auto` });
|
|
return (
|
|
<div className="grid md:grid-cols-4 gap-2">
|
|
<Text label="Name" name={`fill_settings.${i}.name`} register={register} />
|
|
<Select label="Type" name={`fill_settings.${i}.type`} register={register} options={FILL_TYPES} />
|
|
<Number label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} register={register} step="0.1" />
|
|
<Number label="Pulse (ns)" name={`fill_settings.${i}.pulse`} register={register} step="0.1" />
|
|
<Number label="Power (%)" name={`fill_settings.${i}.power`} register={register} step="0.1" />
|
|
<Number label="Speed (mm/s)" name={`fill_settings.${i}.speed`} register={register} step="0.1" />
|
|
<Number label="Interval (mm)" name={`fill_settings.${i}.interval`} register={register} step="0.001" />
|
|
<Number label="Pass" name={`fill_settings.${i}.pass`} register={register} step="1" />
|
|
<Number label="Angle (°)" name={`fill_settings.${i}.angle`} register={register} step="1" />
|
|
|
|
{/* Move Auto first, then its increment in a half-width cell */}
|
|
<Check label="Auto Rotate" name={`fill_settings.${i}.auto`} register={register} />
|
|
{autoRotate && (
|
|
<div className="md:col-span-2">
|
|
<Number label="Auto Rotate Increment (°)" name={`fill_settings.${i}.increment`} register={register} step="0.001" />
|
|
</div>
|
|
)}
|
|
|
|
<Check label="Crosshatch" name={`fill_settings.${i}.cross`} register={register} />
|
|
<Check label="Flood Fill" name={`fill_settings.${i}.flood`} register={register} />
|
|
<Check label="Air Assist" name={`fill_settings.${i}.air`} register={register} />
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
{/* Line */}
|
|
<Repeater
|
|
title="Line"
|
|
fields={lines.fields}
|
|
onAdd={() => lines.append({})}
|
|
onRemove={(i) => lines.remove(i)}
|
|
render={(i) => {
|
|
const perf = !!useWatch({ control, name: `line_settings.${i}.perf` });
|
|
const wobble = !!useWatch({ control, name: `line_settings.${i}.wobble` });
|
|
return (
|
|
<div className="grid md:grid-cols-4 gap-2">
|
|
<Text label="Name" name={`line_settings.${i}.name`} register={register} />
|
|
<Number label="Frequency (kHz)" name={`line_settings.${i}.frequency`} register={register} step="0.1" />
|
|
<Number label="Pulse (ns)" name={`line_settings.${i}.pulse`} register={register} step="0.1" />
|
|
<Number label="Power (%)" name={`line_settings.${i}.power`} register={register} step="0.1" />
|
|
<Number label="Speed (mm/s)" name={`line_settings.${i}.speed`} register={register} step="0.1" />
|
|
|
|
{/* Pass before perf per your last request */}
|
|
<Number label="Pass" name={`line_settings.${i}.pass`} register={register} step="1" />
|
|
<Check label="Perforation Mode" name={`line_settings.${i}.perf`} register={register} />
|
|
|
|
{perf && (
|
|
<>
|
|
<Number label="Cut (mm)" name={`line_settings.${i}.cut`} register={register} step="0.001" />
|
|
<Number label="Skip (mm)" name={`line_settings.${i}.skip`} register={register} step="0.001" />
|
|
</>
|
|
)}
|
|
|
|
<Check label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
|
|
{wobble && (
|
|
<>
|
|
<Number label="Step (mm)" name={`line_settings.${i}.step`} register={register} step="0.001" />
|
|
<Number label="Size (mm)" name={`line_settings.${i}.size`} register={register} step="0.001" />
|
|
</>
|
|
)}
|
|
|
|
<Check label="Air Assist" name={`line_settings.${i}.air`} register={register} />
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
{/* Raster */}
|
|
<Repeater
|
|
title="Raster"
|
|
fields={rasters.fields}
|
|
onAdd={() => rasters.append({ type: "", dither: "" })}
|
|
onRemove={(i) => rasters.remove(i)}
|
|
render={(i) => {
|
|
const ditherVal = useWatch({ control, name: `raster_settings.${i}.dither` }) || "";
|
|
const isHalftone = ditherVal === "halftone";
|
|
return (
|
|
<div className="grid md:grid-cols-4 gap-2">
|
|
<Text label="Name" name={`raster_settings.${i}.name`} register={register} />
|
|
<Number label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} register={register} step="0.1" />
|
|
<Number label="Pulse (ns)" name={`raster_settings.${i}.pulse`} register={register} step="0.1" />
|
|
<Select label="Type" name={`raster_settings.${i}.type`} register={register} options={RASTER_TYPES} />
|
|
<Select label="Dither" name={`raster_settings.${i}.dither`} register={register} options={RASTER_DITHER} />
|
|
<Number label="Power (%)" name={`raster_settings.${i}.power`} register={register} step="0.1" />
|
|
<Number label="Speed (mm/s)" name={`raster_settings.${i}.speed`} register={register} step="0.1" />
|
|
{isHalftone && (
|
|
<>
|
|
<Number label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} register={register} step="1" />
|
|
<Number label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} register={register} step="1" />
|
|
</>
|
|
)}
|
|
<Number label="Interval (mm)" name={`raster_settings.${i}.interval`} register={register} step="0.001" />
|
|
<Number label="Dot Width Adjustment (mm)" name={`raster_settings.${i}.dot`} register={register} step="0.1" />
|
|
<Number label="Pass" name={`raster_settings.${i}.pass`} register={register} step="1" />
|
|
<Check label="Crosshatch" name={`raster_settings.${i}.cross`} register={register} />
|
|
<Check label="Inverted" name={`raster_settings.${i}.inversion`} register={register} />
|
|
<Check label="Air Assist" name={`raster_settings.${i}.air`} register={register} />
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</section>
|
|
|
|
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background disabled:opacity-50">
|
|
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* Small UI */
|
|
function Select({
|
|
label,
|
|
name,
|
|
register,
|
|
options,
|
|
required,
|
|
}: { label: string; name: string; register: UseFormRegister<any>; options: Opt[]; required?: boolean }) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm mb-1">
|
|
{label} {required ? <span className="text-red-600">*</span> : null}
|
|
</label>
|
|
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
|
|
<option value="">—</option>
|
|
{options.map((o) => (
|
|
<option key={o.id} value={o.id}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
function Number({ label, name, register, step }: any) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm mb-1">{label}</label>
|
|
<input type="number" step={step ?? "1"} className="w-full border rounded px-2 py-1" {...register(name)} />
|
|
</div>
|
|
);
|
|
}
|
|
function Text({ label, name, register }: any) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm mb-1">{label}</label>
|
|
<input className="w-full border rounded px-2 py-1" {...register(name)} />
|
|
</div>
|
|
);
|
|
}
|
|
function Check({ label, name, register }: any) {
|
|
return (
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" {...register(name)} /> {label}
|
|
</label>
|
|
);
|
|
}
|
|
function Repeater({ title, fields, onAdd, onRemove, render }: any) {
|
|
return (
|
|
<fieldset className="border rounded p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<legend className="font-semibold">{title}</legend>
|
|
<button type="button" className="px-2 py-1 border rounded" onClick={onAdd}>+ Add</button>
|
|
</div>
|
|
{fields.map((_: any, i: number) => (
|
|
<div key={i} className="space-y-2">
|
|
{render(i)}
|
|
<button type="button" className="px-2 py-1 border rounded" onClick={() => onRemove(i)}>Remove</button>
|
|
</div>
|
|
))}
|
|
</fieldset>
|
|
);
|
|
}
|