modal redirect fixes for [id]s

This commit is contained in:
makearmy 2025-09-27 16:52:05 -04:00
parent f40f9a4092
commit 7b5f66e946
9 changed files with 1276 additions and 1023 deletions

View file

@ -3,50 +3,70 @@
import { useEffect, useState, useMemo } from 'react';
import Link from 'next/link';
type LaserRow = {
id: string | number;
submission_id?: string | number;
make?: string;
model?: string;
w?: string;
mj?: string;
nm?: string;
kHz?: string;
ns?: string;
v?: string;
op?: { name?: string } | string | null;
};
export default function LaserSourcesPage() {
const [sources, setSources] = useState([]);
const [sources, setSources] = useState<LaserRow[]>([]);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [wavelengthFilters, setWavelengthFilters] = useState<Record<string, number | null>>({});
const [sortKey, setSortKey] = useState('model');
const [sortKey, setSortKey] = useState<keyof LaserRow | 'op' | 'model'>('model');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// canonical href builder (prefers submission_id if present)
const detailHref = (row: LaserRow) => `/lasers/${row.submission_id ?? row.id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/laser_source?limit=-1&fields=*,op.label`)
.then((res) => res.json())
.then((data) => setSources(data.data || []));
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/laser_source?limit=-1&fields=*,op.name`
)
.then((res) => res.json())
.then((data) => setSources(data.data || []));
}, []);
const highlightMatch = (text: string, query: string) => {
if (!query || !text) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
const highlightMatch = (text?: string, q?: string) => {
const safeText = String(text ?? '');
const query = String(q ?? '');
if (!query) return safeText;
const parts = safeText.split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : part
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return sources.filter((src) => {
const matchesQuery = [src.make, src.model].filter(Boolean).some((field) =>
field.toLowerCase().includes(q)
);
const matchesQuery = [src.make, src.model]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(q));
return matchesQuery;
});
}, [sources, debouncedQuery]);
const grouped = useMemo<Record<string, typeof filtered>>(() => {
const grouped = useMemo<Record<string, LaserRow[]>>(() => {
return filtered.reduce((acc, src) => {
const key = src.make || 'Unknown Make';
acc[key] = acc[key] || [];
acc[key].push(src);
(acc[key] = acc[key] || []).push(src);
return acc;
}, {} as Record<string, typeof filtered>);
}, {} as Record<string, LaserRow[]>);
}, [filtered]);
const wavelengths = [10600, 1064, 455, 355];
@ -54,189 +74,207 @@ export default function LaserSourcesPage() {
const toggleFilter = (make: string, value: number) => {
setWavelengthFilters((prev) => ({
...prev,
[make]: prev[make] === value ? null : value
[make]: prev[make] === value ? null : value,
}));
};
const toggleSort = (key: string) => {
const toggleSort = (key: keyof LaserRow | 'op' | 'model') => {
setSortKey(key);
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
};
const getSortableValue = (val: any, key: string) => {
if (!val) return '';
if (key === 'w') return parseFloat(val.replace(/[^\d.]/g, '')) || 0;
if (['mj', 'nm', 'khz', 'ns', 'v'].includes(key.toLowerCase())) return parseFloat(val) || 0;
return val.toString().toLowerCase();
const getSortableValue = (row: LaserRow, key: keyof LaserRow | 'op' | 'model') => {
const val =
key === 'op'
? (typeof row.op === 'object' && row.op ? row.op.name : row.op)
: (row as any)[key];
if (val == null) return '';
const k = String(key).toLowerCase();
if (k === 'w') return parseFloat(String(val).replace(/[^\d.]/g, '')) || 0;
if (['mj', 'nm', 'khz', 'ns', 'v'].includes(k)) return parseFloat(String(val)) || 0;
return String(val).toLowerCase();
};
const sortArrow = (key: string) =>
sortKey === key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : '';
const sortArrow = (key: keyof LaserRow | 'op' | 'model') =>
sortKey === key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : '';
const summaryStats = useMemo(() => {
const makes = new Set();
const makes = new Set<string>();
const nmCounts: Record<string, number> = {};
for (const src of sources) {
if (src.make) makes.add(src.make);
if (src.nm) {
const nm = src.nm;
const nm = String(src.nm);
nmCounts[nm] = (nmCounts[nm] || 0) + 1;
}
}
const mostCommonNm = Object.entries(nmCounts)
.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || '—';
return {
total: sources.length,
uniqueMakes: makes.size,
commonNm: mostCommonNm,
};
.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || '—';
return {
total: sources.length,
uniqueMakes: makes.size,
commonNm: mostCommonNm,
};
}, [sources]);
const recentSources = useMemo(() => {
return [...sources]
.filter((src) => src.submission_id)
.sort((a, b) => b.submission_id - a.submission_id)
.slice(0, 5);
.filter((src) => src.submission_id != null)
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5);
}, [sources]);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="grid gap-4 md:grid-cols-3 mb-6">
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Database Summary</h2>
<p>Total Sources: {summaryStats.total}</p>
<p>Unique Makes: {summaryStats.uniqueMakes}</p>
<p>Most Common Wavelength: {summaryStats.commonNm}</p>
</div>
<div className="grid gap-4 md:grid-cols-3 mb-6">
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Database Summary</h2>
<p>Total Sources: {summaryStats.total}</p>
<p>Unique Makes: {summaryStats.uniqueMakes}</p>
<p>Most Common Wavelength: {summaryStats.commonNm}</p>
</div>
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Recent Additions</h2>
<ul className="text-sm list-disc pl-4">
{recentSources.map((src) => (
<li key={src.id}>
<Link className="text-accent underline" href={`/lasers/${src.submission_id}`}>
{src.make} {src.model}
</Link>
</li>
))}
</ul>
</div>
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Recent Additions</h2>
<ul className="text-sm list-disc pl-4">
{recentSources.map((src) => (
<li key={src.id}>
<Link className="text-accent underline" href={detailHref(src)}>
{src.make} {src.model}
</Link>
</li>
))}
</ul>
</div>
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Feedback</h2>
<p className="text-sm mb-2">See something wrong or want to suggest an improvement?</p>
<Link href="#" className="btn-primary inline-block">Submit Feedback</Link>
</div>
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Feedback</h2>
<p className="text-sm mb-2">See something wrong or want to suggest an improvement?</p>
<Link href="#" className="btn-primary inline-block">Submit Feedback</Link>
</div>
</div>
<div className="mb-6 card bg-card text-card-foreground">
<h1 className="text-3xl font-bold mb-2">Laser Source Database</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by make or model..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
Browse laser source specifications collected from community-submitted and verified sources.
</p>
</div>
{Object.entries(grouped).length === 0 ? (
<p className="text-muted">No laser sources found.</p>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([make, items]) => {
const filteredItems =
wavelengthFilters[make] != null
? items.filter((item) => Number(item.nm) === wavelengthFilters[make])
: items;
const sortedItems = [...filteredItems].sort((a, b) => {
const aVal = getSortableValue(a, sortKey);
const bVal = getSortableValue(b, sortKey);
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return (
<details key={make} className="border border-border rounded-md">
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer flex justify-between items-center">
<span>
{make} <span className="text-sm text-muted">({filteredItems.length})</span>
</span>
<div className="space-x-2">
{[10600, 1064, 455, 355].map((w) => (
<button
key={w}
onClick={(e) => {
e.stopPropagation();
toggleFilter(make, w);
}}
className={`px-2 py-1 text-xs rounded-md border ${
wavelengthFilters[make] === w
? 'bg-accent text-white'
: 'bg-muted text-muted-foreground'
}`}
>
{w}
</button>
))}
</div>
</summary>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-sm whitespace-nowrap">
<thead>
<tr>
<th className="px-2 py-2 text-left">Make</th>
<th className="px-2 py-2 text-left w-64">
<button onClick={() => toggleSort('model')}>Model{sortArrow('model')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('w')}>W{sortArrow('w')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('mj')}>mJ{sortArrow('mj')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('op')}>OP{sortArrow('op')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('nm')}>nm{sortArrow('nm')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('kHz')}>kHz{sortArrow('kHz')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('ns')}>ns{sortArrow('ns')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('v')}>V{sortArrow('v')}</button>
</th>
</tr>
</thead>
<tbody>
{sortedItems.map((src) => (
<tr key={src.id} className="border-t border-border">
<td className="px-2 py-2 truncate max-w-[10rem]">
{highlightMatch(src.make || '—', debouncedQuery)}
</td>
<td className="px-2 py-2 truncate max-w-[16rem]">
<Link href={detailHref(src)} className="text-accent underline">
{highlightMatch(src.model || '—', debouncedQuery)}
</Link>
</td>
<td className="px-2 py-2 whitespace-nowrap">{src.w || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.mj || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">
{typeof src.op === 'object' && src.op ? src.op.name || '—' : src.op || '—'}
</td>
<td className="px-2 py-2 whitespace-nowrap">{src.nm || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.kHz || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.ns || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.v || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
);
})}
</div>
<div className="mb-6 card bg-card text-card-foreground">
<h1 className="text-3xl font-bold mb-2">Laser Source Database</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by make or model..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
Browse laser source specifications collected from community-submitted and verified sources.
</p>
</div>
{Object.entries(grouped).length === 0 ? (
<p className="text-muted">No laser sources found.</p>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([make, items]) => {
const filteredItems = wavelengthFilters[make] != null
? items.filter(item => parseInt(item.nm) === wavelengthFilters[make])
: items;
const sortedItems = [...filteredItems].sort((a, b) => {
const aVal = getSortableValue(a[sortKey], sortKey);
const bVal = getSortableValue(b[sortKey], sortKey);
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return (
<details key={make} className="border border-border rounded-md">
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer flex justify-between items-center">
<span>{make} <span className="text-sm text-muted">({filteredItems.length})</span></span>
<div className="space-x-2">
{wavelengths.map((w) => (
<button
key={w}
onClick={(e) => {
e.stopPropagation();
toggleFilter(make, w);
}}
className={`px-2 py-1 text-xs rounded-md border ${wavelengthFilters[make] === w ? 'bg-accent text-white' : 'bg-muted text-muted-foreground'}`}
>
{w}
</button>
))}
</div>
</summary>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-sm whitespace-nowrap">
<thead>
<tr>
<th className="px-2 py-2 text-left">Make</th>
<th className="px-2 py-2 text-left w-64">
<button onClick={() => toggleSort('model')}>Model{sortArrow('model')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('w')}>W{sortArrow('w')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('mj')}>mJ{sortArrow('mj')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('op')}>OP{sortArrow('op')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('nm')}>nm{sortArrow('nm')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('kHz')}>kHz{sortArrow('kHz')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('ns')}>ns{sortArrow('ns')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('v')}>V{sortArrow('v')}</button>
</th>
</tr>
</thead>
<tbody>
{sortedItems.map((src) => (
<tr key={src.id} className="border-t border-border">
<td className="px-2 py-2 truncate max-w-[10rem]">{highlightMatch(src.make || '—', debouncedQuery)}</td>
<td className="px-2 py-2 truncate max-w-[16rem]">
<Link href={`/lasers/${src.submission_id}`} className="text-accent underline">
{highlightMatch(src.model || '—', debouncedQuery)}
</Link>
</td>
<td className="px-2 py-2 whitespace-nowrap">{src.w || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.mj || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.op?.label || src.op || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.nm || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.kHz || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.ns || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.v || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
);
})}
</div>
)}
)}
</div>
);
}

View file

@ -3,19 +3,24 @@
import Link from 'next/link';
import { useEffect, useState, useMemo } from 'react';
function highlightMatch(text, query) {
if (!query) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
function highlightMatch(text?: string, query?: string) {
const safeText = String(text ?? '');
const q = String(query ?? '');
if (!q) return safeText;
const parts = safeText.split(new RegExp(`(${q})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : part
part.toLowerCase() === q.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
}
export default function CoatingsPage() {
const [coatings, setCoatings] = useState([]);
const [coatings, setCoatings] = useState<any[]>([]);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// canonical detail href (no modal yet)
const detailHref = (id: string | number) => `/materials/materials-coatings/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
@ -25,103 +30,117 @@ export default function CoatingsPage() {
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material_coating?fields=id,name,abbreviation,technical_name,composition,coating_status.name&limit=-1`
)
.then((res) => res.json())
.then((data) => setCoatings(data.data || []));
.then((res) => res.json())
.then((data) => setCoatings(data.data || []));
}, []);
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return coatings.filter((coat) =>
[
coat.name,
coat.technical_name,
coat.abbreviation,
coat.composition,
coat.coating_status?.name
]
.filter(Boolean)
.some((field) => field.toLowerCase().includes(q))
[
coat.name,
coat.technical_name,
coat.abbreviation,
coat.composition,
coat.coating_status?.name,
]
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q))
);
}, [coatings, debouncedQuery]);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex flex-col lg:flex-row gap-4">
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
<h1 className="text-3xl font-bold mb-2">Laser Material Coatings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search coatings..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
<p className="mb-6">
The following coatings are provided for educational purposes only and are not intended to be used as your sole or primary source of information when assessing the safety of any particular coating. It is your responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is, free of charge under the assumption you are exercising all other relevant safety precautions.
</p>
<div className="absolute bottom-4 left-4">
<Link
href="/"
className="btn-primary"
>
Back to Main Menu
</Link>
</div>
</div>
<div className="mb-6 flex flex-col lg:flex-row gap-4">
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
<h1 className="text-3xl font-bold mb-2">Laser Material Coatings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search coatings..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
<p className="mb-6">
The following coatings are provided for educational purposes only and are not intended to be used as your
sole or primary source of information when assessing the safety of any particular coating. It is your
responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is,
free of charge under the assumption you are exercising all other relevant safety precautions.
</p>
<div className="absolute bottom-4 left-4">
<Link href="/" className="btn-primary">
Back to Main Menu
</Link>
</div>
</div>
<div className="flex-1 card bg-[#422c17] text-white">
<h2 className="font-bold text-base mb-2"> Safety Level Definitions</h2>
<ul className="space-y-2">
<li><strong>Safe</strong> Materials marked as safe are widely considered to be generally safe by the laser community at large. This does not mean normal safety protocols should not be observed.</li>
<li><strong>Level I Caution</strong> | These materials are typically safe when normal safety protocol observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper exhaust and filtration.</li>
<li><strong>Level II Dangerous</strong> | These materials can be harmful even if normal safety protocol observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and mindfulness.</li>
<li><strong>Level III Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or death. Materials marked Critical Hazard should not be processed by lasers in any environment for any reason.</li>
</ul>
</div>
<div className="flex-1 card bg-[#422c17] text-white">
<h2 className="font-bold text-base mb-2"> Safety Level Definitions</h2>
<ul className="space-y-2">
<li>
<strong>Safe</strong> Materials marked as safe are widely considered to be generally safe by the laser
community at large. This does not mean normal safety protocols should not be observed.
</li>
<li>
<strong>Level I Caution</strong> | These materials are typically safe when normal safety protocol
observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper
exhaust and filtration.
</li>
<li>
<strong>Level II Dangerous</strong> | These materials can be harmful even if normal safety protocol
observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and
mindfulness.
</li>
<li>
<strong>Level III Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or
death. Materials marked Critical Hazard should not be processed by lasers in any environment for any
reason.
</li>
</ul>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-muted">No coatings found.</p>
) : (
<div className="overflow-x-auto">
<table className="table-fixed min-w-full border border-border text-sm">
<thead>
<tr>
<th className="px-4 py-2 text-left w-48">Name</th>
<th className="px-4 py-2 text-left w-32 whitespace-nowrap">Status</th>
<th className="px-4 py-2 text-left w-32">Abbreviation</th>
<th className="px-4 py-2 text-left w-64">Technical Name</th>
<th className="px-4 py-2 text-left w-64">Composition</th>
</tr>
</thead>
<tbody>
{filtered.map((coat) => (
<tr key={coat.id} className="border-t border-border align-top">
<td className="px-4 py-2 truncate max-w-[12rem]">
<Link href={detailHref(coat.id)} className="text-accent underline">
{highlightMatch(coat.name, debouncedQuery)}
</Link>
</td>
<td className="px-4 py-2 whitespace-nowrap">
{highlightMatch(coat.coating_status?.name || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[8rem]">
{highlightMatch(coat.abbreviation || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[16rem]">
{highlightMatch(coat.technical_name || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[16rem]">
{highlightMatch(coat.composition || '—', debouncedQuery)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length === 0 ? (
<p className="text-muted">No coatings found.</p>
) : (
<div className="overflow-x-auto">
<table className="table-fixed min-w-full border border-border text-sm">
<thead>
<tr>
<th className="px-4 py-2 text-left w-48">Name</th>
<th className="px-4 py-2 text-left w-32 whitespace-nowrap">Status</th>
<th className="px-4 py-2 text-left w-32">Abbreviation</th>
<th className="px-4 py-2 text-left w-64">Technical Name</th>
<th className="px-4 py-2 text-left w-64">Composition</th>
</tr>
</thead>
<tbody>
{filtered.map((coat) => (
<tr key={coat.id} className="border-t border-border align-top">
<td className="px-4 py-2 truncate max-w-[12rem]">
<Link href={`/materials-coatings/${coat.id}`} className="text-accent underline">
{highlightMatch(coat.name, debouncedQuery)}
</Link>
</td>
<td className="px-4 py-2 whitespace-nowrap">
{highlightMatch(coat.coating_status?.name || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[8rem]">
{highlightMatch(coat.abbreviation || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[16rem]">
{highlightMatch(coat.technical_name || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[16rem]">
{highlightMatch(coat.composition || '—', debouncedQuery)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
)}
</div>
);
}

View file

@ -5,9 +5,9 @@ import { useEffect, useState, useMemo } from 'react';
function highlightMatch(text: string, query: string) {
if (!query) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
const parts = String(text).split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : part
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
}
@ -16,6 +16,9 @@ export default function MaterialsPage() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// canonical detail href builder (no modal yet)
const detailHref = (id: string | number) => `/materials/materials/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
@ -25,121 +28,134 @@ export default function MaterialsPage() {
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material?fields=id,name,abbreviation,common_names,technical_name,material_cat.name,material_status.name&limit=-1`
)
.then((res) => res.json())
.then((data) => setMaterials(data.data || []));
.then((res) => res.json())
.then((data) => setMaterials(data.data || []));
}, []);
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return materials.filter((mat) =>
[
mat.name,
mat.technical_name,
mat.common_names,
mat.abbreviation,
mat.material_status?.name
]
.filter(Boolean)
.some((field) => field.toLowerCase().includes(q))
[
mat.name,
mat.technical_name,
mat.common_names,
mat.abbreviation,
mat.material_status?.name,
]
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q))
);
}, [materials, debouncedQuery]);
const grouped = useMemo<Record<string, typeof filtered>>(() => {
return filtered.reduce((acc, mat) => {
const key = mat.material_cat?.name || 'Uncategorized';
acc[key] = acc[key] || [];
acc[key].push(mat);
(acc[key] = acc[key] || []).push(mat);
return acc;
}, {} as Record<string, typeof filtered>);
}, [filtered]);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex flex-col lg:flex-row gap-4">
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
<h1 className="text-3xl font-bold mb-2">Laser Material Reference</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search materials..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
<p className="mb-6">
The following materials are provided for educational purposes only and are not intended to be used as your sole or primary source of information when assessing the safety of any particular material. It is your responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is, free of charge under the assumption you are exercising all other relevant safety precautions.
</p>
<div className="absolute bottom-4 left-4">
<Link
href="/"
className="btn-primary"
>
Back to Main Menu
</Link>
</div>
</div>
<div className="mb-6 flex flex-col lg:flex-row gap-4">
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
<h1 className="text-3xl font-bold mb-2">Laser Material Reference</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search materials..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
<p className="mb-6">
The following materials are provided for educational purposes only and are not intended to be used as your
sole or primary source of information when assessing the safety of any particular material. It is your
responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is,
free of charge under the assumption you are exercising all other relevant safety precautions.
</p>
<div className="absolute bottom-4 left-4">
<Link href="/" className="btn-primary">
Back to Main Menu
</Link>
</div>
</div>
<div className="flex-1 card bg-[#422c17] text-white">
<h2 className="font-bold text-base mb-2"> Safety Level Definitions</h2>
<ul className="space-y-2">
<li><strong>Safe</strong> Materials marked as safe are widely considered to be generally safe by the laser community at large. This does not mean normal safety protocols should not be observed.</li>
<li><strong>Level I Caution</strong> | These materials are typically safe when normal safety protocol observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper exhaust and filtration.</li>
<li><strong>Level II Dangerous</strong> | These materials can be harmful even if normal safety protocol observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and mindfulness.</li>
<li><strong>Level III Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or death. Materials marked Critical Hazard should not be processed by lasers in any environment for any reason.</li>
</ul>
<div className="flex-1 card bg-[#422c17] text-white">
<h2 className="font-bold text-base mb-2"> Safety Level Definitions</h2>
<ul className="space-y-2">
<li>
<strong>Safe</strong> Materials marked as safe are widely considered to be generally safe by the laser
community at large. This does not mean normal safety protocols should not be observed.
</li>
<li>
<strong>Level I Caution</strong> | These materials are typically safe when normal safety protocol
observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper
exhaust and filtration.
</li>
<li>
<strong>Level II Dangerous</strong> | These materials can be harmful even if normal safety protocol
observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and
mindfulness.
</li>
<li>
<strong>Level III Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or
death. Materials marked Critical Hazard should not be processed by lasers in any environment for any
reason.
</li>
</ul>
</div>
</div>
{Object.entries(grouped).length === 0 ? (
<p className="text-muted">No materials found.</p>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([category, items]) => (
<details key={category} className="border border-border rounded-md">
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer">
{category} <span className="text-sm text-muted">({items.length})</span>
</summary>
<div className="overflow-x-auto">
<table className="table-fixed">
<thead>
<tr>
<th className="w-48">Name</th>
<th className="w-32 whitespace-nowrap">Status</th>
<th className="w-32">Abbreviation</th>
<th className="w-64">Common Names</th>
<th className="w-64">Technical Name</th>
</tr>
</thead>
<tbody>
{items.map((material) => (
<tr key={material.id} className="border-t border-border align-top">
<td className="truncate max-w-[12rem]">
<Link href={detailHref(material.id)} className="text-accent underline">
{highlightMatch(material.name, debouncedQuery)}
</Link>
</td>
<td className="whitespace-nowrap">
{highlightMatch(material.material_status?.name || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[8rem]">
{highlightMatch(material.abbreviation || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[16rem]">
{highlightMatch(material.common_names || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[16rem]">
{highlightMatch(material.technical_name || '—', debouncedQuery)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
))}
</div>
{Object.entries(grouped).length === 0 ? (
<p className="text-muted">No materials found.</p>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([category, items]) => (
<details key={category} className="border border-border rounded-md">
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer">
{category} <span className="text-sm text-muted">({items.length})</span>
</summary>
<div className="overflow-x-auto">
<table className="table-fixed">
<thead>
<tr>
<th className="w-48">Name</th>
<th className="w-32 whitespace-nowrap">Status</th>
<th className="w-32">Abbreviation</th>
<th className="w-64">Common Names</th>
<th className="w-64">Technical Name</th>
</tr>
</thead>
<tbody>
{items.map((material) => (
<tr key={material.id} className="border-t border-border align-top">
<td className="truncate max-w-[12rem]">
<Link href={`/materials/${material.id}`} className="text-accent underline">
{highlightMatch(material.name, debouncedQuery)}
</Link>
</td>
<td className="whitespace-nowrap">
{highlightMatch(material.material_status?.name || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[8rem]">
{highlightMatch(material.abbreviation || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[16rem]">
{highlightMatch(material.common_names || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[16rem]">
{highlightMatch(material.technical_name || '—', debouncedQuery)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
))}
</div>
)}
)}
</div>
);
}

View file

@ -5,13 +5,23 @@ import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
type ProjectRow = {
id: string | number;
submission_id?: string | number;
title?: string;
uploader?: string;
category?: string;
tags?: string[];
p_image?: { filename_disk?: string; title?: string };
};
export default function ProjectsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [projects, setProjects] = useState<any[]>([]);
const [projects, setProjects] = useState<ProjectRow[]>([]);
const [loading, setLoading] = useState(true);
const [categories] = useState([
"assets",
@ -20,7 +30,7 @@ export default function ProjectsPage() {
"projects",
"templates",
"test files",
"tools"
"tools",
]);
useEffect(() => {
@ -30,13 +40,16 @@ export default function ProjectsPage() {
useEffect(() => {
const url = new URL(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/projects`);
url.searchParams.set("fields", "submission_id,title,uploader,category,tags,p_image.filename_disk,p_image.title");
url.searchParams.set(
"fields",
"submission_id,id,title,uploader,category,tags,p_image.filename_disk,p_image.title"
);
url.searchParams.set("limit", "-1");
fetch(url.toString(), { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
setProjects(data.data || []);
setProjects((data?.data as ProjectRow[]) || []);
setLoading(false);
})
.catch(() => setLoading(false));
@ -45,10 +58,10 @@ export default function ProjectsPage() {
const highlight = (text: string) => {
if (!debouncedQuery) return text;
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, '<mark>$1</mark>');
return text?.replace(regex, "<mark>$1</mark>");
};
const normalize = (str: string) => str?.toLowerCase().replace(/[_\s]/g, "");
const normalize = (str: unknown) => String(str ?? "").toLowerCase().replace(/[_\s]/g, "");
const filtered = useMemo(() => {
const q = normalize(debouncedQuery);
@ -59,9 +72,7 @@ export default function ProjectsPage() {
entry.category ?? "",
Array.isArray(entry.tags) ? entry.tags.join(" ") : "",
];
return fieldsToSearch.filter(Boolean).some((field) =>
normalize(field).includes(q)
);
return fieldsToSearch.filter(Boolean).some((field) => normalize(field).includes(q));
});
}, [projects, debouncedQuery]);
@ -83,7 +94,10 @@ export default function ProjectsPage() {
.map(([tag]) => tag);
const recentTags = [...projects]
.sort((a, b) => b.submission_id - a.submission_id)
.sort(
(a, b) =>
Number(b.submission_id ?? b.id ?? 0) - Number(a.submission_id ?? a.id ?? 0)
)
.slice(0, 10)
.flatMap((p) => p.tags || [])
.filter((tag, i, self) => self.indexOf(tag) === i)
@ -92,6 +106,12 @@ export default function ProjectsPage() {
const uniqueUploaders = new Set(projects.map((p) => p.uploader).filter(Boolean)).size;
const totalTags = Object.keys(tagCounts).length;
const detailHref = (p: ProjectRow) => `/projects/${p.submission_id ?? p.id}`;
const imageSrc = (p: ProjectRow) =>
p.p_image?.filename_disk
? `https://forms.lasereverything.net/assets/${p.p_image.filename_disk}`
: null;
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
@ -119,6 +139,7 @@ export default function ProjectsPage() {
width: 150px;
height: 150px;
object-fit: cover;
background: #111;
}
.project-content {
padding: 0.75rem;
@ -169,6 +190,7 @@ export default function ProjectsPage() {
Back to Main Menu
</Link>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Popular Tags</h2>
{popularTags.length > 0 ? (
@ -183,6 +205,7 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">No tags yet.</p>
)}
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Recent Tags</h2>
{recentTags.length > 0 ? (
@ -197,12 +220,14 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">No recent tags.</p>
)}
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Project Stats</h2>
<p className="text-sm text-muted-foreground">Total Projects: {projects.length}</p>
<p className="text-sm text-muted-foreground">Unique Uploaders: {uniqueUploaders}</p>
<p className="text-sm text-muted-foreground">Total Tags: {totalTags}</p>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Browse by Category</h2>
<div className="flex flex-wrap">
@ -213,6 +238,7 @@ export default function ProjectsPage() {
))}
</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 Project</h2>
@ -220,7 +246,10 @@ export default function ProjectsPage() {
Have a cool design, tool, or jig to share? Submit it 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">
<button
disabled
className="bg-muted text-foreground text-sm px-4 py-2 rounded opacity-50 cursor-not-allowed"
>
Coming Soon
</button>
</div>
@ -234,39 +263,65 @@ export default function ProjectsPage() {
<p className="text-muted">No projects found.</p>
) : (
<div className="card-grid">
{filtered.map((project) => (
<div key={project.submission_id} className="project-card">
<Image
src={`https://forms.lasereverything.net/assets/${project.p_image?.filename_disk}`}
alt={project.p_image?.title || "Project image"}
width={150}
height={150}
className="project-image"
/>
<div className="project-content">
<div>
<Link href={`/projects/${project.submission_id}`} className="text-base font-semibold text-accent underline">
{project.title || "Untitled"}
</Link>
<p className="text-xs text-muted-foreground">Uploaded by: {project.uploader || "—"}</p>
<p className="text-xs text-muted-foreground">Category: {project.category || "—"}</p>
</div>
<div className="project-tags">
{Array.isArray(project.tags) && project.tags.length > 0
? project.tags.map((tag, i) => (
<span
key={i}
onClick={() => setQuery(tag)}
title={tag}
>
{tag}
</span>
))
: ""}
{filtered.map((project) => {
const key = String(project.submission_id ?? project.id);
const href = detailHref(project);
const src = imageSrc(project);
return (
<div key={key} className="project-card">
{src ? (
<Image
src={src}
alt={project.p_image?.title || "Project image"}
width={150}
height={150}
className="project-image"
/>
) : (
<div className="project-image flex items-center justify-center text-xs text-muted-foreground">
No image
</div>
)}
<div className="project-content">
<div>
<Link href={href} className="text-base font-semibold text-accent underline">
{/* highlight title if searching */}
<span
dangerouslySetInnerHTML={{
__html: highlight(project.title || "Untitled"),
}}
/>
</Link>
<p className="text-xs text-muted-foreground">
Uploaded by:{" "}
<span
dangerouslySetInnerHTML={{
__html: highlight(project.uploader || "—"),
}}
/>
</p>
<p className="text-xs text-muted-foreground">
Category:{" "}
<span
dangerouslySetInnerHTML={{
__html: highlight(project.category || "—"),
}}
/>
</p>
</div>
</div>
</div>
))}
<div className="project-tags">
{Array.isArray(project.tags) && project.tags.length > 0
? project.tags.map((tag, i) => (
<span key={i} onClick={() => setQuery(tag)} title={tag}>
{tag}
</span>
))
: ""}
</div>
</div>
</div>
);
})}
</div>
)}
</div>

View file

@ -11,9 +11,12 @@ export default function CO2GalvoSettingsPage() {
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState([]);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// canonical detail href builder
const detailHref = (id: string | number) => `/settings/co2-galvo/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
@ -23,18 +26,18 @@ export default function CO2GalvoSettingsPage() {
fetch(
`${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`
)
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const highlight = (text) => {
if (!debouncedQuery) return text;
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, "<mark>$1</mark>");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
@ -48,129 +51,129 @@ export default function CO2GalvoSettingsPage() {
entry.source?.model,
entry.lens?.field_size,
];
return fieldsToSearch.filter(Boolean).some((field) =>
String(field).toLowerCase().includes(q)
);
return fieldsToSearch
.filter(Boolean)
.some((field: string) => 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 commonLens = settings.reduce((acc: Record<string, number>, 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 mostCommonLens =
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;
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 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);
.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 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">
<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">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">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">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>{" "}
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">
<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>
<Link
href="/submit/settings?target=settings_co2gal"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</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>
<Link
href="/submit/settings?target=settings_co2gal"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</div>
</div>
{loading ? (
@ -179,53 +182,67 @@ export default function CO2GalvoSettingsPage() {
<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?.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={`/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>
<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?.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?.field_size || "—") }}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -11,9 +11,12 @@ export default function CO2GantrySettingsPage() {
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState([]);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// canonical detail href builder
const detailHref = (id: string | number) => `/settings/co2-gantry/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
@ -23,18 +26,18 @@ export default function CO2GantrySettingsPage() {
fetch(
`${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`
)
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const highlight = (text) => {
if (!debouncedQuery) return text;
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, '<mark>$1</mark>');
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
@ -48,128 +51,135 @@ export default function CO2GantrySettingsPage() {
entry.source?.model,
entry.lens?.name,
];
return fieldsToSearch.filter(Boolean).some((field) =>
field.toLowerCase().includes(q)
);
return fieldsToSearch
.filter(Boolean)
.some((field: string) => 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 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?.name;
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 mostCommonLens =
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;
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 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);
.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 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 Gantry 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">
Explore curated CO gantry settings. Search by material, uploader, or source.
</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">
<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 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">
Explore curated CO gantry settings. Search by material, uploader, or source.
</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 gantry settings. Search or filter results, and click any setting for full configuration and notes.
</p>
</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 gantry settings. Search or filter results, and click any setting for full configuration and notes.
</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">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={`/co2gantry-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">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>{" "}
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">
<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">
Got a dialed-in gantry setting? Contribute it to the database.
</p>
</div>
<Link
href="/submit/settings?target=settings_co2gan"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</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">
Got a dialed-in gantry setting? Contribute it to the database.
</p>
</div>
<Link
href="/submit/settings?target=settings_co2gan"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</div>
</div>
{loading ? (
@ -178,53 +188,67 @@ export default function CO2GantrySettingsPage() {
<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">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?.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={`/co2gantry-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?.name || "—") }} />
</tr>
))}
</tbody>
</table>
<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?.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>
);
}

View file

@ -11,9 +11,12 @@ export default function FiberSettingsPage() {
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState([]);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// canonical detail href builder (no modal yet)
const detailHref = (id: string | number) => `/settings/fiber/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
@ -23,18 +26,18 @@ export default function FiberSettingsPage() {
fetch(
`${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((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const highlight = (text) => {
if (!debouncedQuery) return text;
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, '<mark>$1</mark>');
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
@ -48,128 +51,150 @@ export default function FiberSettingsPage() {
entry.source?.model,
entry.lens?.field_size,
];
return fieldsToSearch.filter(Boolean).some((field) =>
field.toLowerCase().includes(q)
return fieldsToSearch.filter(Boolean).some((field: string) =>
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 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;
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 mostCommonLens =
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;
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 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);
.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 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">Fiber Laser 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 fiber laser 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">
<h1 className="text-2xl font-bold mb-2">Fiber Laser 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 fiber laser 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 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>
</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 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>
</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">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={`/fiber-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">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>{" "}
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">
<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 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 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>
{loading ? (
@ -178,53 +203,69 @@ export default function FiberSettingsPage() {
<p className="text-muted">No fiber 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?.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={`/fiber-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>
<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?.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?.field_size || "—") }}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -14,6 +14,9 @@ export default function UVSettingsPage() {
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// canonical detail href builder
const detailHref = (id: string | number) => `/settings/uv/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
@ -23,18 +26,18 @@ export default function UVSettingsPage() {
fetch(
`${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`
)
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
.then((res) => res.json())
.then((data) => {
setSettings(data.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const highlight = (text: string) => {
if (!debouncedQuery) return text;
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, '<mark>$1</mark>');
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
@ -48,22 +51,23 @@ export default function UVSettingsPage() {
entry.source?.model,
entry.lens?.field_size,
];
return fieldsToSearch.filter(Boolean).some((field) =>
field.toLowerCase().includes(q)
);
return fieldsToSearch
.filter(Boolean)
.some((field: string) => 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 uniqueMaterials = new Set(settings.map((s) => s.mat?.name).filter(Boolean)).size;
const commonLens = settings.reduce((acc: Record<string, number>, 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 mostCommonLens =
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 model = cur.source?.model;
@ -71,104 +75,116 @@ export default function UVSettingsPage() {
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 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);
.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 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">UV Laser 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 UV laser 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">
<h1 className="text-2xl font-bold mb-2">UV Laser 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 UV laser 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 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.
</p>
</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 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.
</p>
</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">
<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 UV setting to share? Contribute to the community database.
</p>
</div>
<Link
href="/submit/settings?target=settings_uv"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</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 UV setting to share? Contribute to the community database.
</p>
</div>
<Link
href="/submit/settings?target=settings_uv"
className="bg-accent text-background text-sm px-4 py-2 rounded hover:opacity-90 transition"
>
Submit a Setting
</Link>
</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">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={`/uv-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">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>{" "}
by {s.uploader || "—"}
</li>
))}
</ul>
</div>
</div>
{loading ? (
@ -177,53 +193,67 @@ export default function UVSettingsPage() {
<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">
<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={`/uv-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>
<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?.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?.field_size || "—") }}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -10,7 +10,7 @@ export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const { pathname } = url;
// ── 1) Legacy → Portal mapping (runs before auth gating)
// ── 1) Legacy → Portal / Canonical mapping (runs before auth gating)
const mapped = legacyMap(pathname);
if (mapped) {
url.pathname = mapped.pathname;
@ -47,38 +47,51 @@ export function middleware(req: NextRequest) {
type MapResult = { pathname: string; query?: Record<string, string> };
function legacyMap(pathname: string): MapResult | null {
switch (pathname) {
// Laser settings (old links)
case "/fiber-settings":
return { pathname: "/portal/laser-settings", query: { t: "fiber" } };
case "/uv-settings":
return { pathname: "/portal/laser-settings", query: { t: "uv" } };
case "/co2-galvo-settings":
return { pathname: "/portal/laser-settings", query: { t: "co2-galvo" } };
case "/co2-gantry-settings":
return { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } };
// Materials (both legacy structures)
case "/materials":
return { pathname: "/portal/materials", query: { t: "materials" } };
case "/materials/materials":
return { pathname: "/portal/materials", query: { t: "materials" } };
case "/materials/materials-coatings":
return { pathname: "/portal/materials", query: { t: "materials-coatings" } };
case "/materials-coatings":
return { pathname: "/portal/materials", query: { t: "materials-coatings" } };
// Lasers / Projects / Rigs (legacy)
case "/lasers":
return { pathname: "/portal/laser-sources" };
case "/projects":
return { pathname: "/portal/projects" };
case "/my/rigs":
return { pathname: "/portal/rigs", query: { t: "my" } };
default:
return null;
// 1) DETAIL PAGES: legacy [id] → existing canonical [id] pages
// (keeps working now; we can later switch these to open inside /portal once wrappers exist)
const detailRules: Array<[RegExp, (m: RegExpExecArray) => MapResult]> = [
[/^\/fiber-settings\/([^/]+)\/?$/i, (m) => ({ pathname: `/settings/fiber/${m[1]}` })],
[/^\/uv-settings\/([^/]+)\/?$/i, (m) => ({ pathname: `/settings/uv/${m[1]}` })],
[/^\/co2-galvo-settings\/([^/]+)\/?$/i, (m) => ({ pathname: `/settings/co2-galvo/${m[1]}` })],
[/^\/co2-gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: `/settings/co2-gantry/${m[1]}` })],
[/^\/co2gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: `/settings/co2-gantry/${m[1]}` })], // old alias
[/^\/materials\/([^/]+)\/?$/i, (m) => ({ pathname: `/materials/materials/${m[1]}` })],
[/^\/materials-coatings\/([^/]+)\/?$/i, (m) => ({ pathname: `/materials/materials-coatings/${m[1]}` })],
// Lasers / Projects detail already live under their canonical routes
// (keep as-is; no redirect needed). If you still want to map legacy, uncomment:
// [/^\/lasers\/([^/]+)\/?$/i, (m) => ({ pathname: `/lasers/${m[1]}` })],
// [/^\/projects\/([^/]+)\/?$/i, (m) => ({ pathname: `/projects/${m[1]}` })],
];
for (const [re, to] of detailRules) {
const m = re.exec(pathname);
if (m) return to(m);
}
// 2) LIST PAGES: legacy lists → portal lists (with tab param) or portal sections
// Accept optional trailing slash variants.
const listRules: Array<[RegExp, MapResult]> = [
[/^\/fiber-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "fiber" } }],
[/^\/uv-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "uv" } }],
[/^\/co2-galvo-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-galvo" } }],
[/^\/co2-ganry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // just in case of typos
[/^\/co2-gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }],
[/^\/co2gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // old alias
[/^\/materials\/?$/i, { pathname: "/portal/materials", query: { t: "materials" } }],
[/^\/materials\/materials\/?$/i, { pathname: "/portal/materials", query: { t: "materials" } }],
[/^\/materials\/materials-coatings\/?$/i,
{ pathname: "/portal/materials", query: { t: "materials-coatings" } }],
[/^\/materials-coatings\/?$/i, { pathname: "/portal/materials", query: { t: "materials-coatings" } }],
[/^\/lasers\/?$/i, { pathname: "/portal/laser-sources" }],
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
];
for (const [re, dest] of listRules) {
if (re.test(pathname)) return dest;
}
return null;
}
function isPublicPath(pathname: string): boolean {