diff --git a/app/api/claims/route.ts b/app/api/claims/route.ts new file mode 100644 index 00000000..1355d749 --- /dev/null +++ b/app/api/claims/route.ts @@ -0,0 +1,58 @@ +// app/api/claims/route.ts +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API = +process.env.DIRECTUS_URL /* server-only */ ?? +process.env.NEXT_PUBLIC_API_BASE_URL /* fallback */; + +const ALLOWED = new Set([ + 'settings_fiber', + 'settings_uv', + 'settings_co2gal', + 'settings_co2gan', + 'projects', +]); + +export async function POST(req: Request) { + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { target_collection, target_id } = body ?? {}; + if (!ALLOWED.has(String(target_collection)) || target_id == null) { + return NextResponse.json({ error: 'Invalid target' }, { status: 400 }); + } + + const token = + cookies().get('directus_access_token')?.value ?? + cookies().get('ma_at')?.value ?? + ''; + + if (!token) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const r = await fetch(`${API}/items/user_claims`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + body: JSON.stringify({ target_collection, target_id }), + cache: 'no-store', + next: { revalidate: 0 }, + }); + + const data = await r.json().catch(() => ({})); + if (!r.ok) { + const msg = data?.errors?.[0]?.message ?? data?.message ?? 'Directus error'; + return NextResponse.json({ error: msg }, { status: r.status }); + } + + return NextResponse.json({ ok: true, data }, { status: 200 }); +} diff --git a/app/settings/fiber/page.tsx b/app/settings/fiber/page.tsx index 93f68fe0..260e24db 100644 --- a/app/settings/fiber/page.tsx +++ b/app/settings/fiber/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; @@ -18,22 +18,51 @@ export default function FiberSettingsPage() { const detailHref = (id: string | number) => `/settings/fiber/${id}`; useEffect(() => { - const timer = setTimeout(() => setDebouncedQuery(query), 300); - return () => clearTimeout(timer); + const t = setTimeout(() => setDebouncedQuery(query), 300); + return () => clearTimeout(t); }, [query]); useEffect(() => { - 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` - ) + // Include owner fields now that settings have an M2O "owner" + const url = + `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber` + + `?fields=` + + [ + "submission_id", + "setting_title", + "uploader", + "owner.display_name", + "owner.first_name", + "owner.last_name", + "owner.email", + "photo.id", + "photo.title", + "mat.name", + "mat_coat.name", + "source.model", + "lens.field_size", + ].join(",") + + `&limit=-1`; + + fetch(url, { cache: "no-store" }) .then((res) => res.json()) .then((data) => { - setSettings(data.data || []); + setSettings(data?.data || []); setLoading(false); }) .catch(() => setLoading(false)); }, []); + const ownerName = (row: any) => { + const o = row?.owner || {}; + return ( + o.display_name || + [o.first_name, o.last_name].filter(Boolean).join(" ") || + o.email || + "—" + ); + }; + const highlight = (text?: string) => { if (!debouncedQuery) return text || ""; const regex = new RegExp(`(${debouncedQuery})`, "gi"); @@ -46,19 +75,22 @@ export default function FiberSettingsPage() { const fieldsToSearch = [ entry.setting_title, entry.uploader, - entry.mat?.name, - entry.mat_coat?.name, - entry.source?.model, - entry.lens?.field_size, + ownerName(entry), + entry.mat?.name, + entry.mat_coat?.name, + entry.source?.model, + entry.lens?.field_size, ]; - return fieldsToSearch.filter(Boolean).some((field: string) => - 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 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; @@ -67,7 +99,9 @@ export default function FiberSettingsPage() { return acc; }, {}); const mostCommonLens = - Object.entries(commonLens).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; + Object.entries(commonLens).sort( + (a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0) + )[0]?.[0] || "—"; const sourceModels = settings.reduce((acc: Record, cur) => { const model = cur.source?.model; @@ -76,10 +110,12 @@ export default function FiberSettingsPage() { return acc; }, {}); const mostCommonSource = - Object.entries(sourceModels).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || "—"; + Object.entries(sourceModels).sort( + (a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0) + )[0]?.[0] || "—"; const recentSettings = [...settings] - .sort((a, b) => b.submission_id - a.submission_id) + .sort((a, b) => Number(b.submission_id) - Number(a.submission_id)) .slice(0, 5); return ( @@ -93,6 +129,7 @@ export default function FiberSettingsPage() { } `} + {/* Header + Search */}

Fiber Laser Settings

@@ -100,24 +137,20 @@ export default function FiberSettingsPage() { type="search" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search settings by material, uploader, etc..." + placeholder="Search by material, owner, uploader, model, etc…" className="w-full mb-4 dark:bg-background border border-border rounded-md p-2" /> -

+

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. + Browse community fiber laser settings. Use the search to narrow + results. Click any setting title to view its full configuration, + notes, and photos.

@@ -136,10 +169,16 @@ export default function FiberSettingsPage() {
    {recentSettings.map((s) => (
  • - + {s.setting_title || "Untitled"} {" "} - by {s.uploader || "—"} + {/* keep uploader for now while claims/owners migrate */} + + by {ownerName(s) !== "—" ? ownerName(s) : s.uploader || "—"} +
  • ))}
@@ -180,23 +219,9 @@ export default function FiberSettingsPage() {
- -
-
-

Submit a Setting

-

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

-
- - Submit a Setting - -
+ {/* Table */} {loading ? (

Loading settings...

) : filtered.length === 0 ? ( @@ -208,6 +233,7 @@ export default function FiberSettingsPage() { Photo Title + Owner Uploader Material Coating @@ -231,6 +257,7 @@ export default function FiberSettingsPage() { "—" )} + + + + + + + + ))} diff --git a/app/settings/fiber/page.tsx.bak b/app/settings/fiber/page.tsx.bak index 07a66428..93f68fe0 100644 --- a/app/settings/fiber/page.tsx.bak +++ b/app/settings/fiber/page.tsx.bak @@ -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); @@ -21,20 +24,20 @@ export default function FiberSettingsPage() { useEffect(() => { fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber?fields=submission_id,setting_title,uploader,photo.filename_disk,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber?fields=submission_id,setting_title,uploader,photo.id,photo.title,mat.name,mat_coat.name,source.model,lens.field_size&limit=-1` ) - .then((res) => res.json()) - .then((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,125 +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

+

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

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

No fiber settings found.

) : (
- - - - - - - - - - - - - - {filtered.map((setting) => ( - - - - - ))} - -
PhotoTitleUploaderMaterialCoatingSourceLens
- {setting.photo?.filename_disk ? ( - {setting.photo.title - ) : ( - "—" - )} - - - - - - - -
+ + + + + + + + + + + + + + {filtered.map((setting) => ( + + + + + ))} + +
PhotoTitleUploaderMaterialCoatingSourceLens
+ {setting.photo?.id ? ( + {setting.photo.title + ) : ( + "—" + )} + + + + + + + +
)} -
+ ); } - diff --git a/components/claims/ClaimButton.tsx b/components/claims/ClaimButton.tsx new file mode 100644 index 00000000..2fab4650 --- /dev/null +++ b/components/claims/ClaimButton.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useState } from 'react'; + +export default function ClaimButton({ + collection, + id, + disabledReason, +}: { + collection: 'settings_fiber' | 'settings_uv' | 'settings_co2gal' | 'settings_co2gan' | 'projects' | string; + id: string | number; + disabledReason?: string; +}) { + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const submit = async () => { + setBusy(true); + setMsg(null); + try { + const res = await fetch('/api/claims', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_collection: collection, target_id: id }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) setMsg('✅ Claim submitted for review.'); + else setMsg(data?.message || '❌ Could not submit claim.'); + } catch { + setMsg('❌ Network/auth error. Please sign in and try again.'); + } finally { + setBusy(false); + } + }; + + return ( +
+ + {disabledReason ? {disabledReason} : null} + {msg ? {msg} : null} +
+ ); +} diff --git a/components/common/OwnerBadge.tsx b/components/common/OwnerBadge.tsx new file mode 100644 index 00000000..5360c04e --- /dev/null +++ b/components/common/OwnerBadge.tsx @@ -0,0 +1,25 @@ +'use client'; + +export default function OwnerBadge({ + owner, + uploader, + className = '', +}: { + owner?: { id?: string | number; display_name?: string } | null; + uploader?: string | null; + className?: string; +}) { + const hasOwner = !!owner?.id; + const label = hasOwner ? 'Owner' : 'Uploader'; + const name = owner?.display_name ?? uploader ?? '—'; + + return ( + + {label}: + {name} + + ); +}