Revert implicit-any typing and convert src/ JavaScript to TypeScript #8

Merged
ngu-git-admin merged 2 commits from types/ts-conversion into main 2026-09-26 22:12:39 +01:00
43 changed files with 195 additions and 135 deletions
Showing only changes of commit 22d5328885 - Show all commits

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. - 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/`. - 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. - `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). - `navConfig.ts` 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. - `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. - 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 ## 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. - **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. - **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. - **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 ## 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. - Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere.
- `reindex: false` prevents cross-entity sort order collisions. - `reindex: false` prevents cross-entity sort order collisions.
- The `OMIT` sentinel distinguishes unsent fields from deliberate clears. - 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 HERE = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH ?? "./dev.db"; const DB_PATH = process.env.DB_PATH ?? "./dev.db";
const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js"; const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.ts";
const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js"; const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.ts";
// The root organization. Every national retreat hangs off this, and // The root organization. Every national retreat hangs off this, and
// it's what makes the org_logo fallback work uniformly. // it's what makes the org_logo fallback work uniformly.

View file

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

View file

@ -1,5 +1,5 @@
import { Link } from "react-router-dom"; 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"; import nguLogo from "../assets/NGU_Logo.svg";
const InstagramIcon = ({ id = "ig-gradient" }) => ( const InstagramIcon = ({ id = "ig-gradient" }) => (

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { NavLink, Link, Outlet, useLocation } from "react-router-dom"; 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 Banner from "./Banner.jsx";
import nguLogo from "../assets/NGU_Logo.svg"; import nguLogo from "../assets/NGU_Logo.svg";
import Footer from "./Footer.tsx"; import Footer from "./Footer.tsx";

View file

@ -29,9 +29,27 @@
/> />
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
const TEAL = "#138ba0"; 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 { const {
id, id,
title, 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 ( return (
<> <>
{/* Page header */} {/* Page header */}

View file

@ -10,7 +10,7 @@ import {
import { Link } from "react-router-dom"; 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 { isBadId, personHref } from "../lib/hrefs.ts";
import "./PeopleTiles.css"; import "./PeopleTiles.css";
@ -316,7 +316,7 @@ function resolveAll(
const { peopleslug, ...overrides } = entry; const { peopleslug, ...overrides } = entry;
const merged: Person = { ...base }; const merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) { 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); resolved.push(merged);
} }

View file

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

View file

@ -11,10 +11,10 @@
Two things live elsewhere: 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. 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 every kind, so a section listing regions and
a section listing partners read alike. a section listing partners read alike.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
@ -25,12 +25,12 @@ import {
areasSentence, areasSentence,
initialsFor, initialsFor,
useOrganizations, useOrganizations,
} from "./organizations.js"; } from "./organizations.ts";
import { import {
areaForChapter, areaForChapter,
buildAreaSlices, buildAreaSlices,
countChaptersByArea, countChaptersByArea,
} from "./mapGrid.js"; } from "./mapGrid.ts";
const byName = (a, b) => a.name.localeCompare(b.name); 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 One request, filtered per section. The Retreats page has three
bands of events, and all three call this hook — the cache in 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. narrows the result to what it shows.
useEvents({ section: "national" }) one band useEvents({ section: "national" }) one band
@ -33,13 +33,13 @@
import { useMemo } from "react"; 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 /* One shared empty list, so a memo keyed on `sections` doesn't
restart on every render before the data arrives. */ 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 { data, error, loading } = useResource("/events");
const all = data?.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 /* Past and upcoming, split. `status` arrives already resolved — the
explicit value when there is one, otherwise derived from ends_on explicit value when there is one, otherwise derived from ends_on
— so nothing here needs to know which of the two it got. */ — so nothing here needs to know which of the two it got. */
export function splitByStatus(events = []) { export function splitByStatus(events: any[] = []) {
const upcoming = []; const upcoming: any[] = [];
const past = []; const past: any[] = [];
for (const event of events) { for (const event of events) {
(event.status === "past" ? past : upcoming).push(event); (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 EVENT_TYPES order rather than whatever order the rows arrived
in. A section with one type has nothing to filter, which is what in. A section with one type has nothing to filter, which is what
lets the chip bar hide itself. */ 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)); const seen = new Set(events.map(e => e.event_type));
return declared.filter(entry => seen.has(entry.id)); 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 }] regionAreas [{ region_id, area_code, share, edge, note }]
regions [{ id, name, color, ... }] 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 regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
const byArea = {}; const byArea: Record<string, any[]> = {};
for (const row of regionAreas) { for (const row of regionAreas) {
const area = AREA_BY_CODE[row.area_code]; const area = AREA_BY_CODE[row.area_code];
@ -151,7 +151,7 @@ export function buildAreaSlices(regionAreas = [], regions = []) {
/* ── Chapter counts per tile ─────────────────────────────────── /* ── Chapter counts per tile ───────────────────────────────────
{ WA: 2, MO: 1, CANADA: 1 } { WA: 2, MO: 1, CANADA: 1 }
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
export function countChaptersByArea(chapters = []) { export function countChaptersByArea(chapters: any[] = []) {
const counts = {}; const counts = {};
for (const chapter of chapters) { for (const chapter of chapters) {
const code = areaForChapter(chapter); 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 from the row rather than being looked up in a splits table and
branched on whether this region is the primary. branched on whether this region is the primary.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
export function areasLabel(regionId, regionAreas = []) { export function areasLabel(regionId, regionAreas: any[] = []) {
return regionAreas return regionAreas
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA") .filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code)) .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 } chapter { region_id, region_name, region_color, meets, started }
partner {} 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 keys them apart and two sections asking for regions share one
fetch. fetch.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { useResource } from "../lib/useResource.js"; import { useResource } from "../lib/useResource.ts";
const EMPTY = { organizations: [] }; const EMPTY = { organizations: [] };
@ -69,7 +69,7 @@ export function orgPath(org) {
any more. any more.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
export function areasSentence(areas = []) { export function areasSentence(areas: any[] = []) {
return areas return areas
.filter((area) => area.area_code !== "CANADA") .filter((area) => area.area_code !== "CANADA")
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code)) .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, organizations,
events, events,
people, people,

View file

@ -14,7 +14,7 @@
import { createContext, useContext, useEffect } from "react"; 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 /* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in 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 } const cache = new Map(); // path → { at, promise }
export class ApiError extends Error { export class ApiError extends Error {
constructor(message, { status, fields } = {}) { status;
fields;
constructor(message, { status, fields }: { status?: number; fields?: any } = {}) {
super(message); super(message);
this.name = "ApiError"; this.name = "ApiError";
this.status = status; 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}`, { const response = await fetch(`${BASE}${path}`, {
headers: { Accept: "application/json", ...options.headers }, headers: { Accept: "application/json", ...options.headers },
...options, ...options,
@ -68,7 +71,7 @@ async function request(path, options = {}) {
get("/events", { fallback }) → fallback on any failure 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); const hit = cache.get(path);
if (hit && Date.now() - hit.at < ttl) return hit.promise; 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 { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom"; 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 }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);

View file

@ -13,7 +13,7 @@
table, so there is one answer to "where does an event live" and table, so there is one answer to "where does an event live" and
changing it is changing EVENT_BASE below. 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. record routes, which never appear in it.
── On missing ids ── ── On missing ids ──

View file

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

View file

@ -106,12 +106,22 @@ export type EventRecord = {
hosts: EventHost[] hosts: EventHost[]
description: string[] description: string[]
links: Link[] links: Link[]
instagram?: Link | null /** The instagram link's label, the handle. See splitLinks in shape.js. */
instagram?: string | null
blocks: ContentBlock[] blocks: ContentBlock[]
people: EventPerson[] people: EventPerson[]
awards: EventAward[] 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> => export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event') useRecord<EventRecord>(detailPath('/events', id), 'event')
@ -157,6 +167,17 @@ export type OrgEvent = {
color?: string | null 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 = { export type OrganizationRecord = {
id: string id: string
kind: 'national' | 'region' | 'chapter' | 'partner' kind: 'national' | 'region' | 'chapter' | 'partner'
@ -176,14 +197,16 @@ export type OrganizationRecord = {
blocks: ContentBlock[] blocks: ContentBlock[]
links: Link[] links: Link[]
socials: Link[] socials: Link[]
website?: Link | null /* splitLinks in shape.js lifts these out as bare strings: the
email?: Link | null website's url, the email's label, the instagram handle. */
instagram?: Link | null website?: string | null
email?: string | null
instagram?: string | null
/** Shape depends on `kind`; empty object for national and partner. */ /** Shape depends on `kind`; empty object for national and partner. */
details: { details: {
scope?: string | null scope?: RegionScope | null
map_note?: string | 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 }> chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
region_id?: string | null region_id?: string | null
region_name?: string | null region_name?: string | null
@ -197,6 +220,14 @@ export type OrganizationRecord = {
events: OrgEvent[] 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> => export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization') useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
@ -214,7 +245,8 @@ export type TeamRecord = {
description: string[] description: string[]
links: Link[] links: Link[]
socials: Link[] socials: Link[]
instagram?: Link | null /** The instagram handle. See splitLinks in shape.js. */
instagram?: string | null
blocks: ContentBlock[] blocks: ContentBlock[]
} }

View file

@ -26,7 +26,7 @@
*/ */
import { useCallback, useEffect, useState } from 'react' 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' import type { TimelineItem } from './timeline'
const PATH = '/history' const PATH = '/history'

View file

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

View file

@ -18,13 +18,13 @@
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { useEffect, useState } from "react"; 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 // Seed with the fallback so the first paint has content when one
// is available, rather than flashing a spinner and then the same // is available, rather than flashing a spinner and then the same
// data a moment later. // data a moment later.
const [state, setState] = useState(() => ({ const [state, setState] = useState<any>(() => ({
data: fallback, data: fallback,
error: null, error: null,
loading: true, loading: true,

View file

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

View file

@ -8,7 +8,7 @@
The roster is not in this page's data. PeopleTiles fetches The roster is not in this page's data. PeopleTiles fetches
/teams/:id/people itself, off v_org_leadership, which already /teams/:id/people itself, off v_org_leadership, which already
decides who counts as current and public. Two requests, both 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' import { Link, useParams } from 'react-router-dom'

View file

@ -20,10 +20,10 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts"; 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"]; const STATUSES = ["new", "read", "actioned", "archived", "spam"];
@ -55,7 +55,7 @@ function locationOf(row) {
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) { function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? ""); const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState<any>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? ""); const noteDirty = note !== (row.admin_note ?? "");
@ -232,11 +232,11 @@ export default function AdminFeedback() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [search, setSearch] = useState(""); // applied, not typed const [search, setSearch] = useState(""); // applied, not typed
const [rows, setRows] = useState([]); const [rows, setRows] = useState<any[]>([]);
const [counts, setCounts] = useState({}); const [counts, setCounts] = useState<any>({});
const [cursor, setCursor] = useState(null); const [cursor, setCursor] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState<any>(null);
// Minimums, not equality — see lib/roles.ts. // Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user); const canWrite = roleCanWrite(user);
@ -298,7 +298,7 @@ export default function AdminFeedback() {
})); }));
} }
const tabs = [ const tabs: any[] = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })), ...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 the header deliberately drops its tab row here so the same links
aren't drawn twice — and a standing info panel on the right. 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. reads, so a new entity shows up here the moment it's registered.
Forms sit in their own block below: they're submissions coming Forms sit in their own block below: they're submissions coming
in rather than content going out, and there'll be more of them 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 { useAuth } from "../../lib/auth.tsx";
import { SITE_VERSION } from "../../lib/version.ts"; import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.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 /* One card. The title link is stretched over the whole card with
`after:absolute`, which makes the card clickable without nesting `after:absolute`, which makes the card clickable without nesting

View file

@ -30,7 +30,7 @@ import {
matches, matches,
navFor, navFor,
target, target,
} from "./adminNav.js"; } from "./adminNav.ts";
export default function AdminLayout() { export default function AdminLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -56,7 +56,7 @@ export default function AdminLayout() {
// What the page below has published about itself — a record // What the page below has published about itself — a record
// name, or null on a list. setDetail is stable so publishing // name, or null on a list. setDetail is stable so publishing
// can't loop. // can't loop.
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState<any>(null);
const stableSet = useCallback((value) => setDetail(value), []); const stableSet = useCallback((value) => setDetail(value), []);
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]); const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);

View file

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

View file

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

View file

@ -43,10 +43,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; 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 { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useAdminDetail } from "../../lib/adminTitle.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 { atLeast } from "../../lib/roles.ts";
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx"; import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
@ -62,7 +62,7 @@ function friendly(message, singular) {
export default function EntityEdit() { export default function EntityEdit() {
const { entity: entityKey, id } = useParams(); const { entity: entityKey, id } = useParams();
const manifest = ADMIN_ENTITIES[entityKey]; const manifest = ADMIN_ENTITIES[entityKey ?? ""];
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
@ -120,17 +120,17 @@ export default function EntityEdit() {
// names the field to read instead. // names the field to read instead.
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom; const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
const [form, setForm] = useState(null); const [form, setForm] = useState<any>(null);
const [options, setOptions] = useState({}); const [options, setOptions] = useState<any>({});
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState<any>({});
const [message, setMessage] = useState(null); const [message, setMessage] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [slugTouched, setSlugTouched] = useState(false); const [slugTouched, setSlugTouched] = useState(false);
// The last state the server confirmed. Everything else compares // The last state the server confirmed. Everything else compares
// against this to decide whether there's anything to lose. // against this to decide whether there's anything to lose.
const baseline = useRef(null); const baseline = useRef<any>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!manifest) return; if (!manifest) return;

View file

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

View file

@ -18,10 +18,10 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx"; 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"; import { ADMIN_HOME } from "./adminNav.ts";
export default function RequireRole({ role = "superadmin" }) { export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
// RequireAuth is already showing its own placeholder above this. // 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 Lifted out of AdminLayout because two things read it now: the
header tabs and the card grid on the home page. Adding an entity header tabs and the card grid on the home page. Adding an entity
@ -99,7 +99,7 @@ export const PANEL_NAV = {
superOnly: true, 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. */ /* What this user may see. Call it with the user from useAuth. */
export function navFor(user) { export function navFor(user) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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