260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
// components/lists/CO2GalvoList.tsx
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
|
|
type Owner =
|
|
| string
|
|
| number
|
|
| {
|
|
id?: string | number;
|
|
username?: string | null;
|
|
first_name?: string | null;
|
|
last_name?: string | null;
|
|
email?: string | null;
|
|
}
|
|
| null
|
|
| undefined;
|
|
|
|
type Row = {
|
|
submission_id: string | number;
|
|
setting_title?: string | null;
|
|
owner?: Owner;
|
|
uploader?: string | null;
|
|
mat?: { name?: string | null } | null;
|
|
mat_coat?: { name?: string | null } | null;
|
|
source?: { model?: string | null } | null;
|
|
lens?: { field_size?: string | number | null } | null;
|
|
};
|
|
|
|
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
|
|
|
async function readJson(r: Response) {
|
|
const t = await r.text();
|
|
try {
|
|
return t ? JSON.parse(t) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default function CO2GalvoList({
|
|
linkFor,
|
|
queryText,
|
|
onQueryChange,
|
|
}: {
|
|
linkFor: (id: string | number) => string;
|
|
queryText?: string;
|
|
onQueryChange?: (q: string) => void;
|
|
}) {
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [localQuery, setLocalQuery] = useState(queryText ?? "");
|
|
|
|
// id -> username map (fix showing UUIDs)
|
|
const [ownerMap, setOwnerMap] = useState<Record<string, string>>({});
|
|
// current user id for "Edit" visibility
|
|
const [meId, setMeId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (queryText !== undefined) setLocalQuery(queryText);
|
|
}, [queryText]);
|
|
|
|
// Load current user id
|
|
useEffect(() => {
|
|
let alive = true;
|
|
(async () => {
|
|
try {
|
|
const r = await fetch(`${API}/users/me?fields=id`, { credentials: "include", cache: "no-store" });
|
|
if (!r.ok) return;
|
|
const j = await r.json().catch(() => null);
|
|
const id = j?.data?.id ?? j?.id ?? null;
|
|
if (alive) setMeId(id ? String(id) : null);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
})();
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
(async () => {
|
|
setLoading(true);
|
|
const fields = [
|
|
"submission_id",
|
|
"setting_title",
|
|
"owner",
|
|
"owner.id",
|
|
"owner.username",
|
|
"uploader",
|
|
"mat.name",
|
|
"mat_coat.name",
|
|
"source.model",
|
|
"lens.field_size",
|
|
].join(",");
|
|
const url = `${API}/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`;
|
|
const r = await fetch(url, { credentials: "include", cache: "no-store" });
|
|
if (!r.ok) {
|
|
const j = await readJson(r);
|
|
throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
|
|
}
|
|
const j = await r.json();
|
|
const list = Array.isArray(j?.data) ? j.data : [];
|
|
if (live) setRows(list);
|
|
})()
|
|
.catch(() => live && setRows([]))
|
|
.finally(() => live && setLoading(false));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, []);
|
|
|
|
// Resolve owner usernames when only id/UUID is present
|
|
useEffect(() => {
|
|
const ids = new Set<string>();
|
|
for (const r of rows) {
|
|
const o = r.owner;
|
|
if (!o) continue;
|
|
if (typeof o === "string" || typeof o === "number") {
|
|
const id = String(o);
|
|
if (!ownerMap[id]) ids.add(id);
|
|
} else {
|
|
const id = o.id != null ? String(o.id) : "";
|
|
const hasUsername = !!o.username;
|
|
if (id && !hasUsername && !ownerMap[id]) ids.add(id);
|
|
}
|
|
}
|
|
if (!ids.size) return;
|
|
|
|
let cancelled = false;
|
|
(async () => {
|
|
const all = Array.from(ids);
|
|
const updates: Record<string, string> = {};
|
|
const chunkSize = 100;
|
|
for (let i = 0; i < all.length; i += chunkSize) {
|
|
const slice = all.slice(i, i + chunkSize);
|
|
const qs = new URLSearchParams();
|
|
qs.set("fields", "id,username");
|
|
qs.set("limit", String(slice.length));
|
|
qs.set("filter[id][_in]", slice.join(","));
|
|
const url = `${API}/users?${qs.toString()}`;
|
|
try {
|
|
const r = await fetch(url, { credentials: "include", cache: "no-store" });
|
|
if (!r.ok) continue;
|
|
const j = await r.json().catch(() => null);
|
|
const arr: Array<{ id: string | number; username?: string | null }> = j?.data || [];
|
|
for (const u of arr) {
|
|
updates[String(u.id)] = u.username || String(u.id);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (!cancelled && Object.keys(updates).length) {
|
|
setOwnerMap((prev) => ({ ...prev, ...updates }));
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [rows, ownerMap]);
|
|
|
|
const ownerLabel = (o: Owner) => {
|
|
if (!o) return "—";
|
|
if (typeof o === "string" || typeof o === "number") {
|
|
const id = String(o);
|
|
return ownerMap[id] || id;
|
|
}
|
|
return (
|
|
o.username ||
|
|
[o.first_name, o.last_name].filter(Boolean).join(" ").trim() ||
|
|
o.email ||
|
|
(o.id != null ? String(o.id) : "—")
|
|
);
|
|
};
|
|
|
|
const isMine = (o: Owner) => {
|
|
if (!meId || !o) return false;
|
|
if (typeof o === "string" || typeof o === "number") return String(o) === meId;
|
|
if (o.id != null) return String(o.id) === meId;
|
|
return false;
|
|
};
|
|
|
|
const withEditParam = (href: string) => (href.includes("?") ? `${href}&edit=1` : `${href}?edit=1`);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = (localQuery || "").toLowerCase();
|
|
if (!q) return rows;
|
|
return rows.filter((r) =>
|
|
[r.setting_title, ownerLabel(r.owner), r.uploader, r.mat?.name, r.mat_coat?.name, r.source?.model, r.lens?.field_size]
|
|
.filter(Boolean)
|
|
.some((v) => String(v).toLowerCase().includes(q))
|
|
);
|
|
}, [rows, localQuery, ownerMap]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<input
|
|
value={localQuery}
|
|
onChange={(e) => {
|
|
setLocalQuery(e.currentTarget.value);
|
|
onQueryChange?.(e.currentTarget.value);
|
|
}}
|
|
placeholder="Search title, owner, material, model…"
|
|
className="w-full border rounded px-3 py-2"
|
|
/>
|
|
|
|
{loading ? (
|
|
<p>Loading…</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full table-fixed text-sm">
|
|
<thead className="border-b">
|
|
<tr>
|
|
<th className="px-2 py-2 text-left w-[28%]">Title</th>
|
|
<th className="px-2 py-2 text-left w-[16%]">Owner</th>
|
|
<th className="px-2 py-2 text-left w-[14%]">Material</th>
|
|
<th className="px-2 py-2 text-left w-[14%]">Coating</th>
|
|
<th className="px-2 py-2 text-left w-[14%]">Model</th>
|
|
<th className="px-2 py-2 text-left w-[10%]">Field</th>
|
|
<th className="px-2 py-2 text-left w-[4%]">Edit</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{filtered.map((r) => {
|
|
const href = linkFor(r.submission_id);
|
|
return (
|
|
<tr key={r.submission_id} className="hover:bg-muted/40">
|
|
<td className="px-2 py-2 truncate">
|
|
<Link href={href} className="underline">
|
|
{r.setting_title || "Untitled"}
|
|
</Link>
|
|
</td>
|
|
<td className="px-2 py-2 truncate">{ownerLabel(r.owner)}</td>
|
|
<td className="px-2 py-2 truncate">{r.mat?.name || "—"}</td>
|
|
<td className="px-2 py-2 truncate">{r.mat_coat?.name || "—"}</td>
|
|
<td className="px-2 py-2 truncate">{r.source?.model || "—"}</td>
|
|
<td className="px-2 py-2 truncate">{r.lens?.field_size || "—"}</td>
|
|
<td className="px-2 py-2">
|
|
{isMine(r.owner) ? (
|
|
<Link href={withEditParam(href)} className="underline">
|
|
Edit
|
|
</Link>
|
|
) : (
|
|
<span className="opacity-50">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|