193 lines
7.2 KiB
TypeScript
193 lines
7.2 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
|
ADMIN — ENTITY LIST
|
|
|
|
One component for organizations, events and people. The :entity
|
|
route param picks the manifest; nothing here knows what a
|
|
chapter or a retreat is.
|
|
|
|
The "New X" link follows the same rule as EntityEdit's save
|
|
button: atLeast(user, "editor"), matching requireRole("editor")
|
|
on POST /api/admin/:entity. Drawing it is not permission — the
|
|
server decides — it only avoids offering a click that 403s, and
|
|
avoids hiding one that wouldn't.
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
|
|
import { get, ApiError } from "../../lib/api.js";
|
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
|
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
|
import { atLeast } from "../../lib/roles.ts";
|
|
|
|
export default function EntityList() {
|
|
const { entity: entityKey } = useParams();
|
|
const manifest = ADMIN_ENTITIES[entityKey];
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
|
|
const [params, setParams] = useSearchParams();
|
|
const [rows, setRows] = useState([]);
|
|
const [options, setOptions] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
|
|
|
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
|
// at "editor", so anyone from editor upward may create — and the
|
|
// equality test this replaced hid the button from superadmins as
|
|
// well as editors, which is the failure mode an == against a
|
|
// ladder always produces once a rank is added above it.
|
|
const canWrite = atLeast(user, "editor");
|
|
|
|
const load = useCallback(async () => {
|
|
if (!manifest) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [list, opts] = await Promise.all([
|
|
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
|
get("/admin/options", { ttl: 60_000 }),
|
|
]);
|
|
setRows(list.rows);
|
|
setOptions(opts.options);
|
|
} catch (err) {
|
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
|
setError(err instanceof ApiError ? err.message : "Couldn't reach the server.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [manifest, params, navigate]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
if (!manifest) {
|
|
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
|
}
|
|
|
|
function setParam(key, value) {
|
|
const next = new URLSearchParams(params);
|
|
if (value) next.set(key, value);
|
|
else next.delete(key);
|
|
setParams(next, { replace: true });
|
|
}
|
|
|
|
function labelFor(filter, value) {
|
|
if (filter.optionsFrom) {
|
|
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
|
}
|
|
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<h1 className="text-3xl font-bold text-[#138ba0]">{manifest.label}</h1>
|
|
{canWrite && (
|
|
<Link
|
|
to={`/admin/${manifest.key}/new`}
|
|
className="ml-auto rounded-full bg-[#138ba0] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183]"
|
|
>
|
|
New {manifest.singular}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mt-6 flex flex-wrap items-end gap-3">
|
|
{manifest.list.filters.map((filter) => (
|
|
<label key={filter.key} className="text-xs text-[#4a6b72]">
|
|
<span className="block">{filter.label}</span>
|
|
<select
|
|
value={params.get(filter.key) ?? ""}
|
|
onChange={(e) => setParam(filter.key, e.target.value)}
|
|
className="mt-1 rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
|
|
>
|
|
<option value="">All</option>
|
|
{labelFor(filter).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
))}
|
|
|
|
<div className="ml-auto flex gap-2">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
placeholder="Search"
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && setParam("q", query.trim())}
|
|
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setParam("q", query.trim())}
|
|
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] hover:border-[#138ba0]/50"
|
|
>
|
|
Search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* Rows */}
|
|
<div className="mt-6 overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-[#4a6b72]/15 text-xs text-[#4a6b72]">
|
|
<tr>
|
|
{manifest.list.columns.map((column) => (
|
|
<th key={column.key} className="px-4 py-3 font-medium">
|
|
{column.label}
|
|
</th>
|
|
))}
|
|
<th className="px-4 py-3 font-medium">Slug</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr
|
|
key={row.id}
|
|
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
|
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
|
>
|
|
{manifest.list.columns.map((column) => (
|
|
<td
|
|
key={column.key}
|
|
className={`px-4 py-3 ${
|
|
column.primary ? "font-medium text-[#26454c]" : "text-[#4a6b72]"
|
|
}`}
|
|
>
|
|
{column.widget === "bool"
|
|
? row[column.key]
|
|
? "Yes"
|
|
: "—"
|
|
: row[column.key] || "—"}
|
|
</td>
|
|
))}
|
|
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{!loading && rows.length === 0 && (
|
|
<p className="px-4 py-8 text-center text-[#4a6b72]">Nothing matches.</p>
|
|
)}
|
|
{loading && <p className="px-4 py-8 text-center text-[#4a6b72]">Loading…</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|