Type src/ against API and database shapes, fixing implicit anys
Adds .d.ts declarations beside the untyped JS modules imported from TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*), with shapes taken from the server routes and migrations. get/post/ patch/del now return unknown unless the caller names the response. Fixes found along the way: - website/email/instagram are bare strings from splitLinks, not Link objects; OrganizationDetail's website and email pills rendered with no href or label and now link correctly. - The chapter map falls back to FALLBACK_COLOR for a region with no colour instead of painting its tiles black. Adds defineSection() so each page manifest entry's props are checked against its own Component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5286cae9ca
commit
2428f4412a
39 changed files with 1318 additions and 343 deletions
|
|
@ -25,9 +25,37 @@ import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
|||
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
/* feedback.status's CHECK values, in triage order. */
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
|
||||
|
||||
const STATUS_STYLE = {
|
||||
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> = {
|
||||
new: "bg-[#138ba0] text-white",
|
||||
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||
|
|
@ -37,7 +65,7 @@ const STATUS_STYLE = {
|
|||
|
||||
// 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) {
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
|
|
@ -45,26 +73,34 @@ function formatDate(value) {
|
|||
});
|
||||
}
|
||||
|
||||
function locationOf(row) {
|
||||
function locationOf(row: FeedbackRow) {
|
||||
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, onRemove, canWrite, canRemove }) {
|
||||
type FeedbackCardProps = {
|
||||
row: FeedbackRow;
|
||||
onChange: (row: FeedbackRow) => void;
|
||||
onRemove: (row: FeedbackRow) => void;
|
||||
canWrite: boolean;
|
||||
canRemove: boolean;
|
||||
};
|
||||
|
||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
|
||||
const [note, setNote] = useState(row.admin_note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const noteDirty = note !== (row.admin_note ?? "");
|
||||
|
||||
async function save(changes) {
|
||||
async function save(changes: FeedbackChanges) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||
const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
|
||||
onChange(data.feedback);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||
|
|
@ -141,7 +177,8 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
|||
id={`status-${row.id}`}
|
||||
value={row.status}
|
||||
disabled={busy}
|
||||
onChange={(e) => save({ status: e.target.value })}
|
||||
// The options are STATUSES, so the value is always one of them.
|
||||
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
|
||||
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) => (
|
||||
|
|
@ -228,22 +265,22 @@ export default function AdminFeedback() {
|
|||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState("new");
|
||||
const [status, setStatus] = useState<StatusFilter>("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 [rows, setRows] = useState<FeedbackRow[]>([]);
|
||||
const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Minimums, not equality — see lib/roles.ts.
|
||||
const canWrite = roleCanWrite(user);
|
||||
const canRemove = canDelete(user);
|
||||
|
||||
const load = useCallback(
|
||||
async (before = null) => {
|
||||
async (before: number | null = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -253,7 +290,7 @@ export default function AdminFeedback() {
|
|||
if (before) params.set("before", String(before));
|
||||
|
||||
try {
|
||||
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||
setCounts(data.counts);
|
||||
setCursor(data.nextCursor);
|
||||
|
|
@ -277,7 +314,7 @@ export default function AdminFeedback() {
|
|||
load();
|
||||
}, [load]);
|
||||
|
||||
function replaceRow(updated) {
|
||||
function replaceRow(updated: FeedbackRow) {
|
||||
setRows((prev) =>
|
||||
prev
|
||||
.map((row) => (row.id === updated.id ? updated : row))
|
||||
|
|
@ -290,7 +327,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) {
|
||||
function removeRow(removed: FeedbackRow) {
|
||||
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -298,7 +335,7 @@ export default function AdminFeedback() {
|
|||
}));
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
|
||||
{ id: "all", label: "All" },
|
||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,17 +18,18 @@
|
|||
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 } from "./adminNav.js";
|
||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } 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 }) {
|
||||
function NavCard({ item }: { item: AdminNavItem }) {
|
||||
const to = target(item);
|
||||
|
||||
// Drop the child that just repeats the card's own destination —
|
||||
|
|
@ -75,7 +76,15 @@ function NavCard({ item }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CardBlock({ title, blurb, children }) {
|
||||
function CardBlock({
|
||||
title,
|
||||
blurb,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
blurb?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex items-baseline gap-3">
|
||||
|
|
@ -87,7 +96,7 @@ function CardBlock({ title, blurb, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, children }) {
|
||||
function PanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
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">
|
||||
|
|
@ -107,7 +116,7 @@ const QUICK_ADD = [
|
|||
|
||||
export default function AdminHome() {
|
||||
const { user } = useAuth();
|
||||
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||
const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||
|
|
|
|||
|
|
@ -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(null);
|
||||
const stableSet = useCallback((value) => setDetail(value), []);
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const stableSet = useCallback((value: string | null) => setDetail(value), []);
|
||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
starts working.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } 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(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||
|
||||
async function handleSubmit(event) {
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,23 +24,54 @@
|
|||
the reason shows up before the click rather than after it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } 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 } from "../../lib/roles.ts";
|
||||
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 };
|
||||
|
||||
/* 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) {
|
||||
function when(value: string | null | undefined) {
|
||||
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) {
|
||||
function uptime(seconds: number | null | undefined) {
|
||||
if (seconds == null) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
|
|
@ -50,7 +81,15 @@ function uptime(seconds) {
|
|||
return `${m}m`;
|
||||
}
|
||||
|
||||
function Block({ title, note, children }) {
|
||||
function Block({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mt-8 first:mt-0">
|
||||
<div className="flex items-baseline gap-3">
|
||||
|
|
@ -62,7 +101,7 @@ function Block({ title, note, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
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">
|
||||
|
|
@ -77,23 +116,23 @@ export default function AdminPanel() {
|
|||
const { user: me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [data, setData] = useState<Overview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(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(null);
|
||||
const [rowError, setRowError] = useState(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||
setData(await get<Overview>("/admin/panel/overview", { ttl: 0 }));
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setError(err.message || "Couldn't load the panel.");
|
||||
setError((err instanceof Error && err.message) || "Couldn't load the panel.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -105,7 +144,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) {
|
||||
function mergeUser(updated: PanelUser) {
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
|
|
@ -116,30 +155,37 @@ export default function AdminPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
async function run(id, work) {
|
||||
async function run(id: number, work: () => Promise<PanelUser>) {
|
||||
setBusyId(id);
|
||||
setRowError(null);
|
||||
try {
|
||||
mergeUser(await work());
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setRowError({ id, message: err.message || "That didn't work." });
|
||||
setRowError({ id, message: (err instanceof Error && err.message) || "That didn't work." });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = (row, role) =>
|
||||
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||
|
||||
const setActive = (row, is_active) =>
|
||||
const changeRole = (row: PanelUser, role: Role) =>
|
||||
run(
|
||||
row.id,
|
||||
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||
async () => (await patch<UserResponse>(`/admin/panel/users/${row.id}`, { role })).user,
|
||||
);
|
||||
|
||||
const revoke = (row) =>
|
||||
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).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,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -164,6 +210,9 @@ 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,
|
||||
|
|
@ -254,7 +303,7 @@ export default function AdminPanel() {
|
|||
<select
|
||||
value={row.role}
|
||||
disabled={locked || busy}
|
||||
onChange={(e) => changeRole(row, e.target.value)}
|
||||
onChange={(e) => changeRole(row, e.target.value as Role)}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -43,26 +43,36 @@
|
|||
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 { get, post, patch, del, ApiError, type FieldErrors } 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 {
|
||||
ADMIN_ENTITIES,
|
||||
slugify,
|
||||
type AdminOptions,
|
||||
type AdminRow,
|
||||
type FieldCondition,
|
||||
} 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, singular) {
|
||||
function friendly(message: string, singular: string): string {
|
||||
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 = ADMIN_ENTITIES[entityKey];
|
||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
|
@ -79,9 +89,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 = Array.isArray(manifest?.slugFrom)
|
||||
const slugPaths: string[] = Array.isArray(manifest?.slugFrom)
|
||||
? manifest.slugFrom
|
||||
: [manifest?.slugFrom].filter(Boolean);
|
||||
: [manifest?.slugFrom].filter((path): path is string => Boolean(path));
|
||||
|
||||
// Everything before the last path is a qualifier: a fact about
|
||||
// another field rather than something to type. It renders as
|
||||
|
|
@ -91,7 +101,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) => {
|
||||
const prefixOf = (source: AdminRow | null) => {
|
||||
if (qualifierPaths.length === 0) return "";
|
||||
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||
if (parts.some((part) => !part)) return "";
|
||||
|
|
@ -103,9 +113,9 @@ export default function EntityEdit() {
|
|||
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||
: "";
|
||||
|
||||
const tailOf = (source) => {
|
||||
const tailOf = (source: AdminRow | null) => {
|
||||
const prefix = prefixOf(source);
|
||||
const value = source?.id ?? "";
|
||||
const value = String(source?.id ?? "");
|
||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
};
|
||||
|
||||
|
|
@ -114,24 +124,27 @@ export default function EntityEdit() {
|
|||
// names the field to read instead.
|
||||
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [options, setOptions] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [message, setMessage] = useState(null);
|
||||
// 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 [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 baseline = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||
const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 });
|
||||
setOptions(opts.options);
|
||||
|
||||
if (isNew) {
|
||||
|
|
@ -141,12 +154,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 = autoId ? {} : { id: "" };
|
||||
const blank: AdminRow = autoId ? {} : { 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 });
|
||||
const data = await get<RowResponse>(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
}
|
||||
|
|
@ -181,7 +194,7 @@ export default function EntityEdit() {
|
|||
: isNew
|
||||
? `New ${manifest.singular}`
|
||||
: form
|
||||
? getPath(form, headingPath) || form.id
|
||||
? headingOf(form)
|
||||
: null,
|
||||
);
|
||||
|
||||
|
|
@ -189,7 +202,7 @@ export default function EntityEdit() {
|
|||
// Router entirely, so the only hook available is this one.
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const warn = (event) => {
|
||||
const warn = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
|
|
@ -202,18 +215,19 @@ export default function EntityEdit() {
|
|||
|
||||
/* ── Heading ───────────────────────────────────────────────── */
|
||||
|
||||
const heading = getPath(form, headingPath) || form.id;
|
||||
const heading = headingOf(form);
|
||||
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
||||
|
||||
const children = manifest.children ?? [];
|
||||
|
||||
/* ── Actions ───────────────────────────────────────────────── */
|
||||
|
||||
const leave = (to) => {
|
||||
const leave = (to: string) => {
|
||||
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const change = (path, value) => {
|
||||
const change = (path: string, value: unknown) => {
|
||||
setForm((prev) => {
|
||||
let next = setPath(prev, path, value);
|
||||
// Recompose the id whenever one of its sources moves. The
|
||||
|
|
@ -235,20 +249,20 @@ export default function EntityEdit() {
|
|||
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = isNew
|
||||
? await post(`/admin/${manifest.key}`, form)
|
||||
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||
? await post<RowResponse>(`/admin/${manifest.key}`, form)
|
||||
: await patch<RowResponse>(`/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 });
|
||||
if (isNew) navigate(`/admin/${manifest.key}/${String(data.row.id)}`, { replace: true });
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
|
||||
|
|
@ -273,9 +287,9 @@ export default function EntityEdit() {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function remove() {
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||
|
||||
try {
|
||||
|
|
@ -291,9 +305,9 @@ export default function EntityEdit() {
|
|||
: "Couldn't delete that.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||
const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value;
|
||||
|
||||
return (
|
||||
<div className="pb-24">
|
||||
|
|
@ -316,8 +330,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} #{form.id}
|
||||
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||
{manifest.idLabel} #{String(form.id)}
|
||||
{updatedAt && <> · last saved {updatedAt}</>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -344,9 +358,9 @@ export default function EntityEdit() {
|
|||
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||
}}
|
||||
/>
|
||||
{!isNew && form.updated_at && (
|
||||
{!isNew && updatedAt && (
|
||||
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||
Last saved {form.updated_at}
|
||||
Last saved {updatedAt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -388,7 +402,7 @@ export default function EntityEdit() {
|
|||
<Repeater
|
||||
key={child.key}
|
||||
spec={child}
|
||||
rows={form[child.key]}
|
||||
rows={form[child.key] as AdminRow[] | undefined}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${child.key}.`}
|
||||
|
|
|
|||
|
|
@ -17,20 +17,25 @@ 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 {
|
||||
ADMIN_ENTITIES,
|
||||
type AdminOptions,
|
||||
type AdminRow,
|
||||
type ListFilter,
|
||||
} from "../../lib/adminSchema.js";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
|
||||
export default function EntityList() {
|
||||
const { entity: entityKey } = useParams();
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [options, setOptions] = useState({});
|
||||
const [rows, setRows] = useState<AdminRow[]>([]);
|
||||
const [options, setOptions] = useState<AdminOptions>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
|
||||
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||
|
|
@ -46,8 +51,8 @@ export default function EntityList() {
|
|||
setError(null);
|
||||
try {
|
||||
const [list, opts] = await Promise.all([
|
||||
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get("/admin/options", { ttl: 60_000 }),
|
||||
get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
|
||||
]);
|
||||
setRows(list.rows);
|
||||
setOptions(opts.options);
|
||||
|
|
@ -67,18 +72,18 @@ export default function EntityList() {
|
|||
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||
}
|
||||
|
||||
function setParam(key, value) {
|
||||
function setParam(key: string, value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
function labelFor(filter, value) {
|
||||
function labelFor(filter: ListFilter): Array<readonly [string, string]> {
|
||||
if (filter.optionsFrom) {
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
|
||||
}
|
||||
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
||||
return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -159,7 +164,7 @@ export default function EntityList() {
|
|||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
key={String(row.id)}
|
||||
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
||||
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
||||
>
|
||||
|
|
@ -174,10 +179,12 @@ export default function EntityList() {
|
|||
? row[column.key]
|
||||
? "Yes"
|
||||
: "—"
|
||||
: row[column.key] || "—"}
|
||||
: row[column.key]
|
||||
? String(row[column.key])
|
||||
: "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@
|
|||
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
import { atLeast, type Role } from "../../lib/roles.ts";
|
||||
import { ADMIN_HOME } from "./adminNav.js";
|
||||
|
||||
export default function RequireRole({ role = "superadmin" }) {
|
||||
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// RequireAuth is already showing its own placeholder above this.
|
||||
|
|
|
|||
45
src/pages/admin/adminNav.d.ts
vendored
Normal file
45
src/pages/admin/adminNav.d.ts
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue