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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue