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:
Zaldimmar 2026-09-25 04:13:46 -05:00
parent 5286cae9ca
commit 2428f4412a
39 changed files with 1318 additions and 343 deletions

View file

@ -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