v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
181
src/pages/admin/EntityList.tsx
Normal file
181
src/pages/admin/EntityList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue