Convert src/ JavaScript modules to TypeScript

Renames every .js module under src/ to .ts (api, useResource,
adminSchema, navConfig, adminNav and src/data/*) and points imports,
comments and the seed script's module paths at the new names. Their
types now come from inference; no .d.ts files and no shape
interfaces.

tsc stays strict (noImplicitAny off). The errors inference leaves
behind get the lightest fix that clears them: `any` on empty state,
contexts and list defaults, `: any` on components with optional or
spread props, class fields on ApiError, and option shapes on
get/useResource.

Kept as real types, since they belong to modules that were already
TypeScript and later features import them: PageShell's ShellSection
and props, useContent's corrected record types (website/email/
instagram are bare strings) and EventListItem, and TimelineRef's
orgKind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-26 15:39:42 -05:00
parent 5cedc68fd7
commit 22d5328885
43 changed files with 195 additions and 135 deletions

View file

@ -51,18 +51,18 @@ In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API mu
- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise.
- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`.
- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite.
- `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages).
- `api.js` is the shared caching client used by frontend data hooks.
- `navConfig.ts` is the single source of truth for navigation, routes, and actions (header, footer, pages).
- `api.ts` is the shared caching client used by frontend data hooks.
- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `<Logo>` component hides itself on load error.
## Rules and gotchas
- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs.
- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere.
- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names.
- **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface.
- **Don't use `fallback: EMPTY` in api.ts hooks.** It silently returns empty arrays and hides server errors; let the error state surface.
## Admin CRUD engine
Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.js` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code.
Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.ts` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code.
- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere.
- `reindex: false` prevents cross-entity sort order collisions.
- The `OMIT` sentinel distinguishes unsent fields from deliberate clears.

View file

@ -42,8 +42,8 @@ import { openDatabase, migrate, tx } from "./db.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH ?? "./dev.db";
const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js";
const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js";
const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.ts";
const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.ts";
// The root organization. Every national retreat hangs off this, and
// it's what makes the org_logo fallback work uniformly.

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { SITE_BANNER } from "../data/bannerConfig.js";
import { SITE_BANNER } from "../data/bannerConfig.ts";
export default function Banner() {
const [visible, setVisible] = useState(false);

View file

@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts";
import nguLogo from "../assets/NGU_Logo.svg";
const InstagramIcon = ({ id = "ig-gradient" }) => (

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts";
import Banner from "./Banner.jsx";
import nguLogo from "../assets/NGU_Logo.svg";
import Footer from "./Footer.tsx";

View file

@ -29,9 +29,27 @@
/>
═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
const TEAL = "#138ba0";
export function Section({ section }) {
export type ShellSection = {
id: string;
title: string;
blurb?: string;
accent: string;
background: string;
actions?: ReactNode;
content: ReactNode;
};
type PageShellProps = {
title: ReactNode;
intro?: ReactNode;
sections: ShellSection[];
};
export function Section({ section }: { section: ShellSection }) {
const {
id,
title,
@ -70,7 +88,7 @@ export function Section({ section }) {
);
}
export default function PageShell({ title, intro, sections }) {
export default function PageShell({ title, intro, sections }: PageShellProps) {
return (
<>
{/* Page header */}

View file

@ -10,7 +10,7 @@ import {
import { Link } from "react-router-dom";
import { get } from "../lib/api.js";
import { get } from "../lib/api.ts";
import { isBadId, personHref } from "../lib/hrefs.ts";
import "./PeopleTiles.css";
@ -316,7 +316,7 @@ function resolveAll(
const { peopleslug, ...overrides } = entry;
const merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
if (value !== undefined) (merged as any)[key] = value;
}
resolved.push(merged);
}

View file

@ -71,12 +71,12 @@ export function setPath(object, path, value) {
/* ── Field ───────────────────────────────────────────────────── */
export function Field({ field, value, row, options, error, onChange }) {
export function Field({ field, value, row, options, error, onChange }: any) {
const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly);
let list = null;
let list: any = null;
let orphaned = false;
if (widget === "select") {
@ -236,9 +236,9 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
// Which row is in flight, and which one it's currently over.
// Both are per-Repeater, which is what keeps a drag inside a
// nested collection from being accepted by the outer one.
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const rowRefs = useRef([]);
const [dragIndex, setDragIndex] = useState<any>(null);
const [overIndex, setOverIndex] = useState<any>(null);
const rowRefs = useRef<any[]>([]);
const update = (index, next) =>
onChange(list.map((row, i) => (i === index ? next : row)));
@ -400,7 +400,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
);
}
function IconButton({ label, onClick, danger, disabled, children }) {
function IconButton({ label, onClick, danger = false, disabled = false, children }) {
return (
<button
type="button"

View file

@ -11,10 +11,10 @@
Two things live elsewhere:
the tile grid src/data/mapGrid.js. Where a state sits never
the tile grid src/data/mapGrid.ts. Where a state sits never
changes, so it isn't worth a round trip.
the fetch src/data/organizations.js. One endpoint for
the fetch src/data/organizations.ts. One endpoint for
every kind, so a section listing regions and
a section listing partners read alike.
═══════════════════════════════════════════════════════════════ */
@ -25,12 +25,12 @@ import {
areasSentence,
initialsFor,
useOrganizations,
} from "./organizations.js";
} from "./organizations.ts";
import {
areaForChapter,
buildAreaSlices,
countChaptersByArea,
} from "./mapGrid.js";
} from "./mapGrid.ts";
const byName = (a, b) => a.name.localeCompare(b.name);

View file

@ -3,7 +3,7 @@
One request, filtered per section. The Retreats page has three
bands of events, and all three call this hook — the cache in
api.js keys on the path, so they share a single fetch and each
api.ts keys on the path, so they share a single fetch and each
narrows the result to what it shows.
useEvents({ section: "national" }) one band
@ -33,13 +33,13 @@
import { useMemo } from "react";
import { useResource } from "../lib/useResource.js";
import { useResource } from "../lib/useResource.ts";
/* One shared empty list, so a memo keyed on `sections` doesn't
restart on every render before the data arrives. */
const NO_SECTIONS = [];
const NO_SECTIONS: any[] = [];
export function useEvents({ section, host, status, type } = {}) {
export function useEvents({ section, host, status, type }: any = {}) {
const { data, error, loading } = useResource("/events");
const all = data?.events;
@ -71,9 +71,9 @@ export function useEvents({ section, host, status, type } = {}) {
/* Past and upcoming, split. `status` arrives already resolved — the
explicit value when there is one, otherwise derived from ends_on
— so nothing here needs to know which of the two it got. */
export function splitByStatus(events = []) {
const upcoming = [];
const past = [];
export function splitByStatus(events: any[] = []) {
const upcoming: any[] = [];
const past: any[] = [];
for (const event of events) {
(event.status === "past" ? past : upcoming).push(event);
@ -86,7 +86,7 @@ export function splitByStatus(events = []) {
EVENT_TYPES order rather than whatever order the rows arrived
in. A section with one type has nothing to filter, which is what
lets the chip bar hide itself. */
export function typesPresent(events = [], declared = []) {
export function typesPresent(events: any[] = [], declared: any[] = []) {
const seen = new Set(events.map(e => e.event_type));
return declared.filter(entry => seen.has(entry.id));
}

View file

@ -121,9 +121,9 @@ export function areaForChapter(chapter) {
regionAreas [{ region_id, area_code, share, edge, note }]
regions [{ id, name, color, ... }]
───────────────────────────────────────────────────────────── */
export function buildAreaSlices(regionAreas = [], regions = []) {
export function buildAreaSlices(regionAreas: any[] = [], regions: any[] = []) {
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
const byArea = {};
const byArea: Record<string, any[]> = {};
for (const row of regionAreas) {
const area = AREA_BY_CODE[row.area_code];
@ -151,7 +151,7 @@ export function buildAreaSlices(regionAreas = [], regions = []) {
/* ── Chapter counts per tile ───────────────────────────────────
{ WA: 2, MO: 1, CANADA: 1 }
───────────────────────────────────────────────────────────── */
export function countChaptersByArea(chapters = []) {
export function countChaptersByArea(chapters: any[] = []) {
const counts = {};
for (const chapter of chapters) {
const code = areaForChapter(chapter);
@ -165,7 +165,7 @@ export function countChaptersByArea(chapters = []) {
from the row rather than being looked up in a splits table and
branched on whether this region is the primary.
───────────────────────────────────────────────────────────── */
export function areasLabel(regionId, regionAreas = []) {
export function areasLabel(regionId, regionAreas: any[] = []) {
return regionAreas
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))

View file

@ -18,12 +18,12 @@
chapter { region_id, region_name, region_color, meets, started }
partner {}
Each kind is a separate request path, so the cache in api.js
Each kind is a separate request path, so the cache in api.ts
keys them apart and two sections asking for regions share one
fetch.
═══════════════════════════════════════════════════════════════ */
import { useResource } from "../lib/useResource.js";
import { useResource } from "../lib/useResource.ts";
const EMPTY = { organizations: [] };
@ -69,7 +69,7 @@ export function orgPath(org) {
any more.
───────────────────────────────────────────────────────────── */
export function areasSentence(areas = []) {
export function areasSentence(areas: any[] = []) {
return areas
.filter((area) => area.area_code !== "CANADA")
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))

View file

@ -1128,7 +1128,7 @@ const frontPage = {
],
};
export const ADMIN_ENTITIES = {
export const ADMIN_ENTITIES: Record<string, any> = {
organizations,
events,
people,

View file

@ -14,7 +14,7 @@
import { createContext, useContext, useEffect } from "react";
export const AdminTitleContext = createContext(null);
export const AdminTitleContext = createContext<any>(null);
/* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in

View file

@ -25,7 +25,10 @@ const DEFAULT_TTL = 60_000;
const cache = new Map(); // path → { at, promise }
export class ApiError extends Error {
constructor(message, { status, fields } = {}) {
status;
fields;
constructor(message, { status, fields }: { status?: number; fields?: any } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
@ -33,7 +36,7 @@ export class ApiError extends Error {
}
}
async function request(path, options = {}) {
async function request(path, options: RequestInit = {}) {
const response = await fetch(`${BASE}${path}`, {
headers: { Accept: "application/json", ...options.headers },
...options,
@ -68,7 +71,7 @@ async function request(path, options = {}) {
get("/events", { fallback }) → fallback on any failure
───────────────────────────────────────────────────────────── */
export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
export function get(path, { ttl = DEFAULT_TTL, fallback }: { ttl?: number; fallback?: any } = {}) {
const hit = cache.get(path);
if (hit && Date.now() - hit.at < ttl) return hit.promise;

View file

@ -16,9 +16,9 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { get, post, ApiError } from "./api.js";
import { get, post, ApiError } from "./api.ts";
const AuthContext = createContext(null);
const AuthContext = createContext<any>(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);

View file

@ -13,7 +13,7 @@
table, so there is one answer to "where does an event live" and
changing it is changing EVENT_BASE below.
navConfig.js stays the source of truth for the *nav*: these are
navConfig.ts stays the source of truth for the *nav*: these are
record routes, which never appear in it.
── On missing ids ──

View file

@ -23,6 +23,8 @@
* and the public/ layout stay the frontend's business.
*/
import type { OrgKind } from './hrefs.ts'
export type DatePrecision = 'year' | 'month' | 'day'
/** What an entry is about. Drives the marker and the body layout. */
@ -41,6 +43,8 @@ export type TimelineRef = {
kind: RefKind
/** The row's TEXT primary key — an event id, org slug, team slug. */
id: string
/** Only on organizations: history.js copies v_timeline.org_kind. */
orgKind?: OrgKind
}
/** Filename plus the table it came from; the directory is derived

View file

@ -106,12 +106,22 @@ export type EventRecord = {
hosts: EventHost[]
description: string[]
links: Link[]
instagram?: Link | null
/** The instagram link's label, the handle. See splitLinks in shape.js. */
instagram?: string | null
blocks: ContentBlock[]
people: EventPerson[]
awards: EventAward[]
}
/** One row of GET /events: shapeEvent without the detail-only
* blocks, people and awards. */
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
/** An event_sections row, as GET /events sends it beside the list. */
export type EventSection = { id: string; name: string; sort_order: number }
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event')
@ -157,6 +167,17 @@ export type OrgEvent = {
color?: string | null
}
/** regions.scope's CHECK values. */
export type RegionScope = 'domestic' | 'international' | 'virtual'
/** A region_areas row as attachRegionDetails sends it. */
export type RegionArea = {
area_code: string
share: number
edge: 'top' | 'bottom' | null
note: string | null
}
export type OrganizationRecord = {
id: string
kind: 'national' | 'region' | 'chapter' | 'partner'
@ -176,14 +197,16 @@ export type OrganizationRecord = {
blocks: ContentBlock[]
links: Link[]
socials: Link[]
website?: Link | null
email?: Link | null
instagram?: Link | null
/* splitLinks in shape.js lifts these out as bare strings: the
website's url, the email's label, the instagram handle. */
website?: string | null
email?: string | null
instagram?: string | null
/** Shape depends on `kind`; empty object for national and partner. */
details: {
scope?: string | null
scope?: RegionScope | null
map_note?: string | null
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
areas?: RegionArea[]
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
region_id?: string | null
region_name?: string | null
@ -197,6 +220,14 @@ export type OrganizationRecord = {
events: OrgEvent[]
}
/** One row of GET /organizations: the card surface and details,
* without the sections only the org's own page loads. */
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
sort_order: number
}
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
@ -214,7 +245,8 @@ export type TeamRecord = {
description: string[]
links: Link[]
socials: Link[]
instagram?: Link | null
/** The instagram handle. See splitLinks in shape.js. */
instagram?: string | null
blocks: ContentBlock[]
}

View file

@ -26,7 +26,7 @@
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
import { get, invalidate, ApiError } from './api.ts'
import type { TimelineItem } from './timeline'
const PATH = '/history'

View file

@ -3,7 +3,7 @@
* pattern.
*
* Named for what it returns, and deliberately not useResource:
* src/lib/useResource.js is a different hook — it hands back the
* src/lib/useResource.ts is a different hook — it hands back the
* whole response body and takes an options object — and a .ts file
* of the same name would sit one extension away from it. An import
* whose target goes missing then resolves to the other file without
@ -26,7 +26,7 @@
* · `reload` invalidates before it refetches. Without that, the
* retry button inside the 60s TTL hands back the same settled
* promise and looks like it did nothing. A *failed* request is
* already evicted by api.js, so this matters for the refresh
* already evicted by api.ts, so this matters for the refresh
* case rather than the error case.
*
* · no AbortController. `get` shares one promise between callers,
@ -47,7 +47,7 @@
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
import { get, invalidate, ApiError } from './api.ts'
export type Resource<T> = {
data: T | null

View file

@ -18,13 +18,13 @@
═══════════════════════════════════════════════════════════════ */
import { useEffect, useState } from "react";
import { get } from "./api.js";
import { get } from "./api.ts";
export function useResource(path, { ttl, fallback } = {}) {
export function useResource(path, { ttl, fallback }: { ttl?: number; fallback?: any } = {}) {
// Seed with the fallback so the first paint has content when one
// is available, rather than flashing a spinner and then the same
// data a moment later.
const [state, setState] = useState(() => ({
const [state, setState] = useState<any>(() => ({
data: fallback,
error: null,
loading: true,

View file

@ -18,7 +18,7 @@
import { Link, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
@ -86,7 +86,7 @@ export default function EventDetail() {
const accent = event.color || TEAL
const groups = peopleGroups(event.people, accent)
const sections = [
const sections: ShellSection[] = [
{
id: 'about',
title: event.theme || 'About',

View file

@ -8,7 +8,7 @@
The roster is not in this page's data. PeopleTiles fetches
/teams/:id/people itself, off v_org_leadership, which already
decides who counts as current and public. Two requests, both
cached for 60s by api.js, and one set of visibility rules.
cached for 60s by api.ts, and one set of visibility rules.
═══════════════════════════════════════════════════════════════ */
import { Link, useParams } from 'react-router-dom'

View file

@ -20,10 +20,10 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { del, get, patch, ApiError } from "../../lib/api.js";
import { del, get, patch, ApiError } from "../../lib/api.ts";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
import { feedbackTypeLabel } from "../../data/feedbackTypes.ts";
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
@ -55,7 +55,7 @@ function locationOf(row) {
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [error, setError] = useState<any>(null);
const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? "");
@ -232,11 +232,11 @@ export default function AdminFeedback() {
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<any[]>([]);
const [counts, setCounts] = useState<any>({});
const [cursor, setCursor] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [error, setError] = useState<any>(null);
// Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user);
@ -298,7 +298,7 @@ export default function AdminFeedback() {
}));
}
const tabs = [
const tabs: any[] = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
];

View file

@ -5,7 +5,7 @@
the header deliberately drops its tab row here so the same links
aren't drawn twice — and a standing info panel on the right.
The cards come from adminNav.js, the same list the CMS header
The cards come from adminNav.ts, the same list the CMS header
reads, so a new entity shows up here the moment it's registered.
Forms sit in their own block below: they're submissions coming
in rather than content going out, and there'll be more of them
@ -22,7 +22,7 @@ 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 } from "./adminNav.ts";
/* One card. The title link is stretched over the whole card with
`after:absolute`, which makes the card clickable without nesting

View file

@ -30,7 +30,7 @@ import {
matches,
navFor,
target,
} from "./adminNav.js";
} from "./adminNav.ts";
export default function AdminLayout() {
const { user, logout } = useAuth();
@ -56,7 +56,7 @@ 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 [detail, setDetail] = useState<any>(null);
const stableSet = useCallback((value) => setDetail(value), []);
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);

View file

@ -14,7 +14,7 @@ import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { ApiError } from "../../lib/api.js";
import { ApiError } from "../../lib/api.ts";
const GOOGLE_ENABLED = false;
@ -29,7 +29,7 @@ export default function AdminLogin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [error, setError] = useState<any>(null);
const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/home";

View file

@ -25,7 +25,7 @@
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { del, get, patch } from "../../lib/api.js";
import { del, get, patch } from "../../lib/api.ts";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useNavigate } from "react-router-dom";
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
@ -50,7 +50,7 @@ function uptime(seconds) {
return `${m}m`;
}
function Block({ title, note, children }) {
function Block({ title, note, children }: any) {
return (
<section className="mt-8 first:mt-0">
<div className="flex items-baseline gap-3">
@ -77,21 +77,21 @@ export default function AdminPanel() {
const { user: me } = useAuth();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [error, setError] = useState<any>(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<any>(null);
const [rowError, setRowError] = useState<any>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await get("/admin/panel/overview", { ttl: 0 }));
} catch (err) {
} catch (err: any) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError(err.message || "Couldn't load the panel.");
} finally {
@ -121,7 +121,7 @@ export default function AdminPanel() {
setRowError(null);
try {
mergeUser(await work());
} catch (err) {
} catch (err: any) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setRowError({ id, message: err.message || "That didn't work." });
} finally {

View file

@ -43,10 +43,10 @@
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 } from "../../lib/api.ts";
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 } from "../../lib/adminSchema.ts";
import { atLeast } from "../../lib/roles.ts";
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
@ -62,7 +62,7 @@ function friendly(message, singular) {
export default function EntityEdit() {
const { entity: entityKey, id } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const manifest = ADMIN_ENTITIES[entityKey ?? ""];
const navigate = useNavigate();
const { user } = useAuth();
@ -120,17 +120,17 @@ 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);
const [form, setForm] = useState<any>(null);
const [options, setOptions] = useState<any>({});
const [errors, setErrors] = useState<any>({});
const [message, setMessage] = useState<any>(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<any>(null);
const load = useCallback(async () => {
if (!manifest) return;

View file

@ -15,22 +15,22 @@
import { useCallback, useEffect, useState } from "react";
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { get, ApiError } from "../../lib/api.js";
import { get, ApiError } from "../../lib/api.ts";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.ts";
import { atLeast } from "../../lib/roles.ts";
export default function EntityList() {
const { entity: entityKey } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const manifest = ADMIN_ENTITIES[entityKey ?? ""];
const navigate = useNavigate();
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const [rows, setRows] = useState([]);
const [options, setOptions] = useState({});
const [rows, setRows] = useState<any[]>([]);
const [options, setOptions] = useState<any>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [error, setError] = useState<any>(null);
const [query, setQuery] = useState(params.get("q") ?? "");
// Minimum rank, never equality. POST /api/admin/:entity is gated
@ -79,7 +79,7 @@ export default function EntityList() {
setParams(next, { replace: true });
}
function labelFor(filter, value) {
function labelFor(filter) {
if (filter.optionsFrom) {
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
}

View file

@ -18,10 +18,10 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { atLeast } from "../../lib/roles.ts";
import { ADMIN_HOME } from "./adminNav.js";
import { atLeast, type Role } from "../../lib/roles.ts";
import { ADMIN_HOME } from "./adminNav.ts";
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.

View file

@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN NAVIGATION — src/pages/admin/adminNav.js
ADMIN NAVIGATION — src/pages/admin/adminNav.ts
Lifted out of AdminLayout because two things read it now: the
header tabs and the card grid on the home page. Adding an entity
@ -99,7 +99,7 @@ export const PANEL_NAV = {
superOnly: true,
};
export const NAV = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
export const NAV: any[] = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
/* What this user may see. Call it with the user from useAuth. */
export function navFor(user) {

View file

@ -42,9 +42,8 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { typesPresent, useEvents } from '../../data/eventData.js'
import type { EventFilter } from '../../data/eventData.js'
import { EVENT_TYPES, eventTypeLabel } from '../../lib/eventTypes.ts'
import { typesPresent, useEvents } from '../../data/eventData.ts'
import { EVENT_TYPES, eventTypeLabel, type EventType } from '../../lib/eventTypes.ts'
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
import { eventHref } from '../../lib/hrefs.ts'
import type { EventListItem } from '../../lib/useContent.ts'
@ -63,7 +62,11 @@ const BODY = '#4a6b72'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
type EventCalendarProps = EventFilter & {
type EventCalendarProps = {
section?: string
host?: string
status?: EventListItem['status']
type?: EventType | EventType[]
accent?: string
/** Which visitor controls render. A pinned prop hides its own. */
controls?: CalendarControl[]

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.ts";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
import { seriesLabel } from "../../lib/eventSeries.ts";
@ -454,7 +454,7 @@ export default function EventListCards({
accent = TEAL,
defaultColor,
empty = "· Events coming soon, stay connected for announcements ·",
}) {
}: any) {
const { events: fetched, loading, error } = useEvents({
section,
host,

View file

@ -12,9 +12,9 @@
═══════════════════════════════════════════════════════════════ */
import { useState } from "react";
import { post, ApiError } from "../../lib/api.js";
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
import { post, ApiError } from "../../lib/api.ts";
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.ts";
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.ts";
const ACCENT = "#138ba0";
const MUTED = "#4a6b72";
@ -217,7 +217,7 @@ function describeLocation(page, section) {
/* ── The form ────────────────────────────────────────────────── */
export default function FeedbackForm() {
const [type, setType] = useState(null);
const [type, setType] = useState<any>(null);
const [page, setPage] = useState(SITE_WIDE);
const [section, setSection] = useState(WHOLE_PAGE);
const [message, setMessage] = useState("");
@ -229,8 +229,8 @@ export default function FeedbackForm() {
// idle → sending → sent, or back to idle with an error to show.
const [status, setStatus] = useState("idle");
const [formError, setFormError] = useState(null);
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState<any>(null);
const [fieldErrors, setFieldErrors] = useState<any>({});
const sending = status === "sending";
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;

View file

@ -4,7 +4,7 @@ import {
initialsFor,
orgPath,
useOrganizations,
} from "../../data/organizations.js";
} from "../../data/organizations.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — CARDS

View file

@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useCommunity } from "../../data/chapters.js";
import { useCommunity } from "../../data/chapters.ts";
import { Link } from "react-router-dom";
import ArrowLink from "../../components/ArrowLink.tsx";
import { initialsFor, orgPath } from "../../data/organizations.js";
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
import { initialsFor, orgPath } from "../../data/organizations.ts";
import { AREAS, AREA_NAMES } from "../../data/mapGrid.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — MAP
@ -25,7 +25,7 @@ import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
two colors. If you later want true geography, the swap point is
<RegionMap> — everything else works off the data.
The tile layout comes from mapGrid.js; who paints what comes
The tile layout comes from mapGrid.ts; who paints what comes
from the API. A state and the Canada band are the same shape
now, a tile with a span, so the map is one loop.
═══════════════════════════════════════════════════════════════ */
@ -59,7 +59,7 @@ const INTL_TITLE = "International Unity Regions";
vanishing. When organization and person pages arrive this should
move to a shared component; small enough to live here until then.
───────────────────────────────────────────────────────────── */
function Blocks({ blocks = [], color }) {
function Blocks({ blocks = [] as any[], color }) {
if (blocks.length === 0) return null;
return (
@ -145,7 +145,7 @@ function Tile({
size,
width = size,
label,
slices = [],
slices = [] as any[],
count = 0,
selected,
hovered,
@ -248,7 +248,7 @@ function Tile({
);
}
function RegionMap({ slices, chapterCounts, ...props }) {
function RegionMap({ slices, chapterCounts, ...props }: any) {
const size = TILE - PAD * 2;
return (
@ -306,7 +306,7 @@ function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
);
}
function Legend({ domestic, international, onMapIds, ...props }) {
function Legend({ domestic, international, onMapIds, ...props }: any) {
const { selected, setSelected } = props;
// A region is on the map if it paints a tile. West Central used
@ -615,7 +615,7 @@ function ChapterDetail({ chapter, region, onClose }) {
<div className="mt-5 flex flex-wrap gap-3">
{orgPath(chapter) && (
<Link
to={orgPath(chapter)}
to={orgPath(chapter)!}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ background: colorOf(region), color: "#ffffff" }}
>
@ -769,14 +769,14 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
subtextFor,
} = useCommunity();
const [selected, setSelected] = useState(null);
const [hovered, setHovered] = useState(null);
const [openId, setOpenId] = useState(null); // no card open on arrival
const [selected, setSelected] = useState<any>(null);
const [hovered, setHovered] = useState<any>(null);
const [openId, setOpenId] = useState<any>(null); // no card open on arrival
// The list scrolls itself to whatever the map or legend points at.
const listRef = useRef(null);
const regionRefs = useRef({});
const chapterRefs = useRef({});
const listRef = useRef<any>(null);
const regionRefs = useRef<any>({});
const chapterRefs = useRef<any>({});
const scrollListTo = el => {
const box = listRef.current;

View file

@ -5,7 +5,7 @@ import {
initialsFor,
orgPath,
useOrganizations,
} from "../../data/organizations.js";
} from "../../data/organizations.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — VERTICAL
@ -47,7 +47,7 @@ const FALLBACK_COLOR = "#4a6b72";
it — a link nested in a button is invalid, and a screen reader
announces the whole row as one confused control.
───────────────────────────────────────────────────────────── */
function RowButton({ as: As = "button", color, children, ...rest }) {
function RowButton({ as: As = "button", color, children, ...rest }: any) {
return (
<As
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"