added claim/owner routes and components, test fiber page

This commit is contained in:
makearmy 2025-09-27 22:48:26 -04:00
parent 36ae15162a
commit c93daeda4b
5 changed files with 422 additions and 195 deletions

58
app/api/claims/route.ts Normal file
View file

@ -0,0 +1,58 @@
// app/api/claims/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API =
process.env.DIRECTUS_URL /* server-only */ ??
process.env.NEXT_PUBLIC_API_BASE_URL /* fallback */;
const ALLOWED = new Set([
'settings_fiber',
'settings_uv',
'settings_co2gal',
'settings_co2gan',
'projects',
]);
export async function POST(req: Request) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { target_collection, target_id } = body ?? {};
if (!ALLOWED.has(String(target_collection)) || target_id == null) {
return NextResponse.json({ error: 'Invalid target' }, { status: 400 });
}
const token =
cookies().get('directus_access_token')?.value ??
cookies().get('ma_at')?.value ??
'';
if (!token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const r = await fetch(`${API}/items/user_claims`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
body: JSON.stringify({ target_collection, target_id }),
cache: 'no-store',
next: { revalidate: 0 },
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = data?.errors?.[0]?.message ?? data?.message ?? 'Directus error';
return NextResponse.json({ error: msg }, { status: r.status });
}
return NextResponse.json({ ok: true, data }, { status: 200 });
}

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";
@ -18,22 +18,51 @@ export default function FiberSettingsPage() {
const detailHref = (id: string | number) => `/settings/fiber/${id}`; const detailHref = (id: string | number) => `/settings/fiber/${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( // Include owner fields now that settings have an M2O "owner"
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber?fields=submission_id,setting_title,uploader,photo.id,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1` const url =
) `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
"owner.display_name",
"owner.first_name",
"owner.last_name",
"owner.email",
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
"lens.field_size",
].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 = (row: any) => {
const o = row?.owner || {};
return (
o.display_name ||
[o.first_name, o.last_name].filter(Boolean).join(" ") ||
o.email ||
"—"
);
};
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");
@ -46,19 +75,22 @@ export default function FiberSettingsPage() {
const fieldsToSearch = [ const fieldsToSearch = [
entry.setting_title, entry.setting_title,
entry.uploader, entry.uploader,
entry.mat?.name, ownerName(entry),
entry.mat_coat?.name, entry.mat?.name,
entry.source?.model, entry.mat_coat?.name,
entry.lens?.field_size, entry.source?.model,
entry.lens?.field_size,
]; ];
return fieldsToSearch.filter(Boolean).some((field: string) => return fieldsToSearch
String(field).toLowerCase().includes(q) .filter(Boolean)
); .some((field: string) => String(field).toLowerCase().includes(q));
}); });
}, [settings, debouncedQuery]); }, [settings, debouncedQuery]);
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 lens = cur.lens?.field_size;
@ -67,7 +99,9 @@ export default function FiberSettingsPage() {
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;
@ -76,10 +110,12 @@ export default function FiberSettingsPage() {
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 (
@ -93,6 +129,7 @@ export default function FiberSettingsPage() {
} }
`}</style> `}</style>
{/* Header + Search */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6"> <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"> <div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">Fiber Laser Settings</h1> <h1 className="text-2xl font-bold mb-2">Fiber Laser Settings</h1>
@ -100,24 +137,20 @@ export default function FiberSettingsPage() {
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search settings by material, uploader, etc..." placeholder="Search by material, owner, uploader, model, etc…"
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2" className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/> />
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground">
View and explore detailed fiber laser settings with context. View and explore detailed fiber laser settings with context.
</p> </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>
<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 fiber 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 fiber laser settings. Use the search to narrow
results. Click any setting title to view its full configuration,
notes, and photos.
</p> </p>
</div> </div>
@ -136,10 +169,16 @@ export default function FiberSettingsPage() {
<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 || "—"} {/* keep uploader for now while claims/owners migrate */}
<span className="text-muted-foreground">
by {ownerName(s) !== "—" ? ownerName(s) : s.uploader || "—"}
</span>
</li> </li>
))} ))}
</ul> </ul>
@ -180,23 +219,9 @@ export default function FiberSettingsPage() {
</li> </li>
</ul> </ul>
</div> </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 fiber setting to share? Contribute to the community database.
</p>
</div>
<Link
href="/submit/settings?target=settings_fiber"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</div>
</div> </div>
{/* Table */}
{loading ? ( {loading ? (
<p className="text-muted">Loading settings...</p> <p className="text-muted">Loading settings...</p>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
@ -208,6 +233,7 @@ export default function FiberSettingsPage() {
<tr> <tr>
<th className="px-2 py-2 text-left">Photo</th> <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">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">Uploader</th>
<th className="px-2 py-2 text-left">Material</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">Coating</th>
@ -231,6 +257,7 @@ export default function FiberSettingsPage() {
"—" "—"
)} )}
</td> </td>
<td className="px-2 py-2 whitespace-nowrap"> <td className="px-2 py-2 whitespace-nowrap">
<Link <Link
href={detailHref(setting.submission_id)} href={detailHref(setting.submission_id)}
@ -240,25 +267,45 @@ export default function FiberSettingsPage() {
}} }}
/> />
</td> </td>
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }} dangerouslySetInnerHTML={{ __html: highlight(ownerName(setting)) }}
/> />
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.mat?.name || "—") }} dangerouslySetInnerHTML={{
__html: highlight(setting.uploader || "—"),
}}
/> />
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.mat_coat?.name || "—") }} dangerouslySetInnerHTML={{
__html: highlight(setting.mat?.name || "—"),
}}
/> />
<td <td
className="px-2 py-2 whitespace-nowrap" className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(setting.source?.model || "—") }} dangerouslySetInnerHTML={{
__html: highlight(setting.mat_coat?.name || "—"),
}}
/> />
<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(setting.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.lens?.field_size || "—"),
}}
/> />
</tr> </tr>
))} ))}

View file

@ -11,9 +11,12 @@ export default function FiberSettingsPage() {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery); const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState([]); const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// canonical detail href builder (no modal yet)
const detailHref = (id: string | number) => `/settings/fiber/${id}`;
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
@ -21,20 +24,20 @@ export default function FiberSettingsPage() {
useEffect(() => { useEffect(() => {
fetch( fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber?fields=submission_id,setting_title,uploader,photo.filename_disk,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1` `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber?fields=submission_id,setting_title,uploader,photo.id,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1`
) )
.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 highlight = (text) => { const highlight = (text?: string) => {
if (!debouncedQuery) return text; if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi"); const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, '<mark>$1</mark>'); return (text || "").replace(regex, "<mark>$1</mark>");
}; };
const filtered = useMemo(() => { const filtered = useMemo(() => {
@ -48,125 +51,150 @@ export default function FiberSettingsPage() {
entry.source?.model, entry.source?.model,
entry.lens?.field_size, entry.lens?.field_size,
]; ];
return fieldsToSearch.filter(Boolean).some((field) => return fieldsToSearch.filter(Boolean).some((field: string) =>
field.toLowerCase().includes(q) String(field).toLowerCase().includes(q)
); );
}); });
}, [settings, debouncedQuery]); }, [settings, debouncedQuery]);
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, cur) => { const commonLens = settings.reduce((acc: Record<string, number>, cur) => {
const lens = cur.lens?.field_size; const lens = cur.lens?.field_size;
if (!lens) return acc; if (!lens) return acc;
acc[lens] = (acc[lens] || 0) + 1; acc[lens] = (acc[lens] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const mostCommonLens = Object.entries(commonLens) const mostCommonLens =
.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, 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 = Object.entries(sourceModels) const mostCommonSource =
.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) => b.submission_id - 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"> <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"> <div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">Fiber Laser Settings</h1> <h1 className="text-2xl font-bold mb-2">Fiber Laser Settings</h1>
<input <input
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search settings by material, uploader, etc..." placeholder="Search settings by material, uploader, etc..."
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2" className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/> />
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
View and explore detailed fiber laser settings with context. View and explore detailed fiber laser settings with context.
</p> </p>
<a <a
href="/" href="/"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm" className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
> >
Back to Main Menu Back to Main Menu
</a> </a>
</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">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 fiber 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 real-world fiber 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.
</p> </p>
</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">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={`/fiber-settings/${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> by {s.uploader || "—"} </Link>{" "}
</li> by {s.uploader || "—"}
))} </li>
</ul> ))}
</div> </ul>
</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">Resources</h2>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
<li> <li>
<a href="/materials" target="_blank" rel="noopener noreferrer" className="underline text-accent">Material Safety Guide</a> <a
</li> href="/materials"
<li> target="_blank"
<a href="https://lasereverything.net/scripts/laspwrconvert.php" target="_blank" rel="noopener noreferrer" className="underline text-accent">Laser Parameter Calculator</a> rel="noopener noreferrer"
</li> className="underline text-accent"
<li> >
<a href="https://jptoe.com/downloads" target="_blank" rel="noopener noreferrer" className="underline text-accent">JPT Datasheets</a> Material Safety Guide
</li> </a>
</ul> </li>
</div> <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 className="card bg-card text-card-foreground p-4 flex flex-col justify-between">
<div> <div>
<h2 className="text-md font-semibold mb-2">Submit a Setting</h2> <h2 className="text-md font-semibold mb-2">Submit a Setting</h2>
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
Have a reliable fiber setting to share? Contribute to the community database. Have a reliable fiber setting to share? Contribute to the community database.
</p> </p>
</div> </div>
<button disabled className="bg-muted text-foreground text-sm px-4 py-2 rounded opacity-50 cursor-not-allowed"> <Link
Coming Soon href="/submit/settings?target=settings_fiber"
</button> className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
</div> >
Submit a Setting
</Link>
</div>
</div> </div>
{loading ? ( {loading ? (
@ -175,53 +203,69 @@ export default function FiberSettingsPage() {
<p className="text-muted">No fiber settings found.</p> <p className="text-muted">No fiber settings found.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr> <tr>
<th className="px-2 py-2 text-left">Photo</th> <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">Title</th>
<th className="px-2 py-2 text-left">Uploader</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">Material</th>
<th className="px-2 py-2 text-left">Coating</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">Source</th>
<th className="px-2 py-2 text-left">Lens</th> <th className="px-2 py-2 text-left">Lens</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((setting) => ( {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?.filename_disk ? ( {setting.photo?.id ? (
<Image <Image
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`} src={`https://forms.lasereverything.net/assets/${setting.photo.id}`}
alt={setting.photo.title || "laser preview"} alt={setting.photo.title || "laser preview"}
width={64} width={64}
height={64} height={64}
className="rounded-md" className="rounded-md"
/> />
) : ( ) : (
"—" "—"
)} )}
</td> </td>
<td className="px-2 py-2 whitespace-nowrap"> <td className="px-2 py-2 whitespace-nowrap">
<Link <Link
href={`/fiber-settings/${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 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>
<td className="px-2 py-2 whitespace-nowrap" dangerouslySetInnerHTML={{ __html: highlight(setting.mat_coat?.name || "—") }} /> <td
<td className="px-2 py-2 whitespace-nowrap" dangerouslySetInnerHTML={{ __html: highlight(setting.source?.model || "—") }} /> className="px-2 py-2 whitespace-nowrap"
<td className="px-2 py-2 whitespace-nowrap" dangerouslySetInnerHTML={{ __html: highlight(setting.lens?.field_size || "—") }} /> dangerouslySetInnerHTML={{ __html: highlight(setting.uploader || "—") }}
</tr> />
))} <td
</tbody> className="px-2 py-2 whitespace-nowrap"
</table> 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>
)} )}
</div> </div>
); );
} }

View file

@ -0,0 +1,53 @@
'use client';
import { useState } from 'react';
export default function ClaimButton({
collection,
id,
disabledReason,
}: {
collection: 'settings_fiber' | 'settings_uv' | 'settings_co2gal' | 'settings_co2gan' | 'projects' | string;
id: string | number;
disabledReason?: string;
}) {
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const submit = async () => {
setBusy(true);
setMsg(null);
try {
const res = await fetch('/api/claims', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_collection: collection, target_id: id }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) setMsg('✅ Claim submitted for review.');
else setMsg(data?.message || '❌ Could not submit claim.');
} catch {
setMsg('❌ Network/auth error. Please sign in and try again.');
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-col gap-1">
<button
onClick={submit}
disabled={busy || !!disabledReason}
className={`px-3 py-1 rounded-md text-sm border ${
disabledReason
? 'opacity-60 cursor-not-allowed'
: 'bg-accent text-background hover:opacity-90'
}`}
>
{busy ? 'Submitting…' : 'Claim Ownership'}
</button>
{disabledReason ? <span className="text-xs text-muted-foreground">{disabledReason}</span> : null}
{msg ? <span className="text-xs">{msg}</span> : null}
</div>
);
}

View file

@ -0,0 +1,25 @@
'use client';
export default function OwnerBadge({
owner,
uploader,
className = '',
}: {
owner?: { id?: string | number; display_name?: string } | null;
uploader?: string | null;
className?: string;
}) {
const hasOwner = !!owner?.id;
const label = hasOwner ? 'Owner' : 'Uploader';
const name = owner?.display_name ?? uploader ?? '—';
return (
<span
className={`inline-flex items-center gap-2 text-xs px-2 py-1 rounded-md border border-border bg-card ${className}`}
title={hasOwner ? 'Owner' : (uploader ? 'Original uploader' : '')}
>
<span className="opacity-70">{label}:</span>
<span className="font-medium">{name}</span>
</span>
);
}