v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

View file

@ -0,0 +1,181 @@
/* ═══════════════════════════════════════════════════════════════
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.
═══════════════════════════════════════════════════════════════ */
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";
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") ?? "");
const canWrite = user?.role === "admin";
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>
);
}