added user rig submission, logout | initial
This commit is contained in:
parent
9261fbc165
commit
b341a3675e
8 changed files with 635 additions and 420 deletions
76
app/api/my/rigs/[id]/route.ts
Normal file
76
app/api/my/rigs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// app/api/my/rigs/[id]/route.ts
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const BASE = process.env.DIRECTUS_URL!;
|
||||||
|
if (!BASE) console.warn("[my/rigs/:id] Missing DIRECTUS_URL");
|
||||||
|
|
||||||
|
function bearerFromCookies() {
|
||||||
|
const at = cookies().get("ma_at")?.value;
|
||||||
|
if (!at) throw new Error("Not authenticated");
|
||||||
|
return `Bearer ${at}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function df(path: string, init?: RequestInit) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: bearerFromCookies(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||||
|
if (!res.ok) throw new Error(`Directus error ${res.status}: ${text || res.statusText}`);
|
||||||
|
return json ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, ctx: any) {
|
||||||
|
try {
|
||||||
|
const id = ctx?.params?.id;
|
||||||
|
const fields = [
|
||||||
|
"id","name","rig_type","notes","meta",
|
||||||
|
"laser_source.id","laser_source.make","laser_source.model",
|
||||||
|
"laser_scan_lens.id","laser_scan_lens.field_size","laser_scan_lens.f_number",
|
||||||
|
"laser_focus_lens.id","laser_focus_lens.name",
|
||||||
|
"laser_scan_lens_apt.id","laser_scan_lens_apt.name",
|
||||||
|
"laser_scan_lens_exp.id","laser_scan_lens_exp.multiplier",
|
||||||
|
"laser_software.id","laser_software.name",
|
||||||
|
"date_created","date_updated"
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
const { data } = await df(`/items/rigs/${id}?fields=${encodeURIComponent(fields)}`);
|
||||||
|
return NextResponse.json({ ok: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || "Load failed";
|
||||||
|
const code = msg.includes("Not authenticated") ? 401 : (msg.includes("404") ? 404 : 500);
|
||||||
|
return NextResponse.json({ error: msg }, { status: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, ctx: any) {
|
||||||
|
try {
|
||||||
|
const id = ctx?.params?.id;
|
||||||
|
const body = await req.json();
|
||||||
|
const { data } = await df(`/items/rigs/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
return NextResponse.json({ ok: true, id: data?.id ?? id });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || "Update failed";
|
||||||
|
return NextResponse.json({ error: msg }, { status: msg.includes("Not authenticated") ? 401 : 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, ctx: any) {
|
||||||
|
try {
|
||||||
|
const id = ctx?.params?.id;
|
||||||
|
await df(`/items/rigs/${id}`, { method: "DELETE" });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || "Delete failed";
|
||||||
|
return NextResponse.json({ error: msg }, { status: msg.includes("Not authenticated") ? 401 : 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/api/my/rigs/route.ts
Normal file
83
app/api/my/rigs/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// app/api/my/rigs/route.ts
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const BASE = process.env.DIRECTUS_URL!;
|
||||||
|
if (!BASE) console.warn("[my/rigs] Missing DIRECTUS_URL");
|
||||||
|
|
||||||
|
// Pull the user's Directus access token from cookies
|
||||||
|
function bearerFromCookies() {
|
||||||
|
const at = cookies().get("ma_at")?.value;
|
||||||
|
if (!at) throw new Error("Not authenticated");
|
||||||
|
return `Bearer ${at}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function df(path: string, init?: RequestInit) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: bearerFromCookies(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||||
|
if (!res.ok) throw new Error(`Directus error ${res.status}: ${text || res.statusText}`);
|
||||||
|
return json ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Ownership is enforced by Directus policy (owner = $CURRENT_USER)
|
||||||
|
const fields = [
|
||||||
|
"id","name","rig_type",
|
||||||
|
"laser_source.id","laser_source.make","laser_source.model",
|
||||||
|
"laser_scan_lens.id","laser_scan_lens.field_size","laser_scan_lens.f_number",
|
||||||
|
"laser_focus_lens.id","laser_focus_lens.name",
|
||||||
|
"laser_scan_lens_apt.id","laser_scan_lens_apt.name",
|
||||||
|
"laser_scan_lens_exp.id","laser_scan_lens_exp.multiplier",
|
||||||
|
"laser_software.id","laser_software.name",
|
||||||
|
"date_created","date_updated"
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
const { data } = await df(`/items/rigs?fields=${encodeURIComponent(fields)}&limit=200&sort=-date_updated`);
|
||||||
|
return NextResponse.json({ ok: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || "Failed to load rigs";
|
||||||
|
return NextResponse.json({ error: msg }, { status: msg.includes("Not authenticated") ? 401 : 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Minimal validation
|
||||||
|
if (!body?.name) return NextResponse.json({ error: "Missing name" }, { status: 400 });
|
||||||
|
if (!body?.rig_type) return NextResponse.json({ error: "Missing rig_type" }, { status: 400 });
|
||||||
|
|
||||||
|
// owner is set by Directus preset (owner: $CURRENT_USER)
|
||||||
|
const payload = {
|
||||||
|
name: body.name,
|
||||||
|
rig_type: body.rig_type, // "fiber" | "co2_galvo" | "co2_gantry" | "uv"
|
||||||
|
laser_source: body.laser_source ?? null,
|
||||||
|
laser_scan_lens: body.laser_scan_lens ?? null,
|
||||||
|
laser_focus_lens: body.laser_focus_lens ?? null,
|
||||||
|
laser_scan_lens_apt: body.laser_scan_lens_apt ?? null,
|
||||||
|
laser_scan_lens_exp: body.laser_scan_lens_exp ?? null,
|
||||||
|
laser_software: body.laser_software ?? null,
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
meta: body.meta ?? null, // future: measured focal distance, spot size, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await df(`/items/rigs`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
return NextResponse.json({ ok: true, id: data?.id });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || "Create failed";
|
||||||
|
return NextResponse.json({ error: msg }, { status: msg.includes("Not authenticated") ? 401 : 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ export default function SignInPage() {
|
||||||
});
|
});
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (!res.ok) throw new Error(j?.error || "Login failed");
|
if (!res.ok) throw new Error(j?.error || "Login failed");
|
||||||
r.replace("/my-rigs");
|
r.replace("/my/rigs");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message || "Error");
|
setErr(e?.message || "Error");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default function SignUpPage() {
|
||||||
const lj = await login.json();
|
const lj = await login.json();
|
||||||
if (!login.ok) throw new Error(lj?.error || "Auto login failed");
|
if (!login.ok) throw new Error(lj?.error || "Auto login failed");
|
||||||
|
|
||||||
r.replace("/my-rigs"); // or wherever you want to land
|
r.replace("/my/rigs"); // or wherever you want to land
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message || "Error");
|
setErr(e?.message || "Error");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
43
app/components/SignOutButton.tsx
Normal file
43
app/components/SignOutButton.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// components/SignOutButton.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function SignOutButton({
|
||||||
|
className,
|
||||||
|
redirectTo = "/sign-in",
|
||||||
|
children = "Sign out",
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
redirectTo?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const r = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onClick() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
r.push(redirectTo);
|
||||||
|
r.refresh();
|
||||||
|
} catch {
|
||||||
|
// ignore; worst case user can hard-refresh
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={className ?? "text-sm underline hover:opacity-80"}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Signing out…" : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,418 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
407
app/my/rigs/page.tsx
Normal file
407
app/my/rigs/page.tsx
Normal 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 don’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
middleware.ts
Normal file
24
middleware.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const PROTECTED_PATHS = ["/my", "/api/my"];
|
||||||
|
|
||||||
|
export function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
const needsAuth = PROTECTED_PATHS.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(p + "/")
|
||||||
|
);
|
||||||
|
if (!needsAuth) return NextResponse.next();
|
||||||
|
|
||||||
|
const hasToken = Boolean(req.cookies.get("ma_at")?.value);
|
||||||
|
if (!hasToken) {
|
||||||
|
const url = new URL("/sign-in", req.url);
|
||||||
|
url.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next|static|favicon.ico).*)"],
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue