diff --git a/app/lasers/page.tsx b/app/lasers/page.tsx index 15935e99..bdcab1fd 100644 --- a/app/lasers/page.tsx +++ b/app/lasers/page.tsx @@ -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([]); const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [wavelengthFilters, setWavelengthFilters] = useState>({}); - const [sortKey, setSortKey] = useState('model'); + const [sortKey, setSortKey] = useState('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() ? {part} : part + part.toLowerCase() === query.toLowerCase() ? {part} : {part} ); }; 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>(() => { + const grouped = useMemo>(() => { 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); + }, {} as Record); }, [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(); const nmCounts: Record = {}; 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 (
-
-
-

Database Summary

-

Total Sources: {summaryStats.total}

-

Unique Makes: {summaryStats.uniqueMakes}

-

Most Common Wavelength: {summaryStats.commonNm}

-
+
+
+

Database Summary

+

Total Sources: {summaryStats.total}

+

Unique Makes: {summaryStats.uniqueMakes}

+

Most Common Wavelength: {summaryStats.commonNm}

+
-
-

Recent Additions

-
    - {recentSources.map((src) => ( -
  • - - {src.make} {src.model} - -
  • - ))} -
-
+
+

Recent Additions

+
    + {recentSources.map((src) => ( +
  • + + {src.make} {src.model} + +
  • + ))} +
+
-
-

Feedback

-

See something wrong or want to suggest an improvement?

- Submit Feedback -
+
+

Feedback

+

See something wrong or want to suggest an improvement?

+ Submit Feedback +
+
+ +
+

Laser Source Database

+ 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" + /> +

+ Browse laser source specifications collected from community-submitted and verified sources. +

+
+ + {Object.entries(grouped).length === 0 ? ( +

No laser sources found.

+ ) : ( +
+ {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 ( +
+ + + {make} ({filteredItems.length}) + +
+ {[10600, 1064, 455, 355].map((w) => ( + + ))} +
+
+
+ + + + + + + + + + + + + + + + {sortedItems.map((src) => ( + + + + + + + + + + + + ))} + +
Make + + + + + + + + + + + + + + + +
+ {highlightMatch(src.make || '—', debouncedQuery)} + + + {highlightMatch(src.model || '—', debouncedQuery)} + + {src.w || '—'}{src.mj || '—'} + {typeof src.op === 'object' && src.op ? src.op.name || '—' : src.op || '—'} + {src.nm || '—'}{src.kHz || '—'}{src.ns || '—'}{src.v || '—'}
+
+
+ ); + })}
- -
-

Laser Source Database

- 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" - /> -

- Browse laser source specifications collected from community-submitted and verified sources. -

-
- - {Object.entries(grouped).length === 0 ? ( -

No laser sources found.

- ) : ( -
- {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 ( -
- - {make} ({filteredItems.length}) -
- {wavelengths.map((w) => ( - - ))} -
-
-
- - - - - - - - - - - - - - - - {sortedItems.map((src) => ( - - - - - - - - - - - - ))} - -
Make - - - - - - - - - - - - - - - -
{highlightMatch(src.make || '—', debouncedQuery)} - - {highlightMatch(src.model || '—', debouncedQuery)} - - {src.w || '—'}{src.mj || '—'}{src.op?.label || src.op || '—'}{src.nm || '—'}{src.kHz || '—'}{src.ns || '—'}{src.v || '—'}
-
-
- ); - })} -
- )} + )}
); } - diff --git a/app/materials/materials-coatings/page.tsx b/app/materials/materials-coatings/page.tsx index c6f5b04c..e14ad934 100644 --- a/app/materials/materials-coatings/page.tsx +++ b/app/materials/materials-coatings/page.tsx @@ -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() ? {part} : part + part.toLowerCase() === q.toLowerCase() ? {part} : {part} ); } export default function CoatingsPage() { - const [coatings, setCoatings] = useState([]); + const [coatings, setCoatings] = useState([]); 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 (
-
-
-

Laser Material Coatings

- 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" - /> -

📌 Disclaimer

-

- 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. -

-
- - ← Back to Main Menu - -
-
+
+
+

Laser Material Coatings

+ 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" + /> +

📌 Disclaimer

+

+ 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. +

+
+ + ← Back to Main Menu + +
+
-
-

⚠ Safety Level Definitions

-
    -
  • Safe – 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.
  • -
  • Level I – Caution | 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.
  • -
  • Level II – Dangerous | 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.
  • -
  • Level III – Critical Hazard | 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.
  • -
-
+
+

⚠ Safety Level Definitions

+
    +
  • + Safe – 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. +
  • +
  • + Level I – Caution | 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. +
  • +
  • + Level II – Dangerous | 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. +
  • +
  • + Level III – Critical Hazard | 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. +
  • +
+
+
+ + {filtered.length === 0 ? ( +

No coatings found.

+ ) : ( +
+ + + + + + + + + + + + {filtered.map((coat) => ( + + + + + + + + ))} + +
NameStatusAbbreviationTechnical NameComposition
+ + {highlightMatch(coat.name, debouncedQuery)} + + + {highlightMatch(coat.coating_status?.name || '—', debouncedQuery)} + + {highlightMatch(coat.abbreviation || '—', debouncedQuery)} + + {highlightMatch(coat.technical_name || '—', debouncedQuery)} + + {highlightMatch(coat.composition || '—', debouncedQuery)} +
- - {filtered.length === 0 ? ( -

No coatings found.

- ) : ( -
- - - - - - - - - - - - {filtered.map((coat) => ( - - - - - - - - ))} - -
NameStatusAbbreviationTechnical NameComposition
- - {highlightMatch(coat.name, debouncedQuery)} - - - {highlightMatch(coat.coating_status?.name || '—', debouncedQuery)} - - {highlightMatch(coat.abbreviation || '—', debouncedQuery)} - - {highlightMatch(coat.technical_name || '—', debouncedQuery)} - - {highlightMatch(coat.composition || '—', debouncedQuery)} -
-
- )} + )}
); } - diff --git a/app/materials/materials/page.tsx b/app/materials/materials/page.tsx index 8f7190f3..c2b7d671 100644 --- a/app/materials/materials/page.tsx +++ b/app/materials/materials/page.tsx @@ -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() ? {part} : part + part.toLowerCase() === query.toLowerCase() ? {part} : {part} ); } @@ -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>(() => { 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); }, [filtered]); return (
-
-
-

Laser Material Reference

- 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" - /> -

📌 Disclaimer

-

- 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. -

-
- - ← Back to Main Menu - -
-
+
+
+

Laser Material Reference

+ 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" + /> +

📌 Disclaimer

+

+ 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. +

+
+ + ← Back to Main Menu + +
+
-
-

⚠ Safety Level Definitions

-
    -
  • Safe – 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.
  • -
  • Level I – Caution | 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.
  • -
  • Level II – Dangerous | 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.
  • -
  • Level III – Critical Hazard | 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.
  • -
+
+

⚠ Safety Level Definitions

+
    +
  • + Safe – 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. +
  • +
  • + Level I – Caution | 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. +
  • +
  • + Level II – Dangerous | 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. +
  • +
  • + Level III – Critical Hazard | 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. +
  • +
+
+
+ + {Object.entries(grouped).length === 0 ? ( +

No materials found.

+ ) : ( +
+ {Object.entries(grouped).map(([category, items]) => ( +
+ + {category} ({items.length}) + +
+ + + + + + + + + + + + {items.map((material) => ( + + + + + + + + ))} + +
NameStatusAbbreviationCommon NamesTechnical Name
+ + {highlightMatch(material.name, debouncedQuery)} + + + {highlightMatch(material.material_status?.name || '—', debouncedQuery)} + + {highlightMatch(material.abbreviation || '—', debouncedQuery)} + + {highlightMatch(material.common_names || '—', debouncedQuery)} + + {highlightMatch(material.technical_name || '—', debouncedQuery)} +
+
+ ))}
- - {Object.entries(grouped).length === 0 ? ( -

No materials found.

- ) : ( -
- {Object.entries(grouped).map(([category, items]) => ( -
- - {category} ({items.length}) - -
- - - - - - - - - - - - {items.map((material) => ( - - - - - - - - ))} - -
NameStatusAbbreviationCommon NamesTechnical Name
- - {highlightMatch(material.name, debouncedQuery)} - - - {highlightMatch(material.material_status?.name || '—', debouncedQuery)} - - {highlightMatch(material.abbreviation || '—', debouncedQuery)} - - {highlightMatch(material.common_names || '—', debouncedQuery)} - - {highlightMatch(material.technical_name || '—', debouncedQuery)} -
-
-
- ))} -
- )} + )}
); } - diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 067f0cf5..adb2769a 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -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([]); + const [projects, setProjects] = useState([]); 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, '$1'); + return text?.replace(regex, "$1"); }; - 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 (
-
-

CO₂ Galvo Settings

- 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" - /> -

- View and explore detailed CO₂ galvo settings with context. -

- - ← Back to Main Menu - -
+
+

CO₂ Galvo Settings

+ 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" + /> +

+ View and explore detailed CO₂ galvo settings with context. +

+ + ← Back to Main Menu + +
-
-

How to Use

-

- 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. -

-
+
+

How to Use

+

+ 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. +

+
-
-

Stats Summary

-
    -
  • Total Settings: {totalSettings}
  • -
  • Unique Materials: {uniqueMaterials}
  • -
  • Most Common Lens: {mostCommonLens}
  • -
  • Most Used Source: {mostCommonSource}
  • -
-
+
+

Stats Summary

+
    +
  • Total Settings: {totalSettings}
  • +
  • Unique Materials: {uniqueMaterials}
  • +
  • Most Common Lens: {mostCommonLens}
  • +
  • Most Used Source: {mostCommonSource}
  • +
+
-
-

Recently Added

-
    - {recentSettings.map((s) => ( -
  • - - {s.setting_title || "Untitled"} - {" "} - by {s.uploader || "—"} -
  • - ))} -
-
+
+

Recently Added

+
    + {recentSettings.map((s) => ( +
  • + + {s.setting_title || "Untitled"} + {" "} + by {s.uploader || "—"} +
  • + ))} +
+
- + -
-
-

Submit a Setting

-

- Have a reliable galvo setting to share? Contribute to the community database. -

-
- - Submit a Setting - -
+
+
+

Submit a Setting

+

+ Have a reliable galvo setting to share? Contribute to the community database. +

+
+ + Submit a Setting + +
{loading ? ( @@ -179,53 +182,67 @@ export default function CO2GalvoSettingsPage() {

No CO₂ galvo settings found.

) : (
- - - - - - - - - - - - - - {filtered.map((setting) => ( - - - - - ))} - -
PhotoTitleUploaderMaterialCoatingSourceLens
- {setting.photo?.id ? ( - {setting.photo.title - ) : ( - "—" - )} - - - - - - - -
+ + + + + + + + + + + + + + {filtered.map((setting) => ( + + + + + ))} + +
PhotoTitleUploaderMaterialCoatingSourceLens
+ {setting.photo?.id ? ( + {setting.photo.title + ) : ( + "—" + )} + + + + + + + +
)} -
+
); } - diff --git a/app/settings/co2-gantry/page.tsx b/app/settings/co2-gantry/page.tsx index 7bec43ce..0ef34a1f 100644 --- a/app/settings/co2-gantry/page.tsx +++ b/app/settings/co2-gantry/page.tsx @@ -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([]); 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, '$1'); + return (text || "").replace(regex, "$1"); }; 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, 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, 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 (
-
-
-

CO₂ Gantry Settings

- 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" - /> -

- Explore curated CO₂ gantry settings. Search by material, uploader, or source. -

- - ← Back to Main Menu - -
+
+

CO₂ Gantry Settings

+ 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" + /> +

+ Explore curated CO₂ gantry settings. Search by material, uploader, or source. +

+ + ← Back to Main Menu + +
-
-

How to Use

-

- Browse real-world CO₂ gantry settings. Search or filter results, and click any setting for full configuration and notes. -

-
+
+

How to Use

+

+ Browse real-world CO₂ gantry settings. Search or filter results, and click any setting for full configuration and notes. +

+
-
-

Stats Summary

-
    -
  • Total Settings: {totalSettings}
  • -
  • Unique Materials: {uniqueMaterials}
  • -
  • Most Common Lens: {mostCommonLens}
  • -
  • Most Used Source: {mostCommonSource}
  • -
-
+
+

Stats Summary

+
    +
  • Total Settings: {totalSettings}
  • +
  • Unique Materials: {uniqueMaterials}
  • +
  • Most Common Lens: {mostCommonLens}
  • +
  • Most Used Source: {mostCommonSource}
  • +
+
-
-

Recently Added

-
    - {recentSettings.map((s) => ( -
  • - - {s.setting_title || "Untitled"} - by {s.uploader || "—"} -
  • - ))} -
-
+
+

Recently Added

+
    + {recentSettings.map((s) => ( +
  • + + {s.setting_title || "Untitled"} + {" "} + by {s.uploader || "—"} +
  • + ))} +
+
- + -
-
-

Submit a Setting

-

- Got a dialed-in gantry setting? Contribute it to the database. -

-
- - Submit a Setting - -
+
+
+

Submit a Setting

+

+ Got a dialed-in gantry setting? Contribute it to the database. +

+
+ + Submit a Setting + +
{loading ? ( @@ -178,53 +188,67 @@ export default function CO2GantrySettingsPage() {

No gantry settings found.

) : (
- - - - - - - - - - - - - - {filtered.map((setting) => ( - - - - - ))} - -
PhotoTitleUploaderMaterialCoatingSourceLens
- {setting.photo?.id ? ( - {setting.photo.title - ) : ( - "—" - )} - - - - - - - -
+ + + + + + + + + + + + + + {filtered.map((setting) => ( + + + + + ))} + +
PhotoTitleUploaderMaterialCoatingSourceLens
+ {setting.photo?.id ? ( + {setting.photo.title + ) : ( + "—" + )} + + + + + + + +
)} -
+
); } - diff --git a/app/settings/fiber/page.tsx b/app/settings/fiber/page.tsx index a7a4e8ca..93f68fe0 100644 --- a/app/settings/fiber/page.tsx +++ b/app/settings/fiber/page.tsx @@ -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([]); 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, '$1'); + return (text || "").replace(regex, "$1"); }; 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, 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, 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 (
-
-
-

Fiber Laser Settings

- 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" - /> -

- View and explore detailed fiber laser settings with context. -

- - ← Back to Main Menu - -
+
+

Fiber Laser Settings

+ 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" + /> +

+ View and explore detailed fiber laser settings with context. +

+ + ← Back to Main Menu + +
-
-

How to Use

-

- 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. -

-
+
+

How to Use

+

+ 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. +

+
-
-

Stats Summary

-
    -
  • Total Settings: {totalSettings}
  • -
  • Unique Materials: {uniqueMaterials}
  • -
  • Most Common Lens: {mostCommonLens}
  • -
  • Most Used Source: {mostCommonSource}
  • -
-
+
+

Stats Summary

+
    +
  • Total Settings: {totalSettings}
  • +
  • Unique Materials: {uniqueMaterials}
  • +
  • Most Common Lens: {mostCommonLens}
  • +
  • Most Used Source: {mostCommonSource}
  • +
+
-
-

Recently Added

-
    - {recentSettings.map((s) => ( -
  • - - {s.setting_title || "Untitled"} - by {s.uploader || "—"} -
  • - ))} -
-
+
+

Recently Added

+
    + {recentSettings.map((s) => ( +
  • + + {s.setting_title || "Untitled"} + {" "} + by {s.uploader || "—"} +
  • + ))} +
+
- + -
-
-

Submit a Setting

-

- Have a reliable fiber setting to share? Contribute to the community database. -

-
- - Submit a Setting - -
+
+
+

Submit a Setting

+

+ Have a reliable fiber setting to share? Contribute to the community database. +

+
+ + Submit a Setting + +
{loading ? ( @@ -178,53 +203,69 @@ export default function FiberSettingsPage() {

No fiber settings found.

) : (
- - - - - - - - - - - - - - {filtered.map((setting) => ( - - - - - ))} - -
PhotoTitleUploaderMaterialCoatingSourceLens
- {setting.photo?.id ? ( - {setting.photo.title - ) : ( - "—" - )} - - - - - - - -
+ + + + + + + + + + + + + + {filtered.map((setting) => ( + + + + + ))} + +
PhotoTitleUploaderMaterialCoatingSourceLens
+ {setting.photo?.id ? ( + {setting.photo.title + ) : ( + "—" + )} + + + + + + + +
)} -
+
); } - diff --git a/app/settings/uv/page.tsx b/app/settings/uv/page.tsx index 1c8e2f10..a3cb2d8c 100644 --- a/app/settings/uv/page.tsx +++ b/app/settings/uv/page.tsx @@ -14,6 +14,9 @@ export default function UVSettingsPage() { const [settings, setSettings] = useState([]); 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, '$1'); + return (text || "").replace(regex, "$1"); }; 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, 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, 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 (
-
-
-

UV Laser Settings

- 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" - /> -

- View and explore detailed UV laser settings with context. -

- - ← Back to Main Menu - -
+
+

UV Laser Settings

+ 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" + /> +

+ View and explore detailed UV laser settings with context. +

+ + ← Back to Main Menu + +
-
-

How to Use

-

- 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. -

-
+
+

How to Use

+

+ 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. +

+
- + -
-
-

Submit a Setting

-

- Have a reliable UV setting to share? Contribute to the community database. -

-
- - Submit a Setting - -
+
+
+

Submit a Setting

+

+ Have a reliable UV setting to share? Contribute to the community database. +

+
+ + Submit a Setting + +
-
-

Stats Summary

-
    -
  • Total Settings: {totalSettings}
  • -
  • Unique Materials: {uniqueMaterials}
  • -
  • Most Common Lens: {mostCommonLens}
  • -
  • Most Used Source: {mostCommonSource}
  • -
-
+
+

Stats Summary

+
    +
  • Total Settings: {totalSettings}
  • +
  • Unique Materials: {uniqueMaterials}
  • +
  • Most Common Lens: {mostCommonLens}
  • +
  • Most Used Source: {mostCommonSource}
  • +
+
-
-

Recently Added

-
    - {recentSettings.map((s) => ( -
  • - - {s.setting_title || "Untitled"} - by {s.uploader || "—"} -
  • - ))} -
-
+
+

Recently Added

+
    + {recentSettings.map((s) => ( +
  • + + {s.setting_title || "Untitled"} + {" "} + by {s.uploader || "—"} +
  • + ))} +
+
{loading ? ( @@ -177,53 +193,67 @@ export default function UVSettingsPage() {

No UV settings found.

) : (
- - - - - - - - - - - - - - {filtered.map((setting) => ( - - - - - ))} - -
PhotoTitleUploaderMaterialCoatingSourceLens
- {setting.photo?.id ? ( - {setting.photo.title - ) : ( - "—" - )} - - - - - - - -
+ + + + + + + + + + + + + + {filtered.map((setting) => ( + + + + + ))} + +
PhotoTitleUploaderMaterialCoatingSourceLens
+ {setting.photo?.id ? ( + {setting.photo.title + ) : ( + "—" + )} + + + + + + + +
)} -
+
); } - diff --git a/middleware.ts b/middleware.ts index 32006c72..9c46dd19 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 }; 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 {