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
315
src/pages/admin/AdminFeedback.tsx
Normal file
315
src/pages/admin/AdminFeedback.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN — FEEDBACK TRIAGE
|
||||
|
||||
Reads /api/admin/feedback, writes status and notes back through
|
||||
PATCH. Deliberately a flat list rather than a table: the message
|
||||
is the content, and messages don't fit in a cell.
|
||||
|
||||
Every read passes ttl: 0. The api cache exists for public
|
||||
content that changes weekly; a triage queue two people are
|
||||
working at the same time is the opposite case.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { get, patch, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
|
||||
const STATUS_STYLE = {
|
||||
new: "bg-[#138ba0] text-white",
|
||||
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||
archived: "bg-[#4a6b72]/10 text-[#4a6b72]",
|
||||
spam: "bg-[#fdf3f2] text-[#b3261e]",
|
||||
};
|
||||
|
||||
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
||||
// won't parse without the T and the Z.
|
||||
function formatDate(value) {
|
||||
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function locationOf(row) {
|
||||
if (!row.page_path) return "Not page-specific";
|
||||
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
||||
}
|
||||
|
||||
/* ── One submission ──────────────────────────────────────────── */
|
||||
|
||||
function FeedbackCard({ row, onChange, canWrite }) {
|
||||
const [note, setNote] = useState(row.admin_note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const noteDirty = note !== (row.admin_note ?? "");
|
||||
|
||||
async function save(changes) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||
onChange(data.feedback);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||
<span className="font-semibold text-[#26454c]">
|
||||
{feedbackTypeLabel(row.feedback_type)}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
STATUS_STYLE[row.status] ?? ""
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
<span className="text-[#4a6b72]">{locationOf(row)}</span>
|
||||
<span className="ml-auto text-xs text-[#4a6b72]">
|
||||
#{row.id} · {formatDate(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 whitespace-pre-wrap text-[#26454c]">{row.message}</p>
|
||||
|
||||
<p className="mt-4 text-sm text-[#4a6b72]">
|
||||
{row.name || row.email ? (
|
||||
<>
|
||||
{row.name && <span>{row.name}</span>}
|
||||
{row.name && row.email && " · "}
|
||||
{row.email && (
|
||||
<a
|
||||
href={`mailto:${row.email}?subject=Your%20NGU%20site%20feedback`}
|
||||
className="text-[#138ba0] underline underline-offset-2"
|
||||
>
|
||||
{row.email}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic">Sent anonymously</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{canWrite && (
|
||||
<div className="mt-5 border-t border-[#4a6b72]/15 pt-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
htmlFor={`status-${row.id}`}
|
||||
className="text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id={`status-${row.id}`}
|
||||
value={row.status}
|
||||
disabled={busy}
|
||||
onChange={(e) => save({ status: e.target.value })}
|
||||
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
disabled={busy}
|
||||
placeholder="Internal note — who's handling it, what was done"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="mt-3 w-full resize-y rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
/>
|
||||
{noteDirty && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => save({ admin_note: note })}
|
||||
className="mt-2 rounded-full bg-[#138ba0] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{busy ? "Saving…" : "Save note"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The page ────────────────────────────────────────────────── */
|
||||
|
||||
export default function AdminFeedback() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState("new");
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState(""); // applied, not typed
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [counts, setCounts] = useState({});
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const canWrite = user?.role === "admin";
|
||||
|
||||
const load = useCallback(
|
||||
async (before = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (search) params.set("q", search);
|
||||
if (before) params.set("before", String(before));
|
||||
|
||||
try {
|
||||
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||
setCounts(data.counts);
|
||||
setCursor(data.nextCursor);
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) {
|
||||
// Session expired while the page was open.
|
||||
navigate("/admin/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[status, search, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function replaceRow(updated) {
|
||||
setRows((prev) =>
|
||||
prev
|
||||
.map((row) => (row.id === updated.id ? updated : row))
|
||||
// A row that no longer matches the filter drops out, so
|
||||
// marking something 'read' clears it from the 'new' queue.
|
||||
.filter((row) => status === "all" || row.status === status),
|
||||
);
|
||||
setCounts((prev) => ({ ...prev })); // counts refresh on next load
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "all", label: "All" },
|
||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[#138ba0]">Feedback</h1>
|
||||
<p className="mt-2 text-sm text-[#4a6b72]">
|
||||
{canWrite
|
||||
? "Everything submitted through the site form."
|
||||
: "Read-only: your account can't change statuses or notes."}
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setStatus(tab.id)}
|
||||
className={
|
||||
"rounded-full px-4 py-1.5 text-sm transition-colors " +
|
||||
(status === tab.id
|
||||
? "bg-[#138ba0] font-semibold text-white"
|
||||
: "border border-[#4a6b72]/25 text-[#4a6b72] hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count ? ` (${tab.count})` : ""}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search messages"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSearch(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] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch(query.trim())}
|
||||
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#138ba0]/50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && !error && (
|
||||
<p className="mt-10 text-[#4a6b72]">
|
||||
Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{rows.map((row) => (
|
||||
<FeedbackCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
canWrite={canWrite}
|
||||
onChange={replaceRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <p className="mt-6 text-sm text-[#4a6b72]">Loading…</p>}
|
||||
|
||||
{cursor && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(cursor)}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
Load older
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/pages/admin/AdminLayout.tsx
Normal file
160
src/pages/admin/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN LAYOUT
|
||||
|
||||
Bare on purpose. No PageShell, no announcement banner, no
|
||||
footer site map — none of that belongs around a staff tool, and
|
||||
/admin should never appear in navConfig.
|
||||
|
||||
The nav is two tiers, the same shape as the public header: a
|
||||
primary row of the things you'd go looking for, and a subnav of
|
||||
whatever sits under the one you're in. Teams and Awards live
|
||||
under Organizations because that's where they belong
|
||||
conceptually — a team is part of an org, an award is given by
|
||||
one — even though each is its own table and its own page.
|
||||
|
||||
NAV is the single source for the rows, the document title and
|
||||
which tab lights up. Adding an entity is an entry here plus a
|
||||
descriptor; there's no second list to keep in step.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
|
||||
|
||||
const SITE_TITLE = "NGU Admin CMS";
|
||||
|
||||
// A group with no `to` of its own opens its first child, so
|
||||
// clicking the word Forms goes somewhere rather than nowhere.
|
||||
const NAV = [
|
||||
{ to: "/admin/events", label: "Events" },
|
||||
{
|
||||
to: "/admin/organizations",
|
||||
label: "Organizations",
|
||||
children: [
|
||||
{ to: "/admin/organizations", label: "All organizations" },
|
||||
{ to: "/admin/teams", label: "Teams" },
|
||||
{ to: "/admin/awards", label: "Awards" },
|
||||
],
|
||||
},
|
||||
{ to: "/admin/people", label: "People" },
|
||||
{
|
||||
label: "Forms",
|
||||
separated: true,
|
||||
children: [{ to: "/admin/feedback", label: "Website feedback" }],
|
||||
},
|
||||
];
|
||||
|
||||
// A tab owns its own page and everything below it, so editing
|
||||
// /admin/teams/ngu-board keeps Teams lit.
|
||||
const matches = (pathname, to) =>
|
||||
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
||||
|
||||
const target = (item) => item.to ?? item.children?.[0]?.to;
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = NAV.find(
|
||||
(item) =>
|
||||
matches(pathname, item.to) ||
|
||||
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
||||
);
|
||||
|
||||
const activeChild = (active?.children ?? []).find((child) =>
|
||||
matches(pathname, child.to),
|
||||
);
|
||||
|
||||
// What the page below has published about itself — a record
|
||||
// name, or null on a list. setDetail is stable so publishing
|
||||
// can't loop.
|
||||
const [detail, setDetail] = useState(null);
|
||||
const stableSet = useCallback((value) => setDetail(value), []);
|
||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||
|
||||
useEffect(() => {
|
||||
const section = activeChild?.label ?? active?.label;
|
||||
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
|
||||
}, [active, activeChild, detail]);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/admin/login", { replace: true });
|
||||
}
|
||||
|
||||
const subnav = active?.children ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f6fbfc]">
|
||||
<header className="border-b border-[#138ba0]/20 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
|
||||
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span>
|
||||
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
{NAV.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-6">
|
||||
{item.separated && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-px bg-[#4a6b72]/25"
|
||||
/>
|
||||
)}
|
||||
<NavLink
|
||||
to={target(item)}
|
||||
className={
|
||||
item === active
|
||||
? "font-semibold text-[#138ba0]"
|
||||
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
|
||||
<span>{user?.name || user?.email}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="rounded-full border border-[#4a6b72]/30 px-4 py-1.5 font-medium transition-colors hover:bg-[#eef9fb] hover:text-[#138ba0]"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subnav. Only drawn where there is something to draw, so
|
||||
Events and People don't get an empty grey strip. */}
|
||||
{subnav.length > 0 && (
|
||||
<div className="border-t border-[#138ba0]/10 bg-[#f6fbfc]">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap gap-6 px-6 py-2.5 text-sm">
|
||||
{subnav.map((child) => (
|
||||
<NavLink
|
||||
key={child.to}
|
||||
to={child.to}
|
||||
className={
|
||||
child === activeChild
|
||||
? "font-semibold text-[#138ba0]"
|
||||
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||
}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<AdminTitleContext.Provider value={titleContext}>
|
||||
<Outlet />
|
||||
</AdminTitleContext.Provider>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/pages/admin/AdminLogin.tsx
Normal file
142
src/pages/admin/AdminLogin.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN LOGIN
|
||||
|
||||
Deliberately outside PageShell and the site nav. This isn't a
|
||||
page of the website; it's the door to the back office, and it
|
||||
shouldn't carry a banner, a subnav, or a footer site map.
|
||||
|
||||
The Google button is stubbed and disabled until the OAuth
|
||||
routes exist. It's here so the layout doesn't change when it
|
||||
starts working.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { ApiError } from "../../lib/api.js";
|
||||
|
||||
const GOOGLE_ENABLED = false;
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
|
||||
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destination = location.state?.from?.pathname ?? "/admin/feedback";
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
navigate(destination, { replace: true });
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Couldn't reach the server. Try again in a moment.",
|
||||
);
|
||||
setPassword("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#eef9fb] px-6 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-3xl font-bold text-[#138ba0]">NGU admin</h1>
|
||||
<p className="mt-2 text-sm text-[#4a6b72]">
|
||||
Sign in to read and triage site feedback.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="mt-8 rounded-2xl border border-[#138ba0]/20 bg-white p-6"
|
||||
>
|
||||
<label
|
||||
htmlFor="admin-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="admin-password"
|
||||
className="mt-5 block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-5 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email || !password}
|
||||
className="mt-6 w-full rounded-full bg-[#138ba0] px-6 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
|
||||
{GOOGLE_ENABLED && (
|
||||
<>
|
||||
<div className="my-6 flex items-center gap-3 text-xs text-[#4a6b72]">
|
||||
<span className="h-px flex-1 bg-[#4a6b72]/20" />
|
||||
or
|
||||
<span className="h-px flex-1 bg-[#4a6b72]/20" />
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/google"
|
||||
className="block rounded-full border border-[#4a6b72]/30 px-6 py-3 text-center font-semibold text-[#26454c] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
Continue with Google
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-[#4a6b72]">
|
||||
Accounts are created on the server. Ask whoever runs the box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
src/pages/admin/EntityEdit.tsx
Normal file
422
src/pages/admin/EntityEdit.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN — ENTITY EDIT
|
||||
|
||||
Create and edit for every entity in the manifest. The form holds
|
||||
the whole nested object — parent row, side tables, child
|
||||
collections — and PATCH sends the lot. The server replaces
|
||||
children wholesale, so what you see here is exactly what will
|
||||
exist afterwards.
|
||||
|
||||
Nothing is written until Save, which is what makes removing a
|
||||
repeater row safe: leaving without saving undoes it. That only
|
||||
holds if leaving is hard to do by accident, hence the dirty
|
||||
tracking and the two guards below.
|
||||
|
||||
A field the form never sets is left out of the payload entirely,
|
||||
and the server lets the column's own DEFAULT apply. So a blank
|
||||
new-record form is deliberate, not lazy — writing "" into every
|
||||
field is what used to turn a default into a constraint failure.
|
||||
|
||||
updated_at rides along untouched. If someone else saved while
|
||||
this page was open the server answers 409 rather than letting
|
||||
one of you quietly overwrite the other. Entities with no
|
||||
updated_at column simply never get one, and the 409 path stays
|
||||
dormant for them.
|
||||
|
||||
slugFrom may name one field or several. Most ids are unique
|
||||
because the name is: two organizations aren't both called
|
||||
Northwest. Team ids are the exception — teams.id is a global
|
||||
primary key, so every org's 'Board' would collide — which is
|
||||
why the array form exists and teams uses it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||
|
||||
/* A foreign key refusing to budge is the most common way a save or
|
||||
delete fails here, and SQLite's own wording explains nothing to
|
||||
whoever is filling in the form. */
|
||||
function friendly(message, singular) {
|
||||
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
|
||||
return `Something still points at this ${singular}. Reassign or remove those first.`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export default function EntityEdit() {
|
||||
const { entity: entityKey, id } = useParams();
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const isNew = id === "new";
|
||||
const canWrite = user?.role === "admin";
|
||||
|
||||
// Hoisted above the loading guards: the title hook below is a
|
||||
// hook, so it can't sit after an early return, and it needs the
|
||||
// same paths the heading uses.
|
||||
const slugPaths = Array.isArray(manifest?.slugFrom)
|
||||
? manifest.slugFrom
|
||||
: [manifest?.slugFrom].filter(Boolean);
|
||||
|
||||
// Everything before the last path is a qualifier: a fact about
|
||||
// another field rather than something to type. It renders as
|
||||
// fixed text inside the slug box, and the editable part is only
|
||||
// what follows it.
|
||||
const qualifierPaths = slugPaths.slice(0, -1);
|
||||
|
||||
// Empty until every qualifier is chosen, because half a prefix
|
||||
// would be saved into an id that then never matches.
|
||||
const prefixOf = (source) => {
|
||||
if (qualifierPaths.length === 0) return "";
|
||||
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||
if (parts.some((part) => !part)) return "";
|
||||
return `${slugify(parts.join(" "))}-`;
|
||||
};
|
||||
|
||||
// What to show greyed out before then: org_id becomes "org-".
|
||||
const prefixHint = qualifierPaths.length
|
||||
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||
: "";
|
||||
|
||||
const tailOf = (source) => {
|
||||
const prefix = prefixOf(source);
|
||||
const value = source?.id ?? "";
|
||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
};
|
||||
|
||||
// The heading wants the specific half, not the qualifier: a team
|
||||
// page reads "Board", not "northwest Board".
|
||||
const headingPath = slugPaths[slugPaths.length - 1];
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [options, setOptions] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [message, setMessage] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
// The last state the server confirmed. Everything else compares
|
||||
// against this to decide whether there's anything to lose.
|
||||
const baseline = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||
setOptions(opts.options);
|
||||
|
||||
if (isNew) {
|
||||
// Blank children arrays matter: an absent key means "don't
|
||||
// touch", which is wrong for a row that doesn't exist yet.
|
||||
// The parent's own fields stay absent on purpose so the
|
||||
// server's column defaults apply to whatever isn't filled in.
|
||||
const blank = { id: "" };
|
||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||
setForm(blank);
|
||||
baseline.current = JSON.stringify(blank);
|
||||
} else {
|
||||
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
}
|
||||
setMessage(null);
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setMessage({
|
||||
tone: "error",
|
||||
text: err instanceof ApiError ? err.message : "Couldn't load that.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [manifest, id, isNew, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => Boolean(form) && JSON.stringify(form) !== baseline.current,
|
||||
[form],
|
||||
);
|
||||
|
||||
// The tab says what's on screen; the layout adds the section and
|
||||
// the site name. Null while loading, so it reads "Teams | NGU
|
||||
// Admin CMS" for the half-second before the record arrives
|
||||
// rather than flashing a slug.
|
||||
useAdminDetail(
|
||||
!manifest
|
||||
? null
|
||||
: isNew
|
||||
? `New ${manifest.singular}`
|
||||
: form
|
||||
? getPath(form, headingPath) || form.id
|
||||
: null,
|
||||
);
|
||||
|
||||
// Closing the tab or hitting the browser back button skips React
|
||||
// Router entirely, so the only hook available is this one.
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const warn = (event) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
|
||||
if (!manifest) return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
|
||||
|
||||
/* ── Heading ───────────────────────────────────────────────── */
|
||||
|
||||
const heading = getPath(form, headingPath) || form.id;
|
||||
|
||||
const children = manifest.children ?? [];
|
||||
|
||||
/* ── Actions ───────────────────────────────────────────────── */
|
||||
|
||||
const leave = (to) => {
|
||||
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const change = (path, value) => {
|
||||
setForm((prev) => {
|
||||
let next = setPath(prev, path, value);
|
||||
// Recompose the id whenever one of its sources moves. The
|
||||
// prefix always follows the organization — picking a
|
||||
// different one has to change the slug, or it would claim a
|
||||
// team belongs somewhere it doesn't. The tail only follows
|
||||
// the name until someone types over it.
|
||||
if (isNew && slugPaths.includes(path)) {
|
||||
const tail = slugTouched
|
||||
? tailOf(prev)
|
||||
: slugify(getPath(next, headingPath) ?? "");
|
||||
next = { ...next, id: `${prefixOf(next)}${tail}` };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Clear this field's error as soon as it's touched; leaving a
|
||||
// stale red outline on a field the user just fixed reads as a
|
||||
// save that didn't take.
|
||||
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = isNew
|
||||
? await post(`/admin/${manifest.key}`, form)
|
||||
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
setMessage({ tone: "ok", text: "Saved." });
|
||||
|
||||
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
|
||||
if (err instanceof ApiError) {
|
||||
// 409 means the updated_at we're holding is stale. Every
|
||||
// further save will fail the same way until the page is
|
||||
// reloaded, so offer that rather than just saying no.
|
||||
if (err.status === 409) {
|
||||
setMessage({ tone: "error", text: err.message, recover: "reload" });
|
||||
} else {
|
||||
setErrors(err.fields ?? {});
|
||||
setMessage({
|
||||
tone: "error",
|
||||
text: err.fields
|
||||
? "Some fields need attention."
|
||||
: friendly(err.message, manifest.singular),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setMessage({ tone: "error", text: "Couldn't reach the server." });
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||
|
||||
try {
|
||||
await del(`/admin/${manifest.key}/${id}`);
|
||||
baseline.current = JSON.stringify(form); // nothing left to warn about
|
||||
navigate(`/admin/${manifest.key}`, { replace: true });
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
tone: "error",
|
||||
text:
|
||||
err instanceof ApiError
|
||||
? friendly(err.message, manifest.singular)
|
||||
: "Couldn't delete that.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||
|
||||
return (
|
||||
<div className="pb-24">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => leave(`/admin/${manifest.key}`)}
|
||||
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
|
||||
>
|
||||
← {manifest.label}
|
||||
</button>
|
||||
|
||||
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
|
||||
{isNew ? `New ${manifest.singular}` : heading}
|
||||
</h1>
|
||||
|
||||
{/* Slug */}
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<Field
|
||||
field={{
|
||||
path: "id",
|
||||
label: manifest.idLabel,
|
||||
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
|
||||
prefixPending: isNew && !prefixOf(form),
|
||||
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
|
||||
readOnly: !isNew,
|
||||
help: !isNew
|
||||
? "Fixed once created — links and content blocks reference it."
|
||||
: qualifierPaths.length
|
||||
? "The prefix comes from the organization. Type the rest."
|
||||
: "Lowercase, hyphens, no spaces. Can't be changed later.",
|
||||
}}
|
||||
value={isNew ? tailOf(form) : form.id}
|
||||
error={errors.id}
|
||||
onChange={(value) => {
|
||||
setSlugTouched(true);
|
||||
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||
}}
|
||||
/>
|
||||
{!isNew && form.updated_at && (
|
||||
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||
Last saved {form.updated_at}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field groups */}
|
||||
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
|
||||
<section
|
||||
key={group.legend}
|
||||
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
|
||||
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
|
||||
<div className="mt-4">
|
||||
<FieldGrid>
|
||||
{group.fields.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
value={getPath(form, field.path)}
|
||||
options={options}
|
||||
error={errors[field.path]}
|
||||
onChange={(value) => change(field.path, value)}
|
||||
/>
|
||||
))}
|
||||
</FieldGrid>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Child collections. Awards own none, so the panel would be
|
||||
an empty white box — skip it rather than render it. */}
|
||||
{children.some((child) => visible(child.when)) && (
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
{children
|
||||
.filter((child) => visible(child.when))
|
||||
.map((child) => (
|
||||
<Repeater
|
||||
key={child.key}
|
||||
spec={child}
|
||||
rows={form[child.key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${child.key}.`}
|
||||
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky action bar */}
|
||||
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
|
||||
{canWrite ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-[#4a6b72]">
|
||||
Read-only: your account can't save changes.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{dirty && !saving && (
|
||||
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<span
|
||||
role="status"
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
{message.recover === "reload" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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