added user rig submission, logout | initial

This commit is contained in:
makearmy 2025-09-26 14:18:24 -04:00
parent 9261fbc165
commit b341a3675e
8 changed files with 635 additions and 420 deletions

407
app/my/rigs/page.tsx Normal file
View file

@ -0,0 +1,407 @@
"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 { toast } from "@/components/ui/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>
);
}