makearmy-app/app/settings/co2-gantry/page.tsx

312 lines
9.1 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 { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
type Owner = {
username?: string | null;
id?: string | number;
// keep extras harmlessly in case API returns them
first_name?: string | null;
last_name?: string | null;
email?: string | null;
};
export default function CO2GantrySettingsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const detailHref = (id: string | number) => `/settings/co2-gantry/${id}`;
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(t);
}, [query]);
useEffect(() => {
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
// owner (M2O) ensure username is requested
"owner.id",
"owner.username",
// assets / denorms
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
// NOTE: gantry uses lens.name (not field_size)
"lens.name",
].join(",") +
`&limit=-1`;
fetch(url, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setSettings(data?.data || []))
.catch((e) => {
console.error("CO2 Gantry settings fetch failed:", e);
setSettings([]);
})
.finally(() => setLoading(false));
}, []);
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => {
const fieldsToSearch = [
entry.setting_title,
ownerLabel(entry.owner),
entry.uploader, // keep legacy column visible for now
entry.mat?.name,
entry.mat_coat?.name,
entry.source?.model,
entry.lens?.name,
];
return fieldsToSearch
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q));
});
}, [settings, debouncedQuery]);
const total = settings.length;
const uniqueMaterials = new Set(
settings.map((s) => s.mat?.name).filter(Boolean)
).size;
const lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.lens?.name;
if (!v) return acc;
acc[v] = (acc[v] || 0) + 1;
return acc;
}, {});
const mostCommonLens =
Object.entries(lensCounts).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const srcCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.source?.model;
if (!v) return acc;
acc[v] = (acc[v] || 0) + 1;
return acc;
}, {});
const mostCommonSource =
Object.entries(srcCounts).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const recent = [...settings]
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5);
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
`}</style>
{/* Header / Search */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">CO Gantry Settings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by material, owner, uploader, model, lens…"
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
Explore curated CO gantry settings. Click any row to see full
details.
</p>
</div>
{/* Stats */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1">
<li>Total Settings: {total}</li>
<li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li>
<li>Most Used Source: {mostCommonSource}</li>
</ul>
</div>
{/* Recently Added */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{recent.map((s) => (
<li key={s.submission_id}>
<Link
href={detailHref(s.submission_id)}
className="underline text-accent"
>
{s.setting_title || "Untitled"}
</Link>{" "}
<span className="text-muted-foreground">
by {ownerLabel(s.owner)}
{s.uploader ? ` (uploader: ${s.uploader})` : ""}
</span>
</li>
))}
</ul>
</div>
{/* Resources */}
<div className="card bg-card text-card-foreground p-4 xl:col-span-3">
<h2 className="text-lg font-semibold mb-2">Resources</h2>
<ul className="text-sm space-y-1">
<li>
<a
href="/materials"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
Material Safety Guide
</a>
</li>
<li>
<a
href="https://lasereverything.net/scripts/laspwrconvert.php"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
Laser Parameter Calculator
</a>
</li>
<li>
<a
href="https://jptoe.com/downloads"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
JPT Datasheets
</a>
</li>
</ul>
</div>
</div>
{/* Table */}
{loading ? (
<p className="text-muted">Loading settings...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No gantry settings found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="px-2 py-2 text-left">Photo</th>
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Owner</th>
<th className="px-2 py-2 text-left">Uploader</th>
<th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Coating</th>
<th className="px-2 py-2 text-left">Source</th>
<th className="px-2 py-2 text-left">Lens</th>
</tr>
</thead>
<tbody>
{filtered.map((s) => (
<tr key={s.submission_id} className="border-t border-border">
<td className="px-2 py-2">
{s.photo?.id ? (
<Image
src={`https://forms.lasereverything.net/assets/${s.photo.id}`}
alt={s.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={detailHref(s.submission_id)}
className="text-accent underline"
dangerouslySetInnerHTML={{
__html: highlight(s.setting_title || "—"),
}}
/>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(ownerLabel(s.owner)),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.uploader || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat_coat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.lens?.name || "—"),
}}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}