added user rigs and updated collection route

This commit is contained in:
makearmy 2025-09-25 23:06:50 -04:00
parent 0ba2f9622e
commit faa7372887
2 changed files with 465 additions and 31 deletions

View file

@ -1,45 +1,61 @@
// app/app/api/options/[collection]/route.ts
import { NextResponse } from "next/server";
// app/api/options/[collection]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { directusFetch } from "@/lib/directus";
type MapEntry = { path: string; fields: string; label: (x: any) => string };
// Expandable label-field preferences per collection.
// Well try each key in order until we find a value.
const MAP: Record<
string,
{ coll: string; labelFields: string[] }
> = {
material: { coll: "material", labelFields: ["name", "label", "title"] },
material_coating: { coll: "material_coating", labelFields: ["name", "label", "title"] },
material_color: { coll: "material_color", labelFields: ["name", "label", "title"] },
material_opacity: { coll: "material_opacity", labelFields: ["name", "label", "title", "value"] },
laser_software: { coll: "laser_software", labelFields: ["name", "label", "title"] },
const MAP: Record<string, MapEntry> = {
material: { path: "/items/material", fields: "id,name", label: (x) => String(x.name ?? x.id) },
material_coating: { path: "/items/material_coating", fields: "id,name", label: (x) => String(x.name ?? x.id) },
material_color: { path: "/items/material_color", fields: "id,name", label: (x) => String(x.name ?? x.id) },
material_opacity: { path: "/items/material_opacity", fields: "id,opacity", label: (x) => String(x.opacity ?? x.id) },
laser_software: { path: "/items/laser_software", fields: "id,name", label: (x) => String(x.name ?? x.id) },
// laser_source and lens have dedicated routes
// NEW: Galvo scan head aperture list
laser_scan_lens_apt: { coll: "laser_scan_lens_apt", labelFields: ["name", "label", "title", "aperture_mm", "size_mm", "value"] },
// NEW: Beam expander multiplier list
laser_scan_lens_exp: { coll: "laser_scan_lens_exp", labelFields: ["name", "label", "title", "multiplier", "value"] },
};
export async function GET(req: Request, ctx: any) {
function pickLabel(it: any, candidates: string[]) {
for (const k of candidates) if (it?.[k] != null && it[k] !== "") return String(it[k]);
// fallback: try some common numeric-ish fields if present
if (it?.value != null) return String(it.value);
return String(it?.name ?? it?.label ?? it?.title ?? it?.id ?? "");
}
export async function GET(req: NextRequest, ctx: any) {
try {
const { searchParams } = new URL(req.url);
const key = ctx?.params?.collection as string;
const q = (searchParams.get("q") || "").trim().toLowerCase();
const limit = Number(searchParams.get("limit") || "500");
const key = String(ctx?.params?.collection || "");
const cfg = MAP[key];
if (!cfg) {
return NextResponse.json({ error: "unsupported collection" }, { status: 400 });
}
if (!cfg) return NextResponse.json({ data: [] });
const url = `${cfg.path}?fields=${encodeURIComponent(cfg.fields)}&limit=${limit}`;
const { data } = await directusFetch<{ data: any[] }>(url);
const list = Array.isArray(data) ? data : [];
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").toLowerCase();
const mapped = list.map((x) => ({
id: String(x.id),
label: cfg.label(x),
_s: Object.values(x).join(" ").toLowerCase(),
}));
// Keep fields=* so we can build a friendly label from whatever exists
const url = `/items/${cfg.coll}?fields=*&limit=500`;
const res = await directusFetch<{ data: any[] }>(url);
const items = res?.data ?? [];
const filtered = q ? mapped.filter((m) => m._s.includes(q)) : mapped;
const mapped = items.map((it) => ({
id: String(it.id ?? it.submission_id ?? ""),
label: pickLabel(it, cfg.labelFields),
_search: `${Object.values(it).join(" ")}`.toLowerCase(),
})).filter((m) => !!m.id);
const filtered = q ? mapped.filter((m) => m._search.includes(q)) : mapped;
filtered.sort((a, b) => a.label.localeCompare(b.label));
return NextResponse.json({ data: filtered.map(({ id, label }) => ({ id, label })) });
} catch (e: any) {
return NextResponse.json({ error: e?.message || "Failed to load options" }, { status: 500 });
return NextResponse.json({ data: filtered.map(({ _search, ...r }) => r) });
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || "options error" },
{ status: 500 }
);
}
}

418
app/my-rigs/page.tsx Normal file
View file

@ -0,0 +1,418 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, type UseFormRegister } from "react-hook-form";
// ─────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────
type RigKind = "fiber" | "uv" | "co2_galvo" | "co2_gantry";
type Rig = {
id: string;
name: string;
kind: RigKind;
// core components
source?: { id: string; label: string } | null;
scan_lens?: { id: string; label: string } | null; // fiber/uv/co2_galvo
focus_lens?: { id: string; label: string } | null; // co2_gantry
scan_aperture?: { id: string; label: string } | null; // laser_scan_lens_apt
beam_expander?: { id: string; label: string } | null; // laser_scan_lens_exp
software?: { id: string; label: string } | null;
created_at: number;
updated_at: number;
};
type Opt = { id: string; label: string };
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
const STORAGE_KEY = "makearmy_rigs_v1";
const TARGET_FOR_KIND: Record<RigKind, "settings_fiber" | "settings_uv" | "settings_co2gal" | "settings_co2gan"> = {
fiber: "settings_fiber",
uv: "settings_uv",
co2_galvo: "settings_co2gal",
co2_gantry: "settings_co2gan",
};
function uuid() {
// fine for local client IDs
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
function loadRigs(): Rig[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Rig[]) : [];
} catch {
return [];
}
}
function saveRigs(rigs: Rig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(rigs));
}
function useOptions(path: string) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
useEffect(() => {
let alive = true;
async function run() {
setLoading(true);
try {
const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`;
const res = await fetch(url, { cache: "no-store" });
const json = await res.json();
if (!alive) return;
setOpts((json?.data as Opt[]) ?? []);
} catch {
if (!alive) return;
setOpts([]);
} finally {
if (!alive) return;
setLoading(false);
}
}
run();
return () => {
alive = false;
};
}, [path, q]);
return { opts, loading, setQ };
}
// Reusable UI bits
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="border rounded-lg p-4 space-y-3 bg-background">
<h2 className="text-lg font-semibold">{title}</h2>
{children}
</section>
);
}
function FilterableSelect({
label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
disabled,
}: {
label: string;
name: string;
register: UseFormRegister<any>;
options: Opt[];
loading?: boolean;
onQuery?: (q: string) => void;
placeholder?: string;
disabled?: 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 className={disabled ? "opacity-60 pointer-events-none" : ""}>
<label className="block text-sm mb-1">{label}</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)}
disabled={disabled}
/>
<select className="w-full border rounded px-2 py-1" {...register(name)} disabled={disabled}>
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Page
// ─────────────────────────────────────────────────────────────
export default function MyRigsPage() {
const [rigs, setRigs] = useState<Rig[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
// form
const { register, handleSubmit, watch, reset, setValue, formState: { isSubmitting } } = useForm<any>({
defaultValues: {
name: "",
kind: "fiber" as RigKind,
source: "",
scan_lens: "",
focus_lens: "",
scan_aperture: "",
beam_expander: "",
software: "",
},
});
// load saved rigs
useEffect(() => {
setRigs(loadRigs());
}, []);
// kind → target (for filtering laser source & lens APIs)
const kind = watch("kind") as RigKind;
const target = TARGET_FOR_KIND[kind];
const isGalvo = kind === "fiber" || kind === "uv" || kind === "co2_galvo";
const isGantry = kind === "co2_gantry";
// options
const sourceOpts = useOptions(`laser_source?target=${target}`);
const lensOpts = useOptions(`lens?target=${target}`); // scan lens for galvo, focus lens for gantry
const softOpts = useOptions("laser_software");
const aptOpts = useOptions("laser_scan_lens_apt");
const expOpts = useOptions("laser_scan_lens_exp");
// helpers to resolve id→label from fetched option lists
const findLabel = (id: string | undefined, opts: Opt[]) =>
id ? (opts.find((o) => o.id === id)?.label || id) : "";
function onEdit(rig: Rig) {
setEditingId(rig.id);
reset({
name: rig.name,
kind: rig.kind,
source: rig.source?.id || "",
scan_lens: rig.scan_lens?.id || "",
focus_lens: rig.focus_lens?.id || "",
scan_aperture: rig.scan_aperture?.id || "",
beam_expander: rig.beam_expander?.id || "",
software: rig.software?.id || "",
});
}
function onDelete(id: string) {
const next = rigs.filter((r) => r.id !== id);
setRigs(next);
saveRigs(next);
if (editingId === id) {
setEditingId(null);
reset();
}
}
function onClearForm() {
setEditingId(null);
reset({
name: "",
kind: "fiber",
source: "",
scan_lens: "",
focus_lens: "",
scan_aperture: "",
beam_expander: "",
software: "",
});
}
const onSubmit = handleSubmit((vals) => {
// basic validation
if (!vals.name?.trim()) {
alert("Please provide a name for your Rig.");
return;
}
if (!vals.source) {
alert("Please select a Laser Source.");
return;
}
if (isGalvo && !vals.scan_lens) {
alert("Please select a Scan Lens.");
return;
}
if (isGantry && !vals.focus_lens) {
alert("Please select a Focus Lens.");
return;
}
const now = Date.now();
const rig: Rig = {
id: editingId || uuid(),
name: String(vals.name).trim(),
kind: vals.kind as RigKind,
source: vals.source ? { id: vals.source, label: findLabel(vals.source, sourceOpts.opts) } : null,
scan_lens: vals.scan_lens ? { id: vals.scan_lens, label: findLabel(vals.scan_lens, lensOpts.opts) } : null,
focus_lens: vals.focus_lens ? { id: vals.focus_lens, label: findLabel(vals.focus_lens, lensOpts.opts) } : null,
scan_aperture: vals.scan_aperture ? { id: vals.scan_aperture, label: findLabel(vals.scan_aperture, aptOpts.opts) } : null,
beam_expander: vals.beam_expander ? { id: vals.beam_expander, label: findLabel(vals.beam_expander, expOpts.opts) } : null,
software: vals.software ? { id: vals.software, label: findLabel(vals.software, softOpts.opts) } : null,
created_at: editingId ? rigs.find((r) => r.id === editingId)?.created_at ?? now : now,
updated_at: now,
};
const next = editingId
? rigs.map((r) => (r.id === editingId ? rig : r))
: [rig, ...rigs];
setRigs(next);
saveRigs(next);
setEditingId(null);
reset();
});
return (
<div className="max-w-4xl mx-auto p-4 space-y-6">
<h1 className="text-2xl font-bold">My Rigs</h1>
<Section title={editingId ? "Edit Rig" : "Create a Rig"}>
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid md:grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1">Rig Name *</label>
<input
className="w-full border rounded px-2 py-1"
placeholder="e.g., Fiber + 110×110mm (F160)"
{...register("name")}
/>
</div>
<div>
<label className="block text-sm mb-1">Rig Type *</label>
<select className="w-full border rounded px-2 py-1" {...register("kind")}>
<option value="fiber">Fiber (Galvo)</option>
<option value="uv">UV (Galvo)</option>
<option value="co2_galvo">CO (Galvo)</option>
<option value="co2_gantry">CO (Gantry)</option>
</select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-3">
<FilterableSelect
label="Laser Source *"
name="source"
register={register}
options={sourceOpts.opts}
loading={sourceOpts.loading}
onQuery={sourceOpts.setQ}
/>
<FilterableSelect
label={isGantry ? "Focus Lens *" : "Scan Lens *"}
name={isGantry ? "focus_lens" : "scan_lens"}
register={register}
options={lensOpts.opts}
loading={lensOpts.loading}
onQuery={lensOpts.setQ}
/>
</div>
<div className="grid md:grid-cols-3 gap-3">
<FilterableSelect
label="Scan Head Aperture"
name="scan_aperture"
register={register}
options={aptOpts.opts}
loading={aptOpts.loading}
onQuery={aptOpts.setQ}
disabled={!isGalvo}
placeholder={isGalvo ? "—" : "Not applicable"}
/>
<FilterableSelect
label="Beam Expander"
name="beam_expander"
register={register}
options={expOpts.opts}
loading={expOpts.loading}
onQuery={expOpts.setQ}
disabled={!isGalvo}
placeholder={isGalvo ? "—" : "Not applicable"}
/>
<FilterableSelect
label="Software"
name="software"
register={register}
options={softOpts.opts}
loading={softOpts.loading}
onQuery={softOpts.setQ}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={isSubmitting}
className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50"
>
{editingId ? "Save Changes" : "Save Rig"}
</button>
<button type="button" className="px-3 py-2 border rounded" onClick={onClearForm}>
Clear
</button>
</div>
</form>
</Section>
<Section title="Saved Rigs">
{rigs.length === 0 ? (
<p className="text-sm text-muted-foreground">No rigs yet. Create one above.</p>
) : (
<ul className="space-y-2">
{rigs.map((r) => (
<li key={r.id} className="border rounded p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{r.name}</div>
<div className="text-xs text-muted-foreground">
Type: {r.kind.replace("_", " ")}
</div>
<div className="text-xs mt-1 space-y-0.5">
<div>Source: {r.source?.label || "—"}</div>
{r.scan_lens && <div>Scan Lens: {r.scan_lens.label}</div>}
{r.focus_lens && <div>Focus Lens: {r.focus_lens.label}</div>}
{r.scan_aperture && <div>Scan Head Aperture: {r.scan_aperture.label}</div>}
{r.beam_expander && <div>Beam Expander: {r.beam_expander.label}</div>}
{r.software && <div>Software: {r.software.label}</div>}
</div>
</div>
<div className="flex gap-2">
<button className="px-2 py-1 border rounded" onClick={() => onEdit(r)}>
Edit
</button>
<button className="px-2 py-1 border rounded" onClick={() => onDelete(r.id)}>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
)}
</Section>
<p className="text-xs text-muted-foreground">
Rigs are currently saved locally in your browser. We can switch this to Directus-backed storage once
auth is in place; the UI can stay the same.
</p>
</div>
);
}