standardized list pages with owner

This commit is contained in:
makearmy 2025-09-27 23:34:35 -04:00
parent 7c56170747
commit 90de3b4c44
3 changed files with 599 additions and 484 deletions

View file

@ -14,26 +14,57 @@ export default function CO2GalvoSettingsPage() {
const [settings, setSettings] = useState<any[]>([]); const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// canonical detail href builder
const detailHref = (id: string | number) => `/settings/co2-galvo/${id}`; const detailHref = (id: string | number) => `/settings/co2-galvo/${id}`;
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer); return () => clearTimeout(t);
}, [query]); }, [query]);
useEffect(() => { useEffect(() => {
fetch( const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gal?fields=submission_id,setting_title,uploader,photo.id,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1` `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gal?fields=` +
) [
"submission_id",
"setting_title",
"uploader",
"owner.display_name",
"owner.first_name",
"owner.last_name",
"owner.username",
"owner.email",
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
"lens.field_size",
"lens.name",
].join(",") +
"&limit=-1";
fetch(url, { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
setSettings(data.data || []); setSettings(data?.data || []);
setLoading(false); setLoading(false);
}) })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); }, []);
const ownerName = (owner?: any) => {
if (!owner) return "—";
return (
owner.display_name ||
[owner.first_name, owner.last_name].filter(Boolean).join(" ").trim() ||
owner.username ||
owner.email ||
"—"
);
};
const lensLabel = (row: any) => row?.lens?.field_size ?? row?.lens?.name ?? "—";
const highlight = (text?: string) => { const highlight = (text?: string) => {
if (!debouncedQuery) return text || ""; if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi"); const regex = new RegExp(`(${debouncedQuery})`, "gi");
@ -45,11 +76,12 @@ export default function CO2GalvoSettingsPage() {
return settings.filter((entry) => { return settings.filter((entry) => {
const fieldsToSearch = [ const fieldsToSearch = [
entry.setting_title, entry.setting_title,
entry.uploader, ownerName(entry.owner),
entry.mat?.name, entry.uploader,
entry.mat_coat?.name, entry.mat?.name,
entry.source?.model, entry.mat_coat?.name,
entry.lens?.field_size, entry.source?.model,
lensLabel(entry),
]; ];
return fieldsToSearch return fieldsToSearch
.filter(Boolean) .filter(Boolean)
@ -57,145 +89,151 @@ export default function CO2GalvoSettingsPage() {
}); });
}, [settings, debouncedQuery]); }, [settings, debouncedQuery]);
// Stats
const totalSettings = settings.length; const totalSettings = settings.length;
const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size; const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size;
const commonLens = settings.reduce((acc: Record<string, number>, cur) => { const commonLens = settings.reduce((acc: Record<string, number>, cur) => {
const lens = cur.lens?.field_size; const l = lensLabel(cur);
if (!lens) return acc; if (!l || l === "—") return acc;
acc[lens] = (acc[lens] || 0) + 1; acc[l] = (acc[l] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonLens = const mostCommonLens =
Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
const sourceModels = settings.reduce((acc: Record<string, number>, cur) => { const sourceModels = settings.reduce((acc: Record<string, number>, cur) => {
const model = cur.source?.model; const model = cur.source?.model;
if (!model) return acc; if (!model) return acc;
acc[model] = (acc[model] || 0) + 1; acc[model] = (acc[model] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonSource = const mostCommonSource =
Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
const recentSettings = [...settings] const recentSettings = [...settings]
.sort((a, b) => b.submission_id - a.submission_id) .sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5); .slice(0, 5);
return ( return (
<div className="p-6 max-w-7xl mx-auto"> <div className="p-6 max-w-7xl mx-auto">
<style jsx global>{` <style jsx global>{`
mark { mark {
background: #ffde59; background: #ffde59;
color: #242424; color: #242424;
padding: 0 2px; padding: 0 2px;
border-radius: 2px; border-radius: 2px;
} }
`}</style> `}</style>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6"> {/* Header + Search */}
<div className="card bg-card text-card-foreground p-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<h1 className="text-2xl font-bold mb-2">CO Galvo Settings</h1> <div className="card bg-card text-card-foreground p-4">
<input <h1 className="text-2xl font-bold mb-2">CO Galvo Settings</h1>
type="search" <input
value={query} type="search"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search settings by material, uploader, etc..." onChange={(e) => setQuery(e.target.value)}
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2" placeholder="Search by material, owner, uploader, model, etc…"
/> className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
<p className="text-sm text-muted-foreground mb-2"> />
View and explore detailed CO galvo settings with context. <p className="text-sm text-muted-foreground">
</p> View and explore detailed CO galvo settings with context.
<a </p>
href="/" </div>
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
>
Back to Main Menu
</a>
</div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2> <h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm"> <p className="text-sm">
Browse real-world CO galvo settings from the community. Use the search to narrow results. Click any setting to view its full configuration, notes, and photos. Click any linked term to find related settings. Browse community CO galvo settings. Use the search to narrow results. Click any title
</p> to view the full configuration, notes, and photos.
</div> </p>
</div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2> <h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
<li>Total Settings: {totalSettings}</li> <li>Total Settings: {totalSettings}</li>
<li>Unique Materials: {uniqueMaterials}</li> <li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li> <li>Most Common Lens: {mostCommonLens}</li>
<li>Most Used Source: {mostCommonSource}</li> <li>Most Used Source: {mostCommonSource}</li>
</ul> </ul>
</div> </div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Recently Added</h2> <h2 className="text-lg font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{recentSettings.map((s) => ( {recentSettings.map((s) => (
<li key={s.submission_id}> <li key={s.submission_id}>
<Link href={detailHref(s.submission_id)} className="underline text-accent"> <Link href={detailHref(s.submission_id)} className="underline text-accent">
{s.setting_title || "Untitled"} {s.setting_title || "Untitled"}
</Link>{" "} </Link>{" "}
by {s.uploader || "—"} <span className="text-muted-foreground">
</li> by {ownerName(s.owner) !== "—" ? ownerName(s.owner) : s.uploader || "—"}
))} </span>
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<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>
<li> ))}
<a href="https://lasereverything.net/scripts/laspwrconvert.php" target="_blank" rel="noopener noreferrer" className="underline text-accent">Laser Parameter Calculator</a> </ul>
</li> </div>
<li>
<a href="https://jptoe.com/downloads" target="_blank" rel="noopener noreferrer" className="underline text-accent">JPT Datasheets</a>
</li>
</ul>
</div>
<div className="card bg-card text-card-foreground p-4 flex flex-col justify-between"> <div className="card bg-card text-card-foreground p-4">
<div> <h2 className="text-lg font-semibold mb-2">Resources</h2>
<h2 className="text-md font-semibold mb-2">Submit a Setting</h2> <ul className="text-sm space-y-1">
<p className="text-sm text-muted-foreground mb-2"> <li>
Have a reliable galvo setting to share? Contribute to the community database. <a href="/materials" target="_blank" rel="noopener noreferrer" className="underline text-accent">
</p> Material Safety Guide
</div> </a>
<Link </li>
href="/submit/settings?target=settings_co2gal" <li>
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition" <a
> href="https://lasereverything.net/scripts/laspwrconvert.php"
Submit a Setting target="_blank"
</Link> rel="noopener noreferrer"
</div> className="underline text-accent"
</div> >
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>
{loading ? ( {/* Table */}
<p className="text-muted">Loading settings...</p> {loading ? (
) : filtered.length === 0 ? ( <p className="text-muted">Loading settings...</p>
<p className="text-muted">No CO galvo settings found.</p> ) : filtered.length === 0 ? (
) : ( <p className="text-muted">No CO galvo settings found.</p>
<div className="overflow-x-auto"> ) : (
<table className="w-full text-sm"> <div className="overflow-x-auto">
<thead> <table className="w-full text-sm">
<tr> <thead>
<th className="px-2 py-2 text-left">Photo</th> <tr>
<th className="px-2 py-2 text-left">Title</th> <th className="px-2 py-2 text-left">Photo</th>
<th className="px-2 py-2 text-left">Uploader</th> <th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Material</th> <th className="px-2 py-2 text-left">Owner</th>
<th className="px-2 py-2 text-left">Coating</th> <th className="px-2 py-2 text-left">Uploader</th>
<th className="px-2 py-2 text-left">Source</th> <th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Lens</th> <th className="px-2 py-2 text-left">Coating</th>
</tr> <th className="px-2 py-2 text-left">Source</th>
</thead> <th className="px-2 py-2 text-left">Lens</th>
<tbody> </tr>
{filtered.map((setting) => ( </thead>
<tbody>
{filtered.map((setting) => {
const ownerText = ownerName(setting.owner);
return (
<tr key={setting.submission_id} className="border-t border-border"> <tr key={setting.submission_id} className="border-t border-border">
<td className="px-2 py-2"> <td className="px-2 py-2">
{setting.photo?.id ? ( {setting.photo?.id ? (
@ -214,11 +252,17 @@ export default function CO2GalvoSettingsPage() {
<Link <Link
href={detailHref(setting.submission_id)} href={detailHref(setting.submission_id)}
className="text-accent underline" className="text-accent underline"
dangerouslySetInnerHTML={{ __html: highlight(setting.setting_title || "—") }} dangerouslySetInnerHTML={{
__html: highlight(setting.setting_title || "—"),
}}
/> />
</td> </td>
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(ownerText) }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }} dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }}
/> />
<td <td
@ -235,14 +279,15 @@ export default function CO2GalvoSettingsPage() {
/> />
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.lens?.field_size || "—") }} dangerouslySetInnerHTML={{ __html: highlight(lensLabel(setting)) }}
/> />
</tr> </tr>
))} );
</tbody> })}
</table> </tbody>
</div> </table>
)}
</div> </div>
); )}
</div>
);
} }

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@ -14,26 +14,57 @@ export default function CO2GantrySettingsPage() {
const [settings, setSettings] = useState<any[]>([]); const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// canonical detail href builder
const detailHref = (id: string | number) => `/settings/co2-gantry/${id}`; const detailHref = (id: string | number) => `/settings/co2-gantry/${id}`;
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer); return () => clearTimeout(t);
}, [query]); }, [query]);
useEffect(() => { useEffect(() => {
fetch( const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan?fields=submission_id,setting_title,uploader,photo.id,photo.title,mat.name,mat_coat.name,source.model,lens.name&limit=-1` `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan?fields=` +
) [
"submission_id",
"setting_title",
"uploader",
"owner.display_name",
"owner.first_name",
"owner.last_name",
"owner.username",
"owner.email",
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
"lens.field_size",
"lens.name",
].join(",") +
"&limit=-1";
fetch(url, { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
setSettings(data.data || []); setSettings(data?.data || []);
setLoading(false); setLoading(false);
}) })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); }, []);
const ownerName = (owner?: any) => {
if (!owner) return "—";
return (
owner.display_name ||
[owner.first_name, owner.last_name].filter(Boolean).join(" ").trim() ||
owner.username ||
owner.email ||
"—"
);
};
const lensLabel = (row: any) => row?.lens?.field_size ?? row?.lens?.name ?? "—";
const highlight = (text?: string) => { const highlight = (text?: string) => {
if (!debouncedQuery) return text || ""; if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi"); const regex = new RegExp(`(${debouncedQuery})`, "gi");
@ -45,11 +76,12 @@ export default function CO2GantrySettingsPage() {
return settings.filter((entry) => { return settings.filter((entry) => {
const fieldsToSearch = [ const fieldsToSearch = [
entry.setting_title, entry.setting_title,
entry.uploader, ownerName(entry.owner),
entry.mat?.name, entry.uploader,
entry.mat_coat?.name, entry.mat?.name,
entry.source?.model, entry.mat_coat?.name,
entry.lens?.name, entry.source?.model,
lensLabel(entry),
]; ];
return fieldsToSearch return fieldsToSearch
.filter(Boolean) .filter(Boolean)
@ -57,198 +89,202 @@ export default function CO2GantrySettingsPage() {
}); });
}, [settings, debouncedQuery]); }, [settings, debouncedQuery]);
// Stats
const totalSettings = settings.length; const totalSettings = settings.length;
const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size; const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size;
const commonLens = settings.reduce((acc: Record<string, number>, cur) => { const commonLens = settings.reduce((acc: Record<string, number>, cur) => {
const lens = cur.lens?.name; const l = lensLabel(cur);
if (!lens) return acc; if (!l || l === "—") return acc;
acc[lens] = (acc[lens] || 0) + 1; acc[l] = (acc[l] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonLens = const mostCommonLens =
Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
const sourceModels = settings.reduce((acc: Record<string, number>, cur) => { const sourceModels = settings.reduce((acc: Record<string, number>, cur) => {
const model = cur.source?.model; const model = cur.source?.model;
if (!model) return acc; if (!model) return acc;
acc[model] = (acc[model] || 0) + 1; acc[model] = (acc[model] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonSource = const mostCommonSource =
Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
const recentSettings = [...settings] const recentSettings = [...settings]
.sort((a, b) => b.submission_id - a.submission_id) .sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5); .slice(0, 5);
return ( return (
<div className="p-6 max-w-7xl mx-auto"> <div className="p-6 max-w-7xl mx-auto">
<style jsx global>{` <style jsx global>{`
mark { mark {
background: #ffde59; background: #ffde59;
color: #242424; color: #242424;
padding: 0 2px; padding: 0 2px;
border-radius: 2px; border-radius: 2px;
} }
`}</style> `}</style>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6"> {/* Header + Search */}
<div className="card bg-card text-card-foreground p-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<h1 className="text-2xl font-bold mb-2">CO Gantry Settings</h1> <div className="card bg-card text-card-foreground p-4">
<input <h1 className="text-2xl font-bold mb-2">CO Gantry Settings</h1>
type="search" <input
value={query} type="search"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search settings by material, uploader, etc..." onChange={(e) => setQuery(e.target.value)}
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2" placeholder="Search by material, owner, uploader, model, etc…"
/> className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
<p className="text-sm text-muted-foreground mb-2"> />
Explore curated CO gantry settings. Search by material, uploader, or source. <p className="text-sm text-muted-foreground">
</p> Explore curated CO gantry settings. Click any row to see full details.
<a </p>
href="/" </div>
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
>
Back to Main Menu
</a>
</div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2> <h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm"> <p className="text-sm">
Browse real-world CO gantry settings. Search or filter results, and click any setting for full configuration and notes. Browse real-world CO gantry settings. Use the search to narrow results. Click any title
</p> to view the full configuration, notes, and photos.
</div> </p>
</div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2> <h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
<li>Total Settings: {totalSettings}</li> <li>Total Settings: {totalSettings}</li>
<li>Unique Materials: {uniqueMaterials}</li> <li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li> <li>Most Common Lens: {mostCommonLens}</li>
<li>Most Used Source: {mostCommonSource}</li> <li>Most Used Source: {mostCommonSource}</li>
</ul> </ul>
</div> </div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Recently Added</h2> <h2 className="text-lg font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{recentSettings.map((s) => ( {recentSettings.map((s) => (
<li key={s.submission_id}> <li key={s.submission_id}>
<Link href={detailHref(s.submission_id)} className="underline text-accent"> <Link href={detailHref(s.submission_id)} className="underline text-accent">
{s.setting_title || "Untitled"} {s.setting_title || "Untitled"}
</Link>{" "} </Link>{" "}
by {s.uploader || "—"} <span className="text-muted-foreground">
</li> by {ownerName(s.owner) !== "—" ? ownerName(s.owner) : s.uploader || "—"}
))} </span>
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<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>
<li> ))}
<a href="https://lasereverything.net/scripts/laspwrconvert.php" target="_blank" rel="noopener noreferrer" className="underline text-accent"> </ul>
Laser Parameter Calculator </div>
</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 className="card bg-card text-card-foreground p-4 flex flex-col justify-between"> <div className="card bg-card text-card-foreground p-4 xl:col-span-3">
<div> <h2 className="text-lg font-semibold mb-2">Resources</h2>
<h2 className="text-md font-semibold mb-2">Submit a Setting</h2> <ul className="text-sm space-y-1">
<p className="text-sm text-muted-foreground mb-2"> <li>
Got a dialed-in gantry setting? Contribute it to the database. <a href="/materials" target="_blank" rel="noopener noreferrer" className="underline text-accent">
</p> Material Safety Guide
</div> </a>
<Link </li>
href="/submit/settings?target=settings_co2gan" <li>
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition" <a
> href="https://lasereverything.net/scripts/laspwrconvert.php"
Submit a Setting target="_blank"
</Link> rel="noopener noreferrer"
</div> className="underline text-accent"
</div> >
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>
{loading ? ( {/* Table */}
<p className="text-muted">Loading settings...</p> {loading ? (
) : filtered.length === 0 ? ( <p className="text-muted">Loading settings...</p>
<p className="text-muted">No gantry settings found.</p> ) : filtered.length === 0 ? (
) : ( <p className="text-muted">No CO gantry settings found.</p>
<div className="overflow-x-auto"> ) : (
<table className="w-full text-sm"> <div className="overflow-x-auto">
<thead> <table className="w-full text-sm">
<tr> <thead>
<th className="px-2 py-2 text-left">Photo</th> <tr>
<th className="px-2 py-2 text-left">Title</th> <th className="px-2 py-2 text-left">Photo</th>
<th className="px-2 py-2 text-left">Uploader</th> <th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Material</th> <th className="px-2 py-2 text-left">Owner</th>
<th className="px-2 py-2 text-left">Coating</th> <th className="px-2 py-2 text-left">Uploader</th>
<th className="px-2 py-2 text-left">Source</th> <th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Lens</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((setting) => (
<tr key={setting.submission_id} className="border-t border-border">
<td className="px-2 py-2">
{setting.photo?.id ? (
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.id}`}
alt={setting.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={detailHref(setting.submission_id)}
className="text-accent underline"
dangerouslySetInnerHTML={{
__html: highlight(setting.setting_title || "—"),
}}
/>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(ownerName(setting.owner)) }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.mat?.name || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.mat_coat?.name || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.source?.model || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(lensLabel(setting)) }}
/>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{filtered.map((setting) => ( </table>
<tr key={setting.submission_id} className="border-t border-border">
<td className="px-2 py-2">
{setting.photo?.id ? (
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.id}`}
alt={setting.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={detailHref(setting.submission_id)}
className="text-accent underline"
dangerouslySetInnerHTML={{ __html: highlight(setting.setting_title || "—") }}
/>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.mat?.name || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.mat_coat?.name || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.source?.model || "—") }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.lens?.name || "—") }}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
); )}
</div>
);
} }

View file

@ -14,26 +14,57 @@ export default function UVSettingsPage() {
const [settings, setSettings] = useState<any[]>([]); const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// canonical detail href builder
const detailHref = (id: string | number) => `/settings/uv/${id}`; const detailHref = (id: string | number) => `/settings/uv/${id}`;
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer); return () => clearTimeout(t);
}, [query]); }, [query]);
useEffect(() => { useEffect(() => {
fetch( const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_uv?fields=submission_id,setting_title,uploader,photo.id,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1` `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_uv?fields=` +
) [
"submission_id",
"setting_title",
"uploader",
"owner.display_name",
"owner.first_name",
"owner.last_name",
"owner.username",
"owner.email",
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
"lens.field_size",
"lens.name",
].join(",") +
"&limit=-1";
fetch(url, { cache: "no-store" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
setSettings(data.data || []); setSettings(data?.data || []);
setLoading(false); setLoading(false);
}) })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); }, []);
const ownerName = (owner?: any) => {
if (!owner) return "—";
return (
owner.display_name ||
[owner.first_name, owner.last_name].filter(Boolean).join(" ").trim() ||
owner.username ||
owner.email ||
"—"
);
};
const lensLabel = (row: any) => row?.lens?.field_size ?? row?.lens?.name ?? "—";
const highlight = (text?: string) => { const highlight = (text?: string) => {
if (!debouncedQuery) return text || ""; if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi"); const regex = new RegExp(`(${debouncedQuery})`, "gi");
@ -45,11 +76,12 @@ export default function UVSettingsPage() {
return settings.filter((entry) => { return settings.filter((entry) => {
const fieldsToSearch = [ const fieldsToSearch = [
entry.setting_title, entry.setting_title,
entry.uploader, ownerName(entry.owner),
entry.mat?.name, entry.uploader,
entry.mat_coat?.name, entry.mat?.name,
entry.source?.model, entry.mat_coat?.name,
entry.lens?.field_size, entry.source?.model,
lensLabel(entry),
]; ];
return fieldsToSearch return fieldsToSearch
.filter(Boolean) .filter(Boolean)
@ -57,156 +89,151 @@ export default function UVSettingsPage() {
}); });
}, [settings, debouncedQuery]); }, [settings, debouncedQuery]);
// Stats
const totalSettings = settings.length; const totalSettings = settings.length;
const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size; const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size;
const commonLens = settings.reduce((acc: Record<string, number>, cur) => { const commonLens = settings.reduce((acc: Record<string, number>, cur) => {
const lens = cur.lens?.field_size; const l = lensLabel(cur);
if (!lens) return acc; if (!l || l === "—") return acc;
acc[lens] = (acc[lens] || 0) + 1; acc[l] = (acc[l] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonLens = const mostCommonLens =
Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
const sourceModels = settings.reduce((acc: Record<string, number>, cur) => { const sourceModels = settings.reduce((acc: Record<string, number>, cur) => {
const model = cur.source?.model; const model = cur.source?.model;
if (!model) return acc; if (!model) return acc;
acc[model] = (acc[model] || 0) + 1; acc[model] = (acc[model] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonSource = const mostCommonSource =
Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
const recentSettings = [...settings] const recentSettings = [...settings]
.sort((a, b) => b.submission_id - a.submission_id) .sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5); .slice(0, 5);
return ( return (
<div className="p-6 max-w-7xl mx-auto"> <div className="p-6 max-w-7xl mx-auto">
<style jsx global>{` <style jsx global>{`
mark { mark {
background: #ffde59; background: #ffde59;
color: #242424; color: #242424;
padding: 0 2px; padding: 0 2px;
border-radius: 2px; border-radius: 2px;
} }
`}</style> `}</style>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6"> {/* Header + Search */}
<div className="card bg-card text-card-foreground p-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<h1 className="text-2xl font-bold mb-2">UV Laser Settings</h1> <div className="card bg-card text-card-foreground p-4">
<input <h1 className="text-2xl font-bold mb-2">UV Laser Settings</h1>
type="search" <input
value={query} type="search"
onChange={(e) => setQuery(e.target.value)} value={query}
placeholder="Search settings by material, uploader, etc..." onChange={(e) => setQuery(e.target.value)}
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2" placeholder="Search by material, owner, uploader, model, etc…"
/> className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
<p className="text-sm text-muted-foreground mb-2"> />
View and explore detailed UV laser settings with context. <p className="text-sm text-muted-foreground">
</p> View and explore detailed UV settings with context.
<a </p>
href="/" </div>
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
>
Back to Main Menu
</a>
</div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2> <h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm"> <p className="text-sm">
Browse real-world UV laser settings from the community. Use the search to narrow results. Click any setting to view its full configuration, notes, and photos. Click any linked term to find related settings. Browse community UV laser settings. Use search to narrow results. Click any
</p> title to view full configuration, notes, and photos.
</div> </p>
</div>
<div className="card bg-card text-card-foreground p-4"> <div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Resources</h2> <h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
<li> <li>Total Settings: {totalSettings}</li>
<a href="/materials" target="_blank" rel="noopener noreferrer" className="underline text-accent"> <li>Unique Materials: {uniqueMaterials}</li>
Material Safety Guide <li>Most Common Lens: {mostCommonLens}</li>
</a> <li>Most Used Source: {mostCommonSource}</li>
</ul>
</div>
<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">
{recentSettings.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 {ownerName(s.owner) !== "—" ? ownerName(s.owner) : s.uploader || "—"}
</span>
</li> </li>
<li> ))}
<a </ul>
href="https://lasereverything.net/scripts/laspwrconvert.php" </div>
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 className="card bg-card text-card-foreground p-4 flex flex-col justify-between"> <div className="card bg-card text-card-foreground p-4">
<div> <h2 className="text-lg font-semibold mb-2">Resources</h2>
<h2 className="text-md font-semibold mb-2">Submit a Setting</h2> <ul className="text-sm space-y-1">
<p className="text-sm text-muted-foreground mb-2"> <li>
Have a reliable UV setting to share? Contribute to the community database. <a href="/materials" target="_blank" rel="noopener noreferrer" className="underline text-accent">
</p> Material Safety Guide
</div> </a>
<Link </li>
href="/submit/settings?target=settings_uv" <li>
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition" <a
> href="https://lasereverything.net/scripts/laspwrconvert.php"
Submit a Setting target="_blank"
</Link> rel="noopener noreferrer"
</div> 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>
<div className="card bg-card text-card-foreground p-4"> {/* Table */}
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2> {loading ? (
<ul className="text-sm space-y-1"> <p className="text-muted">Loading settings...</p>
<li>Total Settings: {totalSettings}</li> ) : filtered.length === 0 ? (
<li>Unique Materials: {uniqueMaterials}</li> <p className="text-muted">No UV settings found.</p>
<li>Most Common Lens: {mostCommonLens}</li> ) : (
<li>Most Used Source: {mostCommonSource}</li> <div className="overflow-x-auto">
</ul> <table className="w-full text-sm">
</div> <thead>
<tr>
<div className="card bg-card text-card-foreground p-4"> <th className="px-2 py-2 text-left">Photo</th>
<h2 className="text-lg font-semibold mb-2">Recently Added</h2> <th className="px-2 py-2 text-left">Title</th>
<ul className="text-sm space-y-1"> <th className="px-2 py-2 text-left">Owner</th>
{recentSettings.map((s) => ( <th className="px-2 py-2 text-left">Uploader</th>
<li key={s.submission_id}> <th className="px-2 py-2 text-left">Material</th>
<Link href={detailHref(s.submission_id)} className="underline text-accent"> <th className="px-2 py-2 text-left">Coating</th>
{s.setting_title || "Untitled"} <th className="px-2 py-2 text-left">Source</th>
</Link>{" "} <th className="px-2 py-2 text-left">Lens</th>
by {s.uploader || "—"} </tr>
</li> </thead>
))} <tbody>
</ul> {filtered.map((setting) => {
</div> const ownerText = ownerName(setting.owner);
</div> return (
{loading ? (
<p className="text-muted">Loading settings...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No UV 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">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((setting) => (
<tr key={setting.submission_id} className="border-t border-border"> <tr key={setting.submission_id} className="border-t border-border">
<td className="px-2 py-2"> <td className="px-2 py-2">
{setting.photo?.id ? ( {setting.photo?.id ? (
@ -225,11 +252,17 @@ export default function UVSettingsPage() {
<Link <Link
href={detailHref(setting.submission_id)} href={detailHref(setting.submission_id)}
className="text-accent underline" className="text-accent underline"
dangerouslySetInnerHTML={{ __html: highlight(setting.setting_title || "—") }} dangerouslySetInnerHTML={{
__html: highlight(setting.setting_title || "—"),
}}
/> />
</td> </td>
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(ownerText) }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }} dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }}
/> />
<td <td
@ -246,14 +279,15 @@ export default function UVSettingsPage() {
/> />
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.lens?.field_size || "—") }} dangerouslySetInnerHTML={{ __html: highlight(lensLabel(setting)) }}
/> />
</tr> </tr>
))} );
</tbody> })}
</table> </tbody>
</div> </table>
)}
</div> </div>
); )}
</div>
);
} }