Revert the implicit-any typing of src/

Reverts 2428f44. The .d.ts files beside the JS modules and the
annotations threaded through src/ to satisfy noImplicitAny go;
tsconfig turns noImplicitAny off instead. The JS modules become
TypeScript in the next commit, so their types come from inference.

Kept from that commit, since they're runtime fixes rather than types:
- OrganizationDetail's website and email pills get an href and label
  (they arrive as bare strings, not link objects).
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-26 15:32:28 -05:00
parent 4618ad8e67
commit 5cedc68fd7
38 changed files with 340 additions and 1302 deletions

View file

@ -25,37 +25,9 @@ import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
/* feedback.status's CHECK values, in triage order. */
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
type FeedbackStatus = (typeof STATUSES)[number];
/* The columns GET /api/admin/feedback selects. */
type FeedbackRow = {
id: number;
created_at: string;
feedback_type: string;
message: string;
name: string | null;
email: string | null;
page_path: string | null;
section_id: string | null;
status: FeedbackStatus;
admin_note: string | null;
};
type FeedbackPage = {
feedback: FeedbackRow[];
/** Unfiltered, one per status. */
counts: Record<FeedbackStatus, number>;
nextCursor: number | null;
};
type FeedbackChanges = { status?: FeedbackStatus; admin_note?: string };
type StatusFilter = FeedbackStatus | "all";
const STATUS_STYLE: Record<FeedbackStatus, string> = {
const STATUS_STYLE = {
new: "bg-[#138ba0] text-white",
read: "bg-[#eef9fb] text-[#138ba0]",
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
@ -65,7 +37,7 @@ const STATUS_STYLE: Record<FeedbackStatus, string> = {
// 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: string) {
function formatDate(value) {
const date = new Date(`${value.replace(" ", "T")}Z`);
return date.toLocaleString(undefined, {
dateStyle: "medium",
@ -73,34 +45,26 @@ function formatDate(value: string) {
});
}
function locationOf(row: FeedbackRow) {
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 ──────────────────────────────────────────── */
type FeedbackCardProps = {
row: FeedbackRow;
onChange: (row: FeedbackRow) => void;
onRemove: (row: FeedbackRow) => void;
canWrite: boolean;
canRemove: boolean;
};
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(null);
const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? "");
async function save(changes: FeedbackChanges) {
async function save(changes) {
setBusy(true);
setError(null);
try {
const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
const data = await patch(`/admin/feedback/${row.id}`, changes);
onChange(data.feedback);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
@ -177,8 +141,7 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: Feedback
id={`status-${row.id}`}
value={row.status}
disabled={busy}
// The options are STATUSES, so the value is always one of them.
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
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) => (
@ -265,22 +228,22 @@ export default function AdminFeedback() {
const { user } = useAuth();
const navigate = useNavigate();
const [status, setStatus] = useState<StatusFilter>("new");
const [status, setStatus] = useState("new");
const [query, setQuery] = useState("");
const [search, setSearch] = useState(""); // applied, not typed
const [rows, setRows] = useState<FeedbackRow[]>([]);
const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
const [cursor, setCursor] = useState<number | null>(null);
const [rows, setRows] = useState([]);
const [counts, setCounts] = useState({});
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(null);
// Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user);
const canRemove = canDelete(user);
const load = useCallback(
async (before: number | null = null) => {
async (before = null) => {
setLoading(true);
setError(null);
@ -290,7 +253,7 @@ export default function AdminFeedback() {
if (before) params.set("before", String(before));
try {
const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
setCounts(data.counts);
setCursor(data.nextCursor);
@ -314,7 +277,7 @@ export default function AdminFeedback() {
load();
}, [load]);
function replaceRow(updated: FeedbackRow) {
function replaceRow(updated) {
setRows((prev) =>
prev
.map((row) => (row.id === updated.id ? updated : row))
@ -327,7 +290,7 @@ export default function AdminFeedback() {
// The deleted row is passed whole rather than by id: its status
// is what says which tab count to drop.
function removeRow(removed: FeedbackRow) {
function removeRow(removed) {
setRows((prev) => prev.filter((row) => row.id !== removed.id));
setCounts((prev) => ({
...prev,
@ -335,7 +298,7 @@ export default function AdminFeedback() {
}));
}
const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
const tabs = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
];

View file

@ -18,18 +18,17 @@
record) wants to be a separate component that fails on its own.
═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } from "./adminNav.js";
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
/* One card. The title link is stretched over the whole card with
`after:absolute`, which makes the card clickable without nesting
an anchor inside an anchor; the sub-links sit above it on z-10 so
they stay separately clickable. */
function NavCard({ item }: { item: AdminNavItem }) {
function NavCard({ item }) {
const to = target(item);
// Drop the child that just repeats the card's own destination —
@ -76,15 +75,7 @@ function NavCard({ item }: { item: AdminNavItem }) {
);
}
function CardBlock({
title,
blurb,
children,
}: {
title: string;
blurb?: string;
children: ReactNode;
}) {
function CardBlock({ title, blurb, children }) {
return (
<div className="mt-10">
<div className="flex items-baseline gap-3">
@ -96,7 +87,7 @@ function CardBlock({
);
}
function PanelSection({ title, children }: { title: string; children: ReactNode }) {
function PanelSection({ title, children }) {
return (
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
@ -116,7 +107,7 @@ const QUICK_ADD = [
export default function AdminHome() {
const { user } = useAuth();
const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
const role = ROLE_LABELS[user?.role] ?? user?.role;
return (
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">

View file

@ -56,8 +56,8 @@ export default function AdminLayout() {
// 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<string | null>(null);
const stableSet = useCallback((value: string | null) => setDetail(value), []);
const [detail, setDetail] = useState(null);
const stableSet = useCallback((value) => setDetail(value), []);
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => {

View file

@ -10,7 +10,7 @@
starts working.
═══════════════════════════════════════════════════════════════ */
import { useState, type FormEvent } from "react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
@ -29,12 +29,12 @@ export default function AdminLogin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/home";
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event) {
event.preventDefault();
if (busy) return;

View file

@ -24,54 +24,23 @@
the reason shows up before the click rather than after it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { del, get, patch } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useNavigate } from "react-router-dom";
import { ROLES, ROLE_LABELS, ROLE_NOTES, type Role } from "../../lib/roles.ts";
/* An admin_users row as panel.js selects it, with its live session count. */
type PanelUser = {
id: number;
email: string;
name: string | null;
role: Role;
is_active: number;
created_at: string;
last_login_at: string | null;
sessions: number;
};
/* GET /api/admin/panel/overview. A count is null when its table
doesn't exist yet. */
type Overview = {
system: {
schemaVersion: number;
dbPath: string | null;
nodeVersion: string;
platform: string;
uptimeSeconds: number;
startedAt: string;
sessions: number | null;
roles: Role[];
};
content: Array<{ label: string; count: number | null }>;
users: PanelUser[];
};
type UserResponse = { user: PanelUser };
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
marker that says so. Left alone, browsers read it as local time
and last-login drifts by the timezone offset. */
function when(value: string | null | undefined) {
function when(value) {
if (!value) return "—";
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function uptime(seconds: number | null | undefined) {
function uptime(seconds) {
if (seconds == null) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
@ -81,15 +50,7 @@ function uptime(seconds: number | null | undefined) {
return `${m}m`;
}
function Block({
title,
note,
children,
}: {
title: string;
note?: string;
children: ReactNode;
}) {
function Block({ title, note, children }) {
return (
<section className="mt-8 first:mt-0">
<div className="flex items-baseline gap-3">
@ -101,7 +62,7 @@ function Block({
);
}
function Stat({ label, value }: { label: string; value: ReactNode }) {
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
@ -116,23 +77,23 @@ export default function AdminPanel() {
const { user: me } = useAuth();
const navigate = useNavigate();
const [data, setData] = useState<Overview | null>(null);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(null);
// Which row is mid-request, and what went wrong on it. Scoped to
// the row so a failure on one account doesn't blank the table.
const [busyId, setBusyId] = useState<number | null>(null);
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
const [busyId, setBusyId] = useState(null);
const [rowError, setRowError] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await get<Overview>("/admin/panel/overview", { ttl: 0 }));
setData(await get("/admin/panel/overview", { ttl: 0 }));
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError((err instanceof Error && err.message) || "Couldn't load the panel.");
setError(err.message || "Couldn't load the panel.");
} finally {
setLoading(false);
}
@ -144,7 +105,7 @@ export default function AdminPanel() {
/* Replace the one row the server returns rather than refetching
the whole overview — the counts didn't change. */
function mergeUser(updated: PanelUser) {
function mergeUser(updated) {
setData((current) =>
current
? {
@ -155,37 +116,30 @@ export default function AdminPanel() {
);
}
async function run(id: number, work: () => Promise<PanelUser>) {
async function run(id, work) {
setBusyId(id);
setRowError(null);
try {
mergeUser(await work());
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setRowError({ id, message: (err instanceof Error && err.message) || "That didn't work." });
setRowError({ id, message: err.message || "That didn't work." });
} finally {
setBusyId(null);
}
}
const changeRole = (row: PanelUser, role: Role) =>
const changeRole = (row, role) =>
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
const setActive = (row, is_active) =>
run(
row.id,
async () => (await patch<UserResponse>(`/admin/panel/users/${row.id}`, { role })).user,
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
);
const setActive = (row: PanelUser, is_active: number) =>
run(
row.id,
async () =>
(await patch<UserResponse>(`/admin/panel/users/${row.id}`, { is_active })).user,
);
const revoke = (row: PanelUser) =>
run(
row.id,
async () => (await del<UserResponse>(`/admin/panel/users/${row.id}/sessions`)).user,
);
const revoke = (row) =>
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
if (loading) {
return (
@ -210,9 +164,6 @@ export default function AdminPanel() {
);
}
// Not loading and no error means the overview arrived.
if (!data) return null;
const { system, content, users } = data;
const activeSupers = users.filter(
(u) => u.role === "superadmin" && u.is_active === 1,
@ -303,7 +254,7 @@ export default function AdminPanel() {
<select
value={row.role}
disabled={locked || busy}
onChange={(e) => changeRole(row, e.target.value as Role)}
onChange={(e) => changeRole(row, e.target.value)}
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
title={
isMe

View file

@ -43,36 +43,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { get, post, patch, del, ApiError, type FieldErrors } from "../../lib/api.js";
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,
type AdminOptions,
type AdminRow,
type FieldCondition,
} from "../../lib/adminSchema.js";
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts";
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: string, singular: string): string {
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;
}
type RowResponse = { row: AdminRow };
type Notice = { tone: "ok" | "error"; text: string; recover?: "reload" };
export default function EntityEdit() {
const { entity: entityKey, id } = useParams();
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
@ -95,9 +85,9 @@ export default function EntityEdit() {
// 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: string[] = Array.isArray(manifest?.slugFrom)
const slugPaths = Array.isArray(manifest?.slugFrom)
? manifest.slugFrom
: [manifest?.slugFrom].filter((path): path is string => Boolean(path));
: [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
@ -107,7 +97,7 @@ export default function EntityEdit() {
// Empty until every qualifier is chosen, because half a prefix
// would be saved into an id that then never matches.
const prefixOf = (source: AdminRow | null) => {
const prefixOf = (source) => {
if (qualifierPaths.length === 0) return "";
const parts = qualifierPaths.map((path) => getPath(source, path));
if (parts.some((part) => !part)) return "";
@ -119,9 +109,9 @@ export default function EntityEdit() {
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
: "";
const tailOf = (source: AdminRow | null) => {
const tailOf = (source) => {
const prefix = prefixOf(source);
const value = String(source?.id ?? "");
const value = source?.id ?? "";
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
};
@ -130,27 +120,24 @@ export default function EntityEdit() {
// names the field to read instead.
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
// Blank when neither is set yet, which reads as "nothing to name".
const headingOf = (row: AdminRow) => String(getPath(row, headingPath) || row.id || "");
const [form, setForm] = useState<AdminRow | null>(null);
const [options, setOptions] = useState<AdminOptions>({});
const [errors, setErrors] = useState<Partial<FieldErrors>>({});
const [message, setMessage] = useState<Notice | null>(null);
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<string | null>(null);
const baseline = useRef(null);
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setErrors({});
try {
const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 });
const opts = await get("/admin/options", { ttl: 60_000 });
setOptions(opts.options);
if (isNew) {
@ -160,12 +147,12 @@ export default function EntityEdit() {
// server's column defaults apply to whatever isn't filled in.
// No id key for an auto entity: the table assigns it, and
// sending "" would be an explicit value rather than an absence.
const blank: AdminRow = autoId ? {} : { id: "" };
const blank = autoId ? {} : { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
} else {
const data = await get<RowResponse>(`/admin/${manifest.key}/${id}`, { ttl: 0 });
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
setForm(data.row);
baseline.current = JSON.stringify(data.row);
}
@ -200,7 +187,7 @@ export default function EntityEdit() {
: isNew
? `New ${manifest.singular}`
: form
? headingOf(form)
? getPath(form, headingPath) || form.id
: null,
);
@ -208,7 +195,7 @@ export default function EntityEdit() {
// Router entirely, so the only hook available is this one.
useEffect(() => {
if (!dirty) return undefined;
const warn = (event: BeforeUnloadEvent) => {
const warn = (event) => {
event.preventDefault();
event.returnValue = "";
};
@ -221,19 +208,19 @@ export default function EntityEdit() {
/* ── Heading ───────────────────────────────────────────────── */
const heading = singleton ? manifest.label : headingOf(form);
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
const heading = singleton ? manifest.label : getPath(form, headingPath) || form.id;
const updatedAt = form.updated_at;
const children = manifest.children ?? [];
/* ── Actions ───────────────────────────────────────────────── */
const leave = (to: string) => {
const leave = (to) => {
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
navigate(to);
};
const change = (path: string, value: unknown) => {
const change = (path, value) => {
setForm((prev) => {
let next = setPath(prev, path, value);
// Recompose the id whenever one of its sources moves. The
@ -255,20 +242,20 @@ export default function EntityEdit() {
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
};
const save = async () => {
async function save() {
setSaving(true);
setErrors({});
setMessage(null);
try {
const data = isNew
? await post<RowResponse>(`/admin/${manifest.key}`, form)
: await patch<RowResponse>(`/admin/${manifest.key}/${id}`, form);
? 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}/${String(data.row.id)}`, { replace: true });
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
@ -293,9 +280,9 @@ export default function EntityEdit() {
} finally {
setSaving(false);
}
};
}
const remove = async () => {
async function remove() {
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
try {
@ -311,9 +298,9 @@ export default function EntityEdit() {
: "Couldn't delete that.",
});
}
};
}
const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value;
const visible = (when) => !when || getPath(form, when.path) === when.value;
return (
<div className="pb-24">
@ -344,8 +331,8 @@ export default function EntityEdit() {
!isNew && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">
{manifest.idLabel} #{String(form.id)}
{updatedAt && <> · last saved {updatedAt}</>}
{manifest.idLabel} #{form.id}
{form.updated_at && <> · last saved {form.updated_at}</>}
</p>
</div>
)
@ -372,9 +359,9 @@ export default function EntityEdit() {
change("id", `${prefixOf(form)}${slugify(value)}`);
}}
/>
{!isNew && updatedAt && (
{!isNew && form.updated_at && (
<p className="mt-2 text-xs text-[#4a6b72]">
Last saved {updatedAt}
Last saved {form.updated_at}
</p>
)}
</div>
@ -416,7 +403,7 @@ export default function EntityEdit() {
<Repeater
key={child.key}
spec={child}
rows={form[child.key] as AdminRow[] | undefined}
rows={form[child.key]}
options={options}
errors={errors}
errorPrefix={`${child.key}.`}

View file

@ -17,25 +17,20 @@ import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-r
import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import {
ADMIN_ENTITIES,
type AdminOptions,
type AdminRow,
type ListFilter,
} from "../../lib/adminSchema.js";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts";
export default function EntityList() {
const { entity: entityKey } = useParams();
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const [rows, setRows] = useState<AdminRow[]>([]);
const [options, setOptions] = useState<AdminOptions>({});
const [rows, setRows] = useState([]);
const [options, setOptions] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(null);
const [query, setQuery] = useState(params.get("q") ?? "");
// Minimum rank, never equality. POST /api/admin/:entity is gated
@ -51,8 +46,8 @@ export default function EntityList() {
setError(null);
try {
const [list, opts] = await Promise.all([
get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get("/admin/options", { ttl: 60_000 }),
]);
setRows(list.rows);
setOptions(opts.options);
@ -77,18 +72,18 @@ export default function EntityList() {
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
}
function setParam(key: string, value: string) {
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: ListFilter): Array<readonly [string, string]> {
function labelFor(filter, value) {
if (filter.optionsFrom) {
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
}
return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
}
return (
@ -169,7 +164,7 @@ export default function EntityList() {
<tbody>
{rows.map((row) => (
<tr
key={String(row.id)}
key={row.id}
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
>
@ -184,12 +179,10 @@ export default function EntityList() {
? row[column.key]
? "Yes"
: "—"
: row[column.key]
? String(row[column.key])
: "—"}
: row[column.key] || "—"}
</td>
))}
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
</tr>
))}
</tbody>

View file

@ -18,10 +18,10 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { atLeast, type Role } from "../../lib/roles.ts";
import { atLeast } from "../../lib/roles.ts";
import { ADMIN_HOME } from "./adminNav.js";
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
export default function RequireRole({ role = "superadmin" }) {
const { user, loading } = useAuth();
// RequireAuth is already showing its own placeholder above this.

View file

@ -1,45 +0,0 @@
/* Types for adminNav.js. */
import type { AdminUser } from "../../lib/auth.tsx";
export declare const ADMIN_HOME: string;
export declare const ADMIN_PANEL: string;
export type AdminArea = "home" | "cms" | "panel";
export declare const AREA_TITLES: Record<AdminArea, string>;
export type AdminNavLink = {
to: string;
label: string;
/** Only read by the home cards. */
blurb?: string;
};
type AdminNavBase = {
label: string;
blurb?: string;
separated?: boolean;
superOnly?: boolean;
};
/** A tab. One with no `to` of its own opens its first child, so it
* must have one. */
export type AdminNavItem = AdminNavBase &
(
| { to: string; children?: AdminNavLink[] }
| { to?: undefined; children: [AdminNavLink, ...AdminNavLink[]] }
);
export declare const CMS_NAV: AdminNavItem[];
export declare const FORMS_NAV: AdminNavItem & { children: [AdminNavLink, ...AdminNavLink[]] };
export declare const PANEL_NAV: AdminNavItem;
export declare const NAV: AdminNavItem[];
export declare function navFor(user: AdminUser | null | undefined): AdminNavItem[];
export declare const matches: (pathname: string, to: string | null | undefined) => boolean;
export declare const target: (item: AdminNavItem) => string;
export declare function areaFor(pathname: string): AdminArea;