settings list fix for stable build

This commit is contained in:
makearmy 2025-10-01 17:22:46 -04:00
parent c630bfa665
commit 8069c6287b
7 changed files with 438 additions and 607 deletions

View file

@ -25,15 +25,16 @@ export default function CO2GalvoSettingDetailPage() {
"submission_id",
"setting_title",
"uploader",
// ✅ Owner (M2O) — request username explicitly
"owner.id",
"owner.first_name",
"owner.last_name",
"owner.email",
"owner.username",
// Content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// Denormalized relations / fields
"mat.name",
"mat_coat.name",
"mat_color.name",
@ -41,13 +42,14 @@ export default function CO2GalvoSettingDetailPage() {
"mat_thickness",
"source.make",
"source.model",
// ✅ laser_soft is a STRING, not a relation
"laser_soft",
"lens.field_size",
"lens.focal_length",
"lens_conf.name",
"lens_apt.name",
"lens_exp.name",
"focus",
"laser_soft.name",
"repeat_all",
"fill_settings",
"line_settings",
@ -67,11 +69,11 @@ export default function CO2GalvoSettingDetailPage() {
if (loading) return <p className="p-6">Loading setting...</p>;
if (!setting) return <p className="p-6">Setting not found.</p>;
// ✅ Prefer the owner's username (string). Return null if absent so claim UI shows.
const ownerName = (row: any) => {
const o = row?.owner;
if (!o) return null;
const name = [o.first_name, o.last_name].filter(Boolean).join(" ").trim();
return name || o.email || null;
return o.username || null;
};
const formatBoolean = (val: any) =>
@ -107,9 +109,10 @@ export default function CO2GalvoSettingDetailPage() {
);
};
// ✅ Point searches back to the list route under /settings
const openSearchInNewTab = (value: string) => {
if (!value || typeof window === "undefined") return;
const url = new URL("/co2-galvo-settings", window.location.origin);
const url = new URL("/settings/co2-galvo", window.location.origin);
url.searchParams.set("query", value);
const a = document.createElement("a");
a.href = url.toString();
@ -185,8 +188,9 @@ export default function CO2GalvoSettingDetailPage() {
)}
</div>
{/* ✅ Back link to list route */}
<a
href="/co2-galvo-settings"
href="/settings/co2-galvo"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to CO Galvo Settings
@ -249,8 +253,9 @@ export default function CO2GalvoSettingDetailPage() {
{/* Setup */}
<div className="card bg-card p-4">
<h2 className="text-xl font-semibold mb-2">Setup</h2>
{/* ✅ laser_soft is a string field */}
<p>
<strong>Software:</strong> {setting.laser_soft?.name || "—"}
<strong>Software:</strong> {setting.laser_soft || "—"}
</p>
<p>
<strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}
@ -385,16 +390,8 @@ export default function CO2GalvoSettingDetailPage() {
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "type", label: "Type" },
{ key: "dither", label: "Dither" },
{
key: "halftone_cell",
label: "Cell Size (mm)",
condition: (e: any) => e.dither === "halftone",
},
{
key: "halftone_angle",
label: "Halftone Angle",
condition: (e: any) => e.dither === "halftone",
},
{ key: "halftone_cell", label: "Cell Size (mm)", condition: (e: any) => e.dither === "halftone" },
{ key: "halftone_angle", label: "Halftone Angle", condition: (e: any) => e.dither === "halftone" },
{ key: "inversion", label: "Image Inverted" },
{ key: "interval", label: "Interval (mm)" },
{ key: "dot", label: "Dot-width Adjustment (mm)" },

View file

@ -6,8 +6,9 @@ import Link from "next/link";
import Image from "next/image";
type Owner = {
username?: string | null;
id?: string | number;
username?: string | null;
// keep extras harmlessly if API returns them
first_name?: string | null;
last_name?: string | null;
email?: string | null;
@ -37,10 +38,10 @@ export default function CO2GalvoSettingsPage() {
"submission_id",
"setting_title",
"uploader",
"owner.id", "owner.username",
"owner.first_name",
"owner.last_name",
"owner.email",
// owner (M2O) ensure username is requested
"owner.id",
"owner.username",
// assets / denorms
"photo.id",
"photo.title",
"mat.name",
@ -63,10 +64,7 @@ export default function CO2GalvoSettingsPage() {
.finally(() => setLoading(false));
}, []);
const ownerLabel = (o?: Owner) => {
if (!o) return "—";
return o.username || "—";
};
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
@ -93,7 +91,9 @@ export default function CO2GalvoSettingsPage() {
}, [settings, debouncedQuery]);
const total = 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 lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.lens?.field_size;
@ -102,161 +102,181 @@ export default function CO2GalvoSettingsPage() {
return acc;
}, {});
const mostCommonLens =
Object.entries(lensCounts).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
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 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);
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>
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 Galvo 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">
View and explore detailed CO galvo settings with context.
</p>
</div>
{/* 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 Galvo 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">
View and explore detailed CO galvo settings with context.
</p>
</div>
{/* How to use */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm">
Browse community CO galvo settings. Use search to narrow results. Click a row to view full configuration,
notes, and photos.
</p>
</div>
{/* How to use */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm">
Browse community CO galvo settings. Use search to narrow results.
Click a row to view full configuration, notes, and photos.
</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>
{/* 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 xl:col-span-3">
<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>
</div>
{/* Table */}
{loading ? (
<p className="text-muted">Loading settings...</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">
<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">
{/* Recently Added */}
<div className="card bg-card text-card-foreground p-4 xl:col-span-3">
<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="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?.field_size || "—") }}
/>
</tr>
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>
))}
</tbody>
</table>
</ul>
</div>
)}
</div>
);
</div>
{/* Table */}
{loading ? (
<p className="text-muted">Loading settings...</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">
<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?.field_size || "—"),
}}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -1,228 +0,0 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
export default function CO2GalvoSettingsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gal?fields=submission_id,setting_title,uploader,photo.filename_disk,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1`
)
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const highlight = (text) => {
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,
entry.uploader,
entry.mat?.name,
entry.mat_coat?.name,
entry.source?.model,
entry.lens?.field_size,
];
return fieldsToSearch.filter(Boolean).some((field) =>
String(field).toLowerCase().includes(q)
);
});
}, [settings, debouncedQuery]);
const totalSettings = settings.length;
const uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size;
const commonLens = settings.reduce((acc, cur) => {
const lens = cur.lens?.field_size;
if (!lens) return acc;
acc[lens] = (acc[lens] || 0) + 1;
return acc;
}, {});
const mostCommonLens = Object.entries(commonLens)
.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—";
const sourceModels = settings.reduce((acc, cur) => {
const model = cur.source?.model;
if (!model) return acc;
acc[model] = (acc[model] || 0) + 1;
return acc;
}, {});
const mostCommonSource = Object.entries(sourceModels)
.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—";
const recentSettings = [...settings]
.sort((a, b) => b.submission_id - 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>
<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 Galvo Settings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search settings by material, uploader, 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>
<a
href="/"
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">
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
<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.
</p>
</div>
<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: {totalSettings}</li>
<li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li>
<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={`/co2-galvo-settings/${s.submission_id}`} className="underline text-accent">
{s.setting_title || "Untitled"}
</Link>{" "}
by {s.uploader || "—"}
</li>
))}
</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>
<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 className="card bg-card text-card-foreground p-4 flex flex-col justify-between">
<div>
<h2 className="text-md font-semibold mb-2">Submit a Setting</h2>
<p className="text-sm text-muted-foreground mb-2">
Have a reliable galvo setting to share? Contribute to the community database.
</p>
</div>
<button disabled className="bg-muted text-foreground text-sm px-4 py-2 rounded opacity-50 cursor-not-allowed">
Coming Soon
</button>
</div>
</div>
{loading ? (
<p className="text-muted">Loading settings...</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">
<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">
<td className="px-2 py-2">
{setting.photo?.filename_disk ? (
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
alt={setting.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={`/co2-galvo-settings/${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?.field_size || "—") }} />
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -25,15 +25,19 @@ export default function CO2GantrySettingDetailPage() {
"submission_id",
"setting_title",
"uploader",
// owner (M2O) — include username explicitly
"owner.id",
"owner.username",
"owner.first_name",
"owner.last_name",
"owner.email",
// content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// denormalized relations
"mat.name",
"mat_coat.name",
"mat_color.name",
@ -41,9 +45,12 @@ export default function CO2GantrySettingDetailPage() {
"mat_thickness",
"source.make",
"source.model",
"laser_soft.name",
// NOTE: laser_soft is a STRING field (not relation)
"laser_soft",
// gantry uses lens.name (not field_size)
"lens.name",
"lens_conf.name",
// misc fields
"focus",
"repeat_all",
"fill_settings",
@ -66,11 +73,11 @@ export default function CO2GantrySettingDetailPage() {
if (loading) return <div className="p-6 max-w-7xl mx-auto">Loading</div>;
if (!setting) return <div className="p-6 max-w-7xl mx-auto">Setting not found.</div>;
// Prefer owner's username per schema; return null when absent so claim UI shows
const ownerName = (row: any) => {
const o = row?.owner;
if (!o) return null;
const name = [o.first_name, o.last_name].filter(Boolean).join(" ").trim();
return name || o.email || null;
return o.username || null;
};
const formatBoolean = (val: any) =>
@ -108,7 +115,7 @@ export default function CO2GantrySettingDetailPage() {
const openSearchInNewTab = (value: string) => {
if (!value || typeof window === "undefined") return;
const url = new URL("/co2-gantry-settings", window.location.origin);
const url = new URL("/settings/co2-gantry", window.location.origin);
url.searchParams.set("query", value);
window.open(url.toString(), "_blank", "noopener,noreferrer");
};
@ -181,7 +188,7 @@ export default function CO2GantrySettingDetailPage() {
</div>
<a
href="/co2-gantry-settings"
href="/settings/co2-gantry"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to CO Gantry Settings
@ -239,7 +246,8 @@ export default function CO2GantrySettingDetailPage() {
{/* Setup */}
<div className="card bg-card p-4">
<h2 className="text-xl font-semibold mb-2">Setup</h2>
<p><strong>Software:</strong> {setting.laser_soft?.name || "—"}</p>
{/* laser_soft is a string field */}
<p><strong>Software:</strong> {setting.laser_soft || "—"}</p>
<p><strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}</p>
<p className="mt-4"><strong>Focus:</strong> {setting.focus ?? "—"} mm</p>
<small>-Values Focus Closer | +Values Focus Further</small>

View file

@ -8,6 +8,7 @@ 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;
@ -37,10 +38,10 @@ export default function CO2GantrySettingsPage() {
"submission_id",
"setting_title",
"uploader",
"owner.id", "owner.username",
"owner.first_name",
"owner.last_name",
"owner.email",
// owner (M2O) ensure username is requested
"owner.id",
"owner.username",
// assets / denorms
"photo.id",
"photo.title",
"mat.name",
@ -64,10 +65,7 @@ export default function CO2GantrySettingsPage() {
.finally(() => setLoading(false));
}, []);
const ownerLabel = (o?: Owner) => {
if (!o) return "—";
return o.username || "—";
};
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
@ -94,7 +92,9 @@ export default function CO2GantrySettingsPage() {
}, [settings, debouncedQuery]);
const total = 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 lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.lens?.name;
@ -103,179 +103,210 @@ export default function CO2GantrySettingsPage() {
return acc;
}, {});
const mostCommonLens =
Object.entries(lensCounts).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] ||
"—";
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 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);
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>
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>
{/* 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>
{/* 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">
{/* 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="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>
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>
))}
</tbody>
</table>
</ul>
</div>
)}
</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>
);
}

View file

@ -16,6 +16,8 @@ export default function FiberSettingDetailPage() {
const [claimErr, setClaimErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber/${id}` +
`?fields=` +
@ -23,15 +25,16 @@ export default function FiberSettingDetailPage() {
"submission_id",
"setting_title",
"uploader",
// Owner (M2O): prefer username
"owner.id",
"owner.first_name",
"owner.last_name",
"owner.email",
"owner.username",
// Content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// Relations / denorms
"mat.name",
"mat_coat.name",
"mat_color.name",
@ -41,8 +44,9 @@ export default function FiberSettingDetailPage() {
"source.model",
"lens.field_size",
"lens.focal_length",
// laser_soft is a STRING field
"laser_soft",
"focus",
"laser_soft.name",
"repeat_all",
"fill_settings",
"line_settings",
@ -62,11 +66,11 @@ export default function FiberSettingDetailPage() {
if (loading) return <p className="p-6">Loading setting...</p>;
if (!setting) return <p className="p-6">Setting not found.</p>;
// Prefer owner's username per schema
const ownerName = (row: any) => {
const o = row?.owner;
if (!o) return null;
const name = [o.first_name, o.last_name].filter(Boolean).join(" ").trim();
return name || o.email || null;
return o.username || null;
};
const formatBoolean = (val: any) =>
@ -103,7 +107,8 @@ export default function FiberSettingDetailPage() {
};
const openSearchInNewTab = (value: string) => {
const url = new URL("/fiber-settings", window.location.origin);
if (!value || typeof window === "undefined") return;
const url = new URL("/settings/fiber", window.location.origin);
url.searchParams.set("query", value);
const a = document.createElement("a");
a.href = url.toString();
@ -180,7 +185,7 @@ export default function FiberSettingDetailPage() {
</div>
<a
href="/fiber-settings"
href="/settings/fiber"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to Fiber Settings
@ -243,8 +248,9 @@ export default function FiberSettingDetailPage() {
{/* Setup */}
<div className="card bg-card p-4">
<h2 className="text-xl font-semibold mb-2">Setup</h2>
{/* laser_soft is a string field */}
<p>
<strong>Software:</strong> {setting.laser_soft?.name || "—"}
<strong>Software:</strong> {setting.laser_soft || "—"}
</p>
<p>
<strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}

View file

@ -25,15 +25,16 @@ export default function UVSettingDetailPage() {
"submission_id",
"setting_title",
"uploader",
// ✅ Owner (M2O) — use username (string)
"owner.id",
"owner.first_name",
"owner.last_name",
"owner.email",
"owner.username",
// content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// relations / denorms
"mat.name",
"mat_coat.name",
"mat_color.name",
@ -42,6 +43,7 @@ export default function UVSettingDetailPage() {
"source.model",
"lens.field_size",
"lens.focal_length",
// misc
"focus",
"fill_settings",
"line_settings",
@ -61,12 +63,8 @@ export default function UVSettingDetailPage() {
if (loading) return <p className="p-6">Loading setting...</p>;
if (!setting) return <p className="p-6">Setting not found.</p>;
const ownerName = (row: any) => {
const o = row?.owner;
if (!o) return null;
const name = [o.first_name, o.last_name].filter(Boolean).join(" ").trim();
return name || o.email || null;
};
// ✅ Prefer owner's username per schema
const ownerName = (row: any) => row?.owner?.username ?? null;
const formatBoolean = (val: any) =>
val ? "Enabled" : val === false ? "Disabled" : "—";
@ -88,10 +86,8 @@ export default function UVSettingDetailPage() {
return (
<p key={key} className="text-sm">
<strong>{label}:</strong>{" "}
{typeof value === "boolean"
? formatBoolean(value)
: value ?? "—"}
</p>
{typeof value === "boolean" ? formatBoolean(value) : value ?? "—"}
</p>
);
})}
</div>
@ -101,9 +97,10 @@ export default function UVSettingDetailPage() {
);
};
// ✅ Point searches/back link to /settings/uv
const openSearchInNewTab = (value: string) => {
if (!value || typeof window === "undefined") return;
const url = new URL("/uv-settings", window.location.origin);
const url = new URL("/settings/uv", window.location.origin);
url.searchParams.set("query", value);
const a = document.createElement("a");
a.href = url.toString();
@ -180,7 +177,7 @@ export default function UVSettingDetailPage() {
</div>
<a
href="/uv-settings"
href="/settings/uv"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to UV Settings