makearmy-app/app/my/rigs/page.tsx
2025-09-26 14:39:49 -04:00

407 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
// UI bits (shadcn-style)
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { Loader2, Plus, Save, Settings2, X } from "lucide-react";
import { SignOutButton } from "@/components/SignOutButton";
// simple fetch helper
async function jfetch<T = any>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers || {}),
},
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
return (await res.json()) as T;
}
/** ---------- Types for option endpoints ---------- */
type Opt = { id: string; label: string };
// Finder for options with free-text filter
function useOptions(endpoint: string) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
const url = `/api/options/${endpoint}${endpoint.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`;
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((j) => {
if (!alive) return;
setOpts((j?.data as Opt[]) ?? []);
})
.catch(() => {
if (!alive) return;
setOpts([]);
})
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [endpoint, q]);
return { opts, loading, setQ };
}
/** ---------- Form schema ---------- */
const RigSchema = z.object({
name: z.string().min(1, "Rig name required"),
notes: z.string().optional(),
// Relations (IDs)
laser_source: z.string().min(1, "Laser source required"),
laser_software: z.string().min(1, "Software required"),
// One of scan lens (with optional aperture & expander) OR focus lens
laser_scan_lens: z.string().optional(),
laser_scan_lens_apt: z.string().optional(),
laser_scan_lens_exp: z.string().optional(),
laser_focus_lens: z.string().optional(),
});
type RigForm = z.infer<typeof RigSchema>;
/** ---------- Little select with filter ---------- */
function FilterableSelect({
label,
value,
onValueChange,
placeholder = "Select…",
endpoint, // e.g. "laser_source?target=settings_fiber"
}: {
label: string;
value?: string;
onValueChange: (v: string) => void;
placeholder?: string;
endpoint: string;
}) {
const { opts, loading, setQ } = useOptions(endpoint);
return (
<div className="space-y-1">
<Label>{label}</Label>
<Input
placeholder="Type to filter…"
onChange={(e) => setQ(e.target.value)}
className="mb-1"
/>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger>
<SelectValue placeholder={loading ? "Loading…" : placeholder} />
</SelectTrigger>
<SelectContent>
{opts.length === 0 && (
<div className="px-2 py-1 text-sm text-muted-foreground">
{loading ? "Loading…" : "No matches"}
</div>
)}
{opts.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/** ---------- Page ---------- */
export default function MyRigsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [saving, setSaving] = useState(false);
const [list, setList] = useState<any[]>([]);
const [loadingList, setLoadingList] = useState(true);
// Which lens type UI is active: "scan" vs "focus"
const [lensMode, setLensMode] = useState<"scan" | "focus">("scan");
const form = useForm<RigForm>({
resolver: zodResolver(RigSchema),
defaultValues: {
name: "",
notes: "",
laser_source: "",
laser_software: "",
laser_scan_lens: "",
laser_scan_lens_apt: "",
laser_scan_lens_exp: "",
laser_focus_lens: "",
},
});
const sourceTarget = "settings_fiber"; // for now default fiber; could be dynamic later
// Options hooks
const srcs = useOptions(`laser_source?target=${sourceTarget}`);
const soft = useOptions("laser_software");
const scanLens = useOptions(`lens?target=${sourceTarget}`); // F-theta
const apertures = useOptions("laser_scan_lens_apt");
const expanders = useOptions("laser_scan_lens_exp");
const focusLens = useOptions("lens?target=settings_co2gan"); // focus lens for gantry
// Load my rigs
useEffect(() => {
let alive = true;
(async () => {
try {
setLoadingList(true);
const res = await jfetch<{ data: any[] }>("/api/rigs/list");
if (!alive) return;
setList(res.data || []);
} catch (e: any) {
console.warn("[my/rigs] list error", e?.message);
} finally {
alive = false ? undefined : setLoadingList(false);
}
})();
return () => {
alive = false;
};
}, []);
async function onSave(data: RigForm) {
try {
setSaving(true);
// If in focus mode, ensure scan-lens fields are cleared and vice versa.
const payload: any = { ...data };
if (lensMode === "focus") {
payload.laser_scan_lens = null;
payload.laser_scan_lens_apt = null;
payload.laser_scan_lens_exp = null;
} else {
payload.laser_focus_lens = null;
}
const res = await jfetch<{ ok: boolean; id: string }>("/api/rigs/save", {
method: "POST",
body: JSON.stringify(payload),
});
toast({
title: "Rig saved",
description: "Your rig has been saved to your account.",
});
// refresh list
const updated = await jfetch<{ data: any[] }>("/api/rigs/list");
setList(updated.data || []);
// clear form (optional)
form.reset({
name: "",
notes: "",
laser_source: "",
laser_software: "",
laser_scan_lens: "",
laser_scan_lens_apt: "",
laser_scan_lens_exp: "",
laser_focus_lens: lensMode === "focus" ? "" : "",
});
} catch (e: any) {
toast({
title: "Save failed",
description: e?.message || "Unknown error",
variant: "destructive",
});
} finally {
setSaving(false);
}
}
function LensModeToggle() {
return (
<div className="flex items-center gap-2">
<Badge
variant={lensMode === "scan" ? "default" : "secondary"}
className="cursor-pointer"
onClick={() => setLensMode("scan")}
>
Scan Lens
</Badge>
<Badge
variant={lensMode === "focus" ? "default" : "secondary"}
className="cursor-pointer"
onClick={() => setLensMode("focus")}
>
Focus Lens
</Badge>
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-4 md:p-6 space-y-6">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">My Rigs</h1>
<SignOutButton />
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Build a Rig
</CardTitle>
<LensModeToggle />
</CardHeader>
<CardContent className="space-y-4">
<form
onSubmit={form.handleSubmit(onSave)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div className="space-y-2">
<Label>Rig Name *</Label>
<Input placeholder="e.g., Shop Fiber #1 (20W)" {...form.register("name")} />
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<FilterableSelect
label="Laser Source *"
endpoint={`laser_source?target=${sourceTarget}`}
value={form.watch("laser_source")}
onValueChange={(v) => form.setValue("laser_source", v)}
/>
<FilterableSelect
label="Software *"
endpoint="laser_software"
value={form.watch("laser_software")}
onValueChange={(v) => form.setValue("laser_software", v)}
/>
{lensMode === "scan" ? (
<>
<FilterableSelect
label="Scan Lens (F-theta)"
endpoint={`lens?target=${sourceTarget}`}
value={form.watch("laser_scan_lens")}
onValueChange={(v) => form.setValue("laser_scan_lens", v)}
/>
<FilterableSelect
label="Galvo Head Aperture"
endpoint="laser_scan_lens_apt"
value={form.watch("laser_scan_lens_apt")}
onValueChange={(v) => form.setValue("laser_scan_lens_apt", v)}
/>
<FilterableSelect
label="Beam Expander (multiplier)"
endpoint="laser_scan_lens_exp"
value={form.watch("laser_scan_lens_exp")}
onValueChange={(v) => form.setValue("laser_scan_lens_exp", v)}
/>
</>
) : (
<>
<FilterableSelect
label="Focus Lens"
endpoint="lens?target=settings_co2gan"
value={form.watch("laser_focus_lens")}
onValueChange={(v) => form.setValue("laser_focus_lens", v)}
/>
<div className="md:col-span-2">
<div className="text-sm text-muted-foreground">
Focus lenses dont use F-numbers; just pick the lens by name.
</div>
</div>
</>
)}
<div className="md:col-span-2">
<Label>Notes</Label>
<Textarea rows={3} placeholder="Optional notes about this rig…" {...form.register("notes")} />
</div>
<div className="md:col-span-2 flex items-center justify-end gap-2">
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Save Rig
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Saved Rigs</CardTitle>
</CardHeader>
<CardContent>
{loadingList ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : list.length === 0 ? (
<div className="text-sm text-muted-foreground">No rigs saved yet.</div>
) : (
<div className="grid md:grid-cols-2 gap-3">
{list.map((r) => (
<div key={r.id} className="border rounded p-3 space-y-1">
<div className="flex items-center justify-between">
<div className="font-medium">{r.name}</div>
</div>
<div className="text-xs text-muted-foreground">
Source: {r.laser_source_label || r.laser_source}
</div>
<div className="text-xs text-muted-foreground">
Software: {r.laser_software_label || r.laser_software}
</div>
{r.lens_mode === "scan" ? (
<>
<div className="text-xs text-muted-foreground">
Scan Lens: {r.laser_scan_lens_label || r.laser_scan_lens}
</div>
{r.laser_scan_lens_apt_label && (
<div className="text-xs text-muted-foreground">
Aperture: {r.laser_scan_lens_apt_label}
</div>
)}
{r.laser_scan_lens_exp_label && (
<div className="text-xs text-muted-foreground">
Expander: {r.laser_scan_lens_exp_label}
</div>
)}
</>
) : r.laser_focus_lens ? (
<div className="text-xs text-muted-foreground">
Focus Lens: {r.laser_focus_lens_label || r.laser_focus_lens}
</div>
) : null}
{r.notes && <div className="text-xs">{r.notes}</div>}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}