Merge pull request 'Revert implicit-any typing and convert src/ JavaScript to TypeScript' (#8) from types/ts-conversion into main

Reviewed-on: #8
This commit is contained in:
ngu-git-admin 2026-09-26 22:12:38 +01:00
commit cbf440bed9
54 changed files with 400 additions and 1302 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

@ -4,15 +4,7 @@ import { Link } from "react-router-dom";
ARROW LINK
═══════════════════════════════════════════════════════════════ */
type ArrowLinkProps = {
to: string;
label: string;
color: string;
/** Tailwind size classes for the circle. */
size?: string;
};
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) {
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
return (
<Link
to={to}

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";
@ -117,7 +117,7 @@ export default function Layout() {
const targets = sections
.map((s) => document.querySelector(s.hash))
.filter((el): el is Element => el !== null);
.filter(Boolean);
if (targets.length === 0) return;
const observer = new IntersectionObserver(

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";
@ -174,7 +174,7 @@ export default function PeopleTiles({
Promise.all(
specs.map((spec) =>
get<TeamResponse>(`/teams/${spec.id}/people`, { ttl }).then((data) => ({
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
spec,
data,
})),
@ -208,8 +208,8 @@ export default function PeopleTiles({
let live = true;
setFailed(false);
get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data) => {
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data: { people: Person[] }) => {
if (!live) return;
const byId: Record<string, Person> = {};
for (const person of data.people) byId[String(person.id)] = person;
@ -314,10 +314,11 @@ function resolveAll(
}
const { peopleslug, ...overrides } = entry;
const defined: Partial<Person> = Object.fromEntries(
Object.entries(overrides).filter(([, value]) => value !== undefined),
);
resolved.push({ ...base, ...defined });
const merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) (merged as any)[key] = value;
}
resolved.push(merged);
}
return resolved;

View file

@ -32,16 +32,7 @@
able to read and copy.
═══════════════════════════════════════════════════════════════ */
import { useRef, useState, type ChangeEvent, type ReactNode } from "react";
import type { FieldErrors } from "../../lib/api.js";
import type {
AdminFieldSpec,
AdminOption,
AdminOptions,
AdminRow,
CollectionSpec,
} from "../../lib/adminSchema.js";
import { useRef, useState } from "react";
const input =
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
@ -65,56 +56,37 @@ const inputLocked =
/* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object: unknown, path: string | null | undefined): unknown {
export function getPath(object, path) {
// An entity with no slug has no heading path either, and a missing
// path should read as "no value" rather than throwing on .split.
if (!path) return undefined;
return path
.split(".")
.reduce<unknown>((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object);
return path.split(".").reduce((value, key) => value?.[key], object);
}
export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow {
export function setPath(object, path, value) {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value };
const inner = (object?.[head] ?? {}) as AdminRow;
return { ...object, [head]: setPath(inner, rest.join("."), value) };
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
}
/* ── Field ───────────────────────────────────────────────────── */
/* [value, label, the option row it came from]. Manifest options
have no row, which is what filterBy's `!raw` lets through. */
type Choice = [id: string, label: string, raw?: AdminOption];
type FieldProps = {
field: AdminFieldSpec;
/* Whatever the row holds at field.path; shown as text. */
value: unknown;
row?: AdminRow;
options?: AdminOptions | null;
error?: string;
onChange: (value: string | number) => void;
};
export function Field({ field, value, row, options, error, onChange }: FieldProps) {
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);
const text = value == null ? "" : String(value);
let list: Choice[] = [];
let list: any = null;
let orphaned = false;
if (widget === "select") {
list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o])
: (field.options ?? []).map((o): Choice =>
typeof o === "string" ? [o, o] : [o[0], o[1]],
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
: (field.options ?? []).map((o) =>
Array.isArray(o) ? [o[0], o[1]] : [o, o],
);
const { filterBy } = field;
if (filterBy && row) {
list = list.filter(([, , raw]) => !raw || filterBy(raw, row));
if (field.filterBy && row) {
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
}
// A stored value with no matching option renders as the blank
@ -130,9 +102,8 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
const common = {
id,
className: `${input} ${error ? inputError : ""}`,
value: text,
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
onChange(e.target.value),
value: value ?? "",
onChange: (e) => onChange(e.target.value),
};
return (
@ -174,7 +145,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
}`}
>
<option value="">{field.blankLabel ?? "— choose —"}</option>
{orphaned && <option value={text}>{text} — no longer exists</option>}
{orphaned && <option value={value}>{value} — no longer exists</option>}
{list.map(([id2, label]) => (
<option key={id2} value={id2}>
{label}
@ -185,7 +156,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
<div className="flex gap-2">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#138ba0"}
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
@ -217,7 +188,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
<input
id={id}
type="text"
value={text}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
@ -253,36 +224,26 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
);
}
export function FieldGrid({ children }: { children: ReactNode }) {
export function FieldGrid({ children }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
}
/* ── Repeater ────────────────────────────────────────────────── */
type RepeaterProps = {
spec: CollectionSpec;
rows: AdminRow[] | null | undefined;
options?: AdminOptions | null;
errors?: Partial<FieldErrors> | null;
/** Where this collection's rows sit in the server's error keys. */
errorPrefix: string;
onChange: (rows: AdminRow[]) => void;
};
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }: RepeaterProps) {
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
const list = rows ?? [];
// 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<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const [dragIndex, setDragIndex] = useState<any>(null);
const [overIndex, setOverIndex] = useState<any>(null);
const rowRefs = useRef<any[]>([]);
const update = (index: number, next: AdminRow) =>
const update = (index, next) =>
onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index: number, delta: number) => {
const move = (index, delta) => {
const target = index + delta;
if (target < 0 || target >= list.length) return;
const next = [...list];
@ -290,7 +251,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
onChange(next);
};
const relocate = (from: number | null, to: number | null) => {
const relocate = (from, to) => {
if (from === to || from == null || to == null) return;
const next = [...list];
const [moved] = next.splice(from, 1);
@ -423,7 +384,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
<Repeater
spec={nested}
rows={row[nested.key] as AdminRow[] | undefined}
rows={row[nested.key]}
options={options}
errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
@ -439,15 +400,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
);
}
type IconButtonProps = {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
children: ReactNode;
};
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
function IconButton({ label, onClick, danger = false, disabled = false, children }) {
return (
<button
type="button"

View file

@ -1,10 +0,0 @@
/* Types for bannerConfig.js. */
export type BannerPart = { text: string; href?: string; external?: boolean };
export declare const SITE_BANNER: {
enabled: boolean;
id: string;
content: BannerPart[];
dismissible: boolean;
};

View file

@ -1,47 +0,0 @@
/* Types for chapters.js: GET /organizations rows with their
kind-specific details lifted to the top level. */
import type { OrganizationListItem, RegionArea, RegionScope } from "../lib/useContent.ts";
import type { AreaSlice, RegionAreaRow } from "./mapGrid.js";
export { initialsFor } from "./organizations.js";
export type Region = OrganizationListItem & {
scope: RegionScope | null;
map_note: string | null;
areas: RegionArea[];
};
export type Chapter = OrganizationListItem & {
region_id: string | null;
region_name: string | null;
region_color: string | null;
meets: string | null;
started: string | null;
/** The map tile this chapter lights up, or null if it has none. */
area_code: string | null;
};
export type Community = {
loading: boolean;
error: Error | null;
regions: Region[];
regionAreas: RegionAreaRow[];
regionById: Record<string, Region>;
domestic: Region[];
international: Region[];
virtual: Region[];
chapters: Chapter[];
chaptersIn: (regionId: string) => Chapter[];
slices: Record<string, AreaSlice[]>;
chapterCounts: Record<string, number>;
regionsForArea: (areaCode: string) => Region[];
areasLabelFor: (regionId: string) => string;
subtextFor: (region: Pick<Region, "id" | "map_note">) => string;
};
export declare function useCommunity(): Community;

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

@ -1,29 +0,0 @@
/* Types for eventData.js. Rows are GET /events as shapeEvent in
server/src/routes/content.js sends them. */
import type { EventType } from "../lib/eventTypes.ts";
import type { EventListItem, EventSection } from "../lib/useContent.ts";
export type EventFilter = {
section?: string;
host?: string;
status?: EventListItem["status"];
type?: EventType | EventType[];
};
export declare function useEvents(filter?: EventFilter): {
events: EventListItem[];
/** event_sections, in scope order. Empty until loaded. */
sections: EventSection[];
loading: boolean;
error: Error | null;
};
export declare function splitByStatus<T extends { status: string }>(
events?: T[],
): { upcoming: T[]; past: T[] };
export declare function typesPresent<D extends { id: string }>(
events?: Array<{ event_type: string }>,
declared?: readonly D[],
): D[];

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

@ -1,7 +0,0 @@
/* Types for feedbackTypes.js. */
export type FeedbackType = { id: string; label: string; hint: string };
export declare const FEEDBACK_TYPES: FeedbackType[];
export declare function feedbackTypeLabel(id: string): string;

51
src/data/mapGrid.d.ts vendored
View file

@ -1,51 +0,0 @@
/* Types for mapGrid.js. */
import type { RegionArea } from "../lib/useContent.ts";
export declare const GRID_COLS: number;
export declare const GRID_ROWS: number;
export declare const AREA_NAMES: Record<string, string>;
/** One tile: a state, or a band such as CANADA with a span. */
export type MapArea = {
code: string;
name: string;
col: number;
row: number;
span: number;
isState: boolean;
};
export declare const AREAS: readonly MapArea[];
export declare const AREA_BY_CODE: Record<string, MapArea>;
/** A region_areas row flattened with its region, as buildAreaSlices takes it. */
export type RegionAreaRow = RegionArea & { region_id: string };
/** One region's share of a tile. */
export type AreaSlice = {
regionId: string;
name: string;
color: string | null | undefined;
share: number;
edge: RegionArea["edge"];
note: string | null;
};
type Locatable = {
is_online?: boolean;
country?: string | null;
state_code?: string | null;
};
export declare function areaForChapter(chapter: Locatable | null | undefined): string | null;
export declare function buildAreaSlices(
regionAreas?: RegionAreaRow[],
regions?: Array<{ id: string; name: string; color?: string | null }>,
): Record<string, AreaSlice[]>;
export declare function countChaptersByArea(chapters?: Locatable[]): Record<string, number>;
export declare function areasLabel(regionId: string, regionAreas?: RegionAreaRow[]): string;

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

@ -1,21 +0,0 @@
/* Types for organizations.js. Rows are GET /organizations as
shapeOrganization in server/src/routes/content.js sends them. */
import type { OrgKind } from "../lib/hrefs.ts";
import type { OrganizationListItem, RegionArea } from "../lib/useContent.ts";
export declare function useOrganizations(kind?: OrgKind): {
organizations: OrganizationListItem[];
loading: boolean;
error: Error | null;
};
export declare function orgPath(
org: { id: string; kind?: string | null } | null | undefined,
): string | null;
export declare function areasSentence(
areas?: Array<Pick<RegionArea, "area_code" | "note">>,
): string;
export declare function initialsFor(name?: string): string;

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

@ -1,123 +0,0 @@
/* Types for adminSchema.js: the client half of the descriptor-driven
admin CRUD engine. Rows are whatever columns the server-side
descriptor in server/src/admin-schema.js declares, so they stay a
string-keyed record; the descriptors are what's fixed. */
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
* ('region.scope') are side-table objects under their key, and
* child collections are arrays of rows under theirs. */
export type AdminRow = Record<string, unknown>;
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
* along on the lists that filterBy narrows. */
export type AdminOption = {
id: string;
label: string;
kind?: string;
org_id?: string | null;
};
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
export type AdminOptions = Record<string, AdminOption[]>;
export type FieldWidget =
| "text"
| "textarea"
| "select"
| "checkbox"
| "color"
| "number"
| "date"
| "time";
/** A bare value, or [value, label]. */
export type SelectOption = string | readonly [string, string];
/** Show a group or collection only when another field has this value. */
export type FieldCondition = { path: string; value: unknown };
export type AdminFieldSpec = {
path: string;
label: string;
widget?: FieldWidget;
required?: boolean;
full?: boolean;
help?: string;
options?: readonly SelectOption[];
optionsFrom?: string;
blankLabel?: string;
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
/* Set by EntityEdit on the id field rather than in a manifest. */
readOnly?: boolean;
prefix?: string;
prefixPending?: boolean;
placeholder?: string;
};
export type FieldGroupSpec = {
legend: string;
note?: string;
when?: FieldCondition;
fields: AdminFieldSpec[];
};
export type CollectionSpec = {
key: string;
label: string;
addLabel?: string;
note?: string;
when?: FieldCondition;
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
blank: AdminRow;
fields: AdminFieldSpec[];
children?: CollectionSpec[];
};
export type ListColumn = {
key: string;
label: string;
primary?: boolean;
widget?: "bool";
};
export type ListFilter = {
key: string;
label: string;
options?: readonly SelectOption[];
optionsFrom?: string;
};
export type EntitySpec = {
key: string;
label: string;
singular: string;
idLabel: string;
/** "auto": the table assigns the id, so the form shows it rather than asking. */
idKind?: "auto";
/** The one id a singleton entity has. The list opens it directly,
* and the editor offers no slug, back link or delete. */
singleton?: string;
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
slugFrom?: string | string[];
titleFrom?: string;
list: { columns: ListColumn[]; filters: ListFilter[] };
groups: FieldGroupSpec[];
children?: CollectionSpec[];
};
export type AdminEntityKey =
| "organizations"
| "events"
| "people"
| "teams"
| "awards"
| "timeline"
| "front_page";
/* Indexed by route param as often as by name, so any other string
reads as possibly missing. */
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
readonly [key: string]: EntitySpec | undefined;
};
export declare function slugify(value: unknown): string;

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,14 +14,12 @@
import { createContext, useContext, useEffect } from "react";
export type AdminTitleValue = { setDetail: (value: string | null) => void };
export const AdminTitleContext = createContext<AdminTitleValue | null>(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
the tab. */
export function useAdminDetail(name: string | null | undefined) {
export function useAdminDetail(name) {
const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => {

26
src/lib/api.d.ts vendored
View file

@ -1,26 +0,0 @@
/* Types for api.js. The response type is the caller's to name:
every endpoint sends a different body, so get<T> defaults to
unknown rather than pretending to know. */
/** Per-field messages, as admin-crud.js and feedback.js send them. */
export type FieldErrors = Record<string, string>;
export declare class ApiError extends Error {
status?: number;
fields?: FieldErrors;
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
}
export declare function get<T = unknown>(
path: string,
options?: { ttl?: number; fallback?: T },
): Promise<T>;
export declare function invalidate(path?: string): void;
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
/* A 204 comes back as null. */
export declare function del<T = null>(path: string): Promise<T>;

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

@ -13,40 +13,21 @@
staring at an empty table.
═══════════════════════════════════════════════════════════════ */
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { get, post, ApiError } from "./api.js";
import type { Role } from "./roles.ts";
import { get, post, ApiError } from "./api.ts";
/* What currentUser() in server/src/auth.js returns, and what
/auth/me and /auth/login send under `user`. */
export type AdminUser = {
id: number;
email: string;
name: string | null;
role: Role;
};
const AuthContext = createContext<any>(null);
type AuthResponse = { user: AdminUser };
type AuthValue = {
user: AdminUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<AdminUser>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AdminUser | null>(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
get<AuthResponse>("/auth/me", { ttl: 0 })
get("/auth/me", { ttl: 0 })
.then((data) => {
if (!ignore) setUser(data.user);
})
@ -62,8 +43,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
const login = useCallback(async (email: string, password: string) => {
const data = await post<AuthResponse>("/auth/login", { email, password });
const login = useCallback(async (email, password) => {
const data = await post("/auth/login", { email, password });
setUser(data.user);
return data.user;
}, []);
@ -92,7 +73,7 @@ export function useAuth() {
/* Signals a session that ended while the page was open — the
admin pages call this when a request comes back 401. */
export function isUnauthorized(error: unknown): boolean {
export function isUnauthorized(error) {
return error instanceof ApiError && error.status === 401;
}

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

@ -1,4 +1,4 @@
import { useState, type ComponentType, type ReactNode } from "react";
import { useState } from "react";
/* ═══════════════════════════════════════════════════════════════
SECTION MANIFEST
@ -33,69 +33,25 @@ import { useState, type ComponentType, type ReactNode } from "react";
want them.
═══════════════════════════════════════════════════════════════ */
/* What every section Component is handed on top of its own props. */
export type SectionInjected = { accent: string; view?: string };
export type SectionToggleProps = {
view: string;
setView: (value: string) => void;
accent: string;
options: string[];
};
export type SectionViews = {
options: string[];
default?: string;
Toggle: ComponentType<SectionToggleProps>;
};
export type SectionHeading = {
id: string;
title: string;
blurb?: string;
accent: string;
background: string;
};
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
Component: ComponentType<P & SectionInjected>;
/* The Component decides P; props are checked against it. */
props?: NoInfer<P>;
views?: SectionViews;
};
/* A manifest mixes components with different props, which no single
array element type can check. This checks each entry against its
own Component where it's written, then forgets P so the entries
fit one array. */
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
return entry as unknown as SectionEntry;
}
export type ManifestSection = SectionHeading & {
actions?: ReactNode;
content: ReactNode;
};
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
export function useSectionManifest(manifest) {
// One entry per section that has a toggle, seeded from its
// declared default so the control is right before anything loads.
const [views, setViews] = useState<Record<string, string | null>>(() =>
const [views, setViews] = useState(() =>
Object.fromEntries(
manifest
.filter(entry => entry.views)
.map(entry => [
entry.id,
entry.views?.default ?? entry.views?.options[0] ?? null,
entry.views.default ?? entry.views.options?.[0] ?? null,
]),
),
);
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
return manifest.map(entry => {
const { Component, props, views: spec, ...heading } = entry;
const view = views[entry.id] ?? undefined;
const view = views[entry.id];
const Toggle = spec?.Toggle;
return {
@ -103,7 +59,7 @@ export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[]
actions: Toggle ? (
<Toggle
view={view ?? ""}
view={view}
setView={value => setView(entry.id, value)}
accent={entry.accent}
options={spec.options}

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
@ -89,7 +89,7 @@ export function useRecord<T>(path: string | null, key: string): Resource<T> {
setError(null)
setNotFound(false)
try {
const body = await get<Record<string, unknown> | null>(path as string)
const body = await get(path as string)
if (!live) return
// A 200 with the key absent is a server-side shaping bug,
// not an empty record. Say so rather than rendering a page

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,

20
src/navConfig.d.ts vendored
View file

@ -1,20 +0,0 @@
/* Types for navConfig.js. */
export type PageLink = { label: string; path: string };
export type PageSectionLink = { label: string; hash: string };
export type NavAction = {
label: string;
to: string;
/** Picks the styling, not the destination. */
variant: "ghost" | "fancy";
external?: boolean;
};
export declare const PAGE_LINKS: PageLink[];
/** Keyed by the owning page's path. */
export declare const PAGE_SECTIONS: Record<string, PageSectionLink[]>;
export declare const NAV_ACTIONS: NavAction[];

View file

@ -1,5 +1,5 @@
import PageShell from "../components/PageShell.tsx";
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
import { useSectionManifest } from "../lib/sections.tsx";
import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
import OrgListVertical from "./sections/OrgList-Vertical.tsx";
import OrgListCards from "./sections/OrgList-Card.tsx";
@ -13,7 +13,7 @@ import OrgListCards from "./sections/OrgList-Card.tsx";
═══════════════════════════════════════════════════════════════ */
const SECTIONS = [
defineSection({
{
id: "chapters",
title: "Local Chapters",
blurb:
@ -22,8 +22,8 @@ const SECTIONS = [
background: "#eef9fb",
Component: OrgListMap,
views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
}),
defineSection({
},
{
id: "regions",
title: "Unity Regions",
blurb:
@ -40,8 +40,8 @@ const SECTIONS = [
{ key: "international", title: "International Unity Regions" },
],
},
}),
defineSection({
},
{
id: "partners",
title: "Partner Organizations",
blurb:
@ -54,7 +54,7 @@ const SECTIONS = [
pageLabel: "Partner page",
empty: "· Partner organizations coming soon ·",
},
}),
},
];
export default function CommunityPage() {

View file

@ -1,5 +1,5 @@
import PageShell from "../components/PageShell.tsx";
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
import { useSectionManifest } from "../lib/sections.tsx";
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
/* ═══════════════════════════════════════════════════════════════
@ -35,10 +35,10 @@ const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
/* Pinned on every band. Named rather than repeated so turning this
page into "everything, filtered" later is one deletion. */
const RETREATS = { type: "retreat" } as const;
const RETREATS = { type: "retreat" };
const SECTIONS = [
defineSection({
{
id: "national",
title: "National Retreats",
blurb: "Our flagship gatherings, open to young adults across the country.",
@ -47,8 +47,8 @@ const SECTIONS = [
Component: EventListCards,
props: { section: "national", ...RETREATS },
views: { ...CARD_VIEWS, default: "carousel" },
}),
defineSection({
},
{
id: "regional",
title: "Regional Retreats",
blurb: "Smaller gatherings hosted by regions throughout the year.",
@ -57,8 +57,8 @@ const SECTIONS = [
Component: EventListCards,
props: { section: "regional", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" },
}),
defineSection({
},
{
id: "partner",
title: "Partner Events",
blurb: "Retreats hosted by organizations we collaborate with.",
@ -67,13 +67,13 @@ const SECTIONS = [
Component: EventListCards,
props: { section: "partner", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" },
}),
},
/* The other three scopes, ready to uncomment. Each needs an accent
and a background of its own — those are presentation and live
here, not in event_sections.
defineSection({
{
id: "local",
title: "Local Events",
blurb: "Hosted by individual chapters.",
@ -82,8 +82,8 @@ const SECTIONS = [
Component: EventListCards,
props: { section: "local", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
}),
defineSection({
},
{
id: "international",
title: "International Events",
blurb: "Gatherings beyond the US.",
@ -92,8 +92,8 @@ const SECTIONS = [
Component: EventListCards,
props: { section: "international", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
}),
defineSection({
},
{
id: "other",
title: "Other Events",
blurb: "Everything that doesn't fit the categories above.",
@ -102,7 +102,7 @@ const SECTIONS = [
Component: EventListCards,
props: { section: "other", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" },
}),
},
*/
];

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,42 +20,14 @@
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";
/* feedback.status's CHECK values, in triage order. */
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
type FeedbackStatus = (typeof STATUSES)[number];
/* The columns GET /api/admin/feedback selects. */
type FeedbackRow = {
id: number;
created_at: string;
feedback_type: string;
message: string;
name: string | null;
email: string | null;
page_path: string | null;
section_id: string | null;
status: FeedbackStatus;
admin_note: string | null;
};
type FeedbackPage = {
feedback: FeedbackRow[];
/** Unfiltered, one per status. */
counts: Record<FeedbackStatus, number>;
nextCursor: number | null;
};
type FeedbackChanges = { status?: FeedbackStatus; admin_note?: string };
type StatusFilter = FeedbackStatus | "all";
const STATUS_STYLE: Record<FeedbackStatus, string> = {
const STATUS_STYLE = {
new: "bg-[#138ba0] text-white",
read: "bg-[#eef9fb] text-[#138ba0]",
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
@ -65,7 +37,7 @@ const STATUS_STYLE: Record<FeedbackStatus, string> = {
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
// won't parse without the T and the Z.
function formatDate(value: string) {
function formatDate(value) {
const date = new Date(`${value.replace(" ", "T")}Z`);
return date.toLocaleString(undefined, {
dateStyle: "medium",
@ -73,34 +45,26 @@ function formatDate(value: string) {
});
}
function locationOf(row: FeedbackRow) {
function locationOf(row) {
if (!row.page_path) return "Not page-specific";
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
}
/* ── One submission ──────────────────────────────────────────── */
type FeedbackCardProps = {
row: FeedbackRow;
onChange: (row: FeedbackRow) => void;
onRemove: (row: FeedbackRow) => void;
canWrite: boolean;
canRemove: boolean;
};
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<any>(null);
const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? "");
async function save(changes: FeedbackChanges) {
async function save(changes) {
setBusy(true);
setError(null);
try {
const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
const data = await patch(`/admin/feedback/${row.id}`, changes);
onChange(data.feedback);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
@ -177,8 +141,7 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: Feedback
id={`status-${row.id}`}
value={row.status}
disabled={busy}
// The options are STATUSES, so the value is always one of them.
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
onChange={(e) => save({ status: e.target.value })}
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
>
{STATUSES.map((s) => (
@ -265,22 +228,22 @@ export default function AdminFeedback() {
const { user } = useAuth();
const navigate = useNavigate();
const [status, setStatus] = useState<StatusFilter>("new");
const [status, setStatus] = useState("new");
const [query, setQuery] = useState("");
const [search, setSearch] = useState(""); // applied, not typed
const [rows, setRows] = useState<FeedbackRow[]>([]);
const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
const [cursor, setCursor] = useState<number | null>(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<string | null>(null);
const [error, setError] = useState<any>(null);
// Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user);
const canRemove = canDelete(user);
const load = useCallback(
async (before: number | null = null) => {
async (before = null) => {
setLoading(true);
setError(null);
@ -290,7 +253,7 @@ export default function AdminFeedback() {
if (before) params.set("before", String(before));
try {
const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
setCounts(data.counts);
setCursor(data.nextCursor);
@ -314,7 +277,7 @@ export default function AdminFeedback() {
load();
}, [load]);
function replaceRow(updated: FeedbackRow) {
function replaceRow(updated) {
setRows((prev) =>
prev
.map((row) => (row.id === updated.id ? updated : row))
@ -327,7 +290,7 @@ export default function AdminFeedback() {
// The deleted row is passed whole rather than by id: its status
// is what says which tab count to drop.
function removeRow(removed: FeedbackRow) {
function removeRow(removed) {
setRows((prev) => prev.filter((row) => row.id !== removed.id));
setCounts((prev) => ({
...prev,
@ -335,7 +298,7 @@ export default function AdminFeedback() {
}));
}
const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
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
@ -18,18 +18,17 @@
record) wants to be a separate component that fails on its own.
═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } 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
an anchor inside an anchor; the sub-links sit above it on z-10 so
they stay separately clickable. */
function NavCard({ item }: { item: AdminNavItem }) {
function NavCard({ item }) {
const to = target(item);
// Drop the child that just repeats the card's own destination —
@ -76,15 +75,7 @@ function NavCard({ item }: { item: AdminNavItem }) {
);
}
function CardBlock({
title,
blurb,
children,
}: {
title: string;
blurb?: string;
children: ReactNode;
}) {
function CardBlock({ title, blurb, children }) {
return (
<div className="mt-10">
<div className="flex items-baseline gap-3">
@ -96,7 +87,7 @@ function CardBlock({
);
}
function PanelSection({ title, children }: { title: string; children: ReactNode }) {
function PanelSection({ title, children }) {
return (
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
@ -116,7 +107,7 @@ const QUICK_ADD = [
export default function AdminHome() {
const { user } = useAuth();
const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
const role = ROLE_LABELS[user?.role] ?? user?.role;
return (
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">

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,8 +56,8 @@ export default function AdminLayout() {
// What the page below has published about itself — a record
// name, or null on a list. setDetail is stable so publishing
// can't loop.
const [detail, setDetail] = useState<string | null>(null);
const stableSet = useCallback((value: string | null) => setDetail(value), []);
const [detail, setDetail] = useState<any>(null);
const stableSet = useCallback((value) => setDetail(value), []);
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => {

View file

@ -10,11 +10,11 @@
starts working.
═══════════════════════════════════════════════════════════════ */
import { useState, type FormEvent } from "react";
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,12 +29,12 @@ export default function AdminLogin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<any>(null);
const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/home";
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event) {
event.preventDefault();
if (busy) return;

View file

@ -24,54 +24,23 @@
the reason shows up before the click rather than after it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { del, get, patch } from "../../lib/api.js";
import { useCallback, useEffect, useState } from "react";
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, type Role } from "../../lib/roles.ts";
/* An admin_users row as panel.js selects it, with its live session count. */
type PanelUser = {
id: number;
email: string;
name: string | null;
role: Role;
is_active: number;
created_at: string;
last_login_at: string | null;
sessions: number;
};
/* GET /api/admin/panel/overview. A count is null when its table
doesn't exist yet. */
type Overview = {
system: {
schemaVersion: number;
dbPath: string | null;
nodeVersion: string;
platform: string;
uptimeSeconds: number;
startedAt: string;
sessions: number | null;
roles: Role[];
};
content: Array<{ label: string; count: number | null }>;
users: PanelUser[];
};
type UserResponse = { user: PanelUser };
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
marker that says so. Left alone, browsers read it as local time
and last-login drifts by the timezone offset. */
function when(value: string | null | undefined) {
function when(value) {
if (!value) return "—";
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function uptime(seconds: number | null | undefined) {
function uptime(seconds) {
if (seconds == null) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
@ -81,15 +50,7 @@ function uptime(seconds: number | null | undefined) {
return `${m}m`;
}
function Block({
title,
note,
children,
}: {
title: string;
note?: string;
children: ReactNode;
}) {
function Block({ title, note, children }: any) {
return (
<section className="mt-8 first:mt-0">
<div className="flex items-baseline gap-3">
@ -101,7 +62,7 @@ function Block({
);
}
function Stat({ label, value }: { label: string; value: ReactNode }) {
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
@ -116,23 +77,23 @@ export default function AdminPanel() {
const { user: me } = useAuth();
const navigate = useNavigate();
const [data, setData] = useState<Overview | null>(null);
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<number | null>(null);
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(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<Overview>("/admin/panel/overview", { ttl: 0 }));
} catch (err) {
setData(await get("/admin/panel/overview", { ttl: 0 }));
} catch (err: any) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError((err instanceof Error && err.message) || "Couldn't load the panel.");
setError(err.message || "Couldn't load the panel.");
} finally {
setLoading(false);
}
@ -144,7 +105,7 @@ export default function AdminPanel() {
/* Replace the one row the server returns rather than refetching
the whole overview — the counts didn't change. */
function mergeUser(updated: PanelUser) {
function mergeUser(updated) {
setData((current) =>
current
? {
@ -155,37 +116,30 @@ export default function AdminPanel() {
);
}
async function run(id: number, work: () => Promise<PanelUser>) {
async function run(id, work) {
setBusyId(id);
setRowError(null);
try {
mergeUser(await work());
} catch (err) {
} catch (err: any) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setRowError({ id, message: (err instanceof Error && err.message) || "That didn't work." });
setRowError({ id, message: err.message || "That didn't work." });
} finally {
setBusyId(null);
}
}
const changeRole = (row: PanelUser, role: Role) =>
const changeRole = (row, role) =>
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
const setActive = (row, is_active) =>
run(
row.id,
async () => (await patch<UserResponse>(`/admin/panel/users/${row.id}`, { role })).user,
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
);
const setActive = (row: PanelUser, is_active: number) =>
run(
row.id,
async () =>
(await patch<UserResponse>(`/admin/panel/users/${row.id}`, { is_active })).user,
);
const revoke = (row: PanelUser) =>
run(
row.id,
async () => (await del<UserResponse>(`/admin/panel/users/${row.id}/sessions`)).user,
);
const revoke = (row) =>
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
if (loading) {
return (
@ -210,9 +164,6 @@ export default function AdminPanel() {
);
}
// Not loading and no error means the overview arrived.
if (!data) return null;
const { system, content, users } = data;
const activeSupers = users.filter(
(u) => u.role === "superadmin" && u.is_active === 1,
@ -303,7 +254,7 @@ export default function AdminPanel() {
<select
value={row.role}
disabled={locked || busy}
onChange={(e) => changeRole(row, e.target.value as Role)}
onChange={(e) => changeRole(row, e.target.value)}
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
title={
isMe

View file

@ -43,36 +43,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { get, post, patch, del, ApiError, type FieldErrors } 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,
type AdminOptions,
type AdminRow,
type FieldCondition,
} 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";
/* A foreign key refusing to budge is the most common way a save or
delete fails here, and SQLite's own wording explains nothing to
whoever is filling in the form. */
function friendly(message: string, singular: string): string {
function friendly(message, singular) {
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
return `Something still points at this ${singular}. Reassign or remove those first.`;
}
return message;
}
type RowResponse = { row: AdminRow };
type Notice = { tone: "ok" | "error"; text: string; recover?: "reload" };
export default function EntityEdit() {
const { entity: entityKey, id } = useParams();
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
const manifest = ADMIN_ENTITIES[entityKey ?? ""];
const navigate = useNavigate();
const { user } = useAuth();
@ -95,9 +85,9 @@ export default function EntityEdit() {
// Hoisted above the loading guards: the title hook below is a
// hook, so it can't sit after an early return, and it needs the
// same paths the heading uses.
const slugPaths: string[] = Array.isArray(manifest?.slugFrom)
const slugPaths = Array.isArray(manifest?.slugFrom)
? manifest.slugFrom
: [manifest?.slugFrom].filter((path): path is string => Boolean(path));
: [manifest?.slugFrom].filter(Boolean);
// Everything before the last path is a qualifier: a fact about
// another field rather than something to type. It renders as
@ -107,7 +97,7 @@ export default function EntityEdit() {
// Empty until every qualifier is chosen, because half a prefix
// would be saved into an id that then never matches.
const prefixOf = (source: AdminRow | null) => {
const prefixOf = (source) => {
if (qualifierPaths.length === 0) return "";
const parts = qualifierPaths.map((path) => getPath(source, path));
if (parts.some((part) => !part)) return "";
@ -119,9 +109,9 @@ export default function EntityEdit() {
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
: "";
const tailOf = (source: AdminRow | null) => {
const tailOf = (source) => {
const prefix = prefixOf(source);
const value = String(source?.id ?? "");
const value = source?.id ?? "";
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
};
@ -130,27 +120,24 @@ export default function EntityEdit() {
// names the field to read instead.
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
// Blank when neither is set yet, which reads as "nothing to name".
const headingOf = (row: AdminRow) => String(getPath(row, headingPath) || row.id || "");
const [form, setForm] = useState<AdminRow | null>(null);
const [options, setOptions] = useState<AdminOptions>({});
const [errors, setErrors] = useState<Partial<FieldErrors>>({});
const [message, setMessage] = useState<Notice | null>(null);
const [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<string | null>(null);
const baseline = useRef<any>(null);
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setErrors({});
try {
const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 });
const opts = await get("/admin/options", { ttl: 60_000 });
setOptions(opts.options);
if (isNew) {
@ -160,12 +147,12 @@ export default function EntityEdit() {
// server's column defaults apply to whatever isn't filled in.
// No id key for an auto entity: the table assigns it, and
// sending "" would be an explicit value rather than an absence.
const blank: AdminRow = autoId ? {} : { id: "" };
const blank = autoId ? {} : { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
} else {
const data = await get<RowResponse>(`/admin/${manifest.key}/${id}`, { ttl: 0 });
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
setForm(data.row);
baseline.current = JSON.stringify(data.row);
}
@ -200,7 +187,7 @@ export default function EntityEdit() {
: isNew
? `New ${manifest.singular}`
: form
? headingOf(form)
? getPath(form, headingPath) || form.id
: null,
);
@ -208,7 +195,7 @@ export default function EntityEdit() {
// Router entirely, so the only hook available is this one.
useEffect(() => {
if (!dirty) return undefined;
const warn = (event: BeforeUnloadEvent) => {
const warn = (event) => {
event.preventDefault();
event.returnValue = "";
};
@ -221,19 +208,19 @@ export default function EntityEdit() {
/* ── Heading ───────────────────────────────────────────────── */
const heading = singleton ? manifest.label : headingOf(form);
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
const heading = singleton ? manifest.label : getPath(form, headingPath) || form.id;
const updatedAt = form.updated_at;
const children = manifest.children ?? [];
/* ── Actions ───────────────────────────────────────────────── */
const leave = (to: string) => {
const leave = (to) => {
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
navigate(to);
};
const change = (path: string, value: unknown) => {
const change = (path, value) => {
setForm((prev) => {
let next = setPath(prev, path, value);
// Recompose the id whenever one of its sources moves. The
@ -255,20 +242,20 @@ export default function EntityEdit() {
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
};
const save = async () => {
async function save() {
setSaving(true);
setErrors({});
setMessage(null);
try {
const data = isNew
? await post<RowResponse>(`/admin/${manifest.key}`, form)
: await patch<RowResponse>(`/admin/${manifest.key}/${id}`, form);
? await post(`/admin/${manifest.key}`, form)
: await patch(`/admin/${manifest.key}/${id}`, form);
setForm(data.row);
baseline.current = JSON.stringify(data.row);
setMessage({ tone: "ok", text: "Saved." });
if (isNew) navigate(`/admin/${manifest.key}/${String(data.row.id)}`, { replace: true });
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
@ -293,9 +280,9 @@ export default function EntityEdit() {
} finally {
setSaving(false);
}
};
}
const remove = async () => {
async function remove() {
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
try {
@ -311,9 +298,9 @@ export default function EntityEdit() {
: "Couldn't delete that.",
});
}
};
}
const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value;
const visible = (when) => !when || getPath(form, when.path) === when.value;
return (
<div className="pb-24">
@ -344,8 +331,8 @@ export default function EntityEdit() {
!isNew && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">
{manifest.idLabel} #{String(form.id)}
{updatedAt && <> · last saved {updatedAt}</>}
{manifest.idLabel} #{form.id}
{form.updated_at && <> · last saved {form.updated_at}</>}
</p>
</div>
)
@ -372,9 +359,9 @@ export default function EntityEdit() {
change("id", `${prefixOf(form)}${slugify(value)}`);
}}
/>
{!isNew && updatedAt && (
{!isNew && form.updated_at && (
<p className="mt-2 text-xs text-[#4a6b72]">
Last saved {updatedAt}
Last saved {form.updated_at}
</p>
)}
</div>
@ -416,7 +403,7 @@ export default function EntityEdit() {
<Repeater
key={child.key}
spec={child}
rows={form[child.key] as AdminRow[] | undefined}
rows={form[child.key]}
options={options}
errors={errors}
errorPrefix={`${child.key}.`}

View file

@ -15,27 +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,
type AdminOptions,
type AdminRow,
type ListFilter,
} 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 = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
const manifest = ADMIN_ENTITIES[entityKey ?? ""];
const navigate = useNavigate();
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const [rows, setRows] = useState<AdminRow[]>([]);
const [options, setOptions] = useState<AdminOptions>({});
const [rows, setRows] = useState<any[]>([]);
const [options, setOptions] = useState<any>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<any>(null);
const [query, setQuery] = useState(params.get("q") ?? "");
// Minimum rank, never equality. POST /api/admin/:entity is gated
@ -51,8 +46,8 @@ export default function EntityList() {
setError(null);
try {
const [list, opts] = await Promise.all([
get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get("/admin/options", { ttl: 60_000 }),
]);
setRows(list.rows);
setOptions(opts.options);
@ -77,18 +72,18 @@ export default function EntityList() {
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
}
function setParam(key: string, value: string) {
function setParam(key, value) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
}
function labelFor(filter: ListFilter): Array<readonly [string, string]> {
function labelFor(filter) {
if (filter.optionsFrom) {
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
}
return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
}
return (
@ -169,7 +164,7 @@ export default function EntityList() {
<tbody>
{rows.map((row) => (
<tr
key={String(row.id)}
key={row.id}
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
>
@ -184,12 +179,10 @@ export default function EntityList() {
? row[column.key]
? "Yes"
: "—"
: row[column.key]
? String(row[column.key])
: "—"}
: row[column.key] || "—"}
</td>
))}
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
</tr>
))}
</tbody>

View file

@ -19,7 +19,7 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
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" }: { role?: Role }) {
const { user, loading } = useAuth();

View file

@ -1,45 +0,0 @@
/* Types for adminNav.js. */
import type { AdminUser } from "../../lib/auth.tsx";
export declare const ADMIN_HOME: string;
export declare const ADMIN_PANEL: string;
export type AdminArea = "home" | "cms" | "panel";
export declare const AREA_TITLES: Record<AdminArea, string>;
export type AdminNavLink = {
to: string;
label: string;
/** Only read by the home cards. */
blurb?: string;
};
type AdminNavBase = {
label: string;
blurb?: string;
separated?: boolean;
superOnly?: boolean;
};
/** A tab. One with no `to` of its own opens its first child, so it
* must have one. */
export type AdminNavItem = AdminNavBase &
(
| { to: string; children?: AdminNavLink[] }
| { to?: undefined; children: [AdminNavLink, ...AdminNavLink[]] }
);
export declare const CMS_NAV: AdminNavItem[];
export declare const FORMS_NAV: AdminNavItem & { children: [AdminNavLink, ...AdminNavLink[]] };
export declare const PANEL_NAV: AdminNavItem;
export declare const NAV: AdminNavItem[];
export declare function navFor(user: AdminUser | null | undefined): AdminNavItem[];
export declare const matches: (pathname: string, to: string | null | undefined) => boolean;
export declare const target: (item: AdminNavItem) => string;
export declare function areaFor(pathname: string): AdminArea;

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,16 +1,9 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
splitByStatus,
typesPresent,
useEvents,
type EventFilter,
} from "../../data/eventData.js";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.ts";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
import { seriesLabel } from "../../lib/eventSeries.ts";
import type { EventListItem } from "../../lib/useContent.ts";
import type { SectionToggleProps } from "../../lib/sections.tsx";
/* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS
@ -32,7 +25,7 @@ import type { SectionToggleProps } from "../../lib/sections.tsx";
nothing but retreats shows no control at all.
═══════════════════════════════════════════════════════════════ */
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
eager: true,
import: "default",
});
@ -41,7 +34,7 @@ const LOGOS = Object.fromEntries(
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
);
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
const logoSrc = file => (file && LOGOS[file]) || null;
/* Last resort only. The API already falls back to the host
organization's logo when an event doesn't name its own, so this
@ -49,15 +42,7 @@ const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || nu
const DEFAULT_ORG_LOGO = null;
/* An <img> that removes itself if the file 404s. */
function Logo({
file,
alt = "",
className,
}: {
file: string | null | undefined;
alt?: string;
className?: string;
}) {
function Logo({ file, alt = "", className }) {
const [failed, setFailed] = useState(false);
const src = logoSrc(file);
if (!src || failed) return null;
@ -138,15 +123,7 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
carousel has the same constraint — it needs the card's click to
mean "bring this one to the front" on anything that isn't the
active slide. */
function TitleLink({
ev,
linked,
children,
}: {
ev: Pick<EventListItem, "id">;
linked: boolean;
children: ReactNode;
}) {
function TitleLink({ ev, linked, children }) {
if (!linked) return <>{children}</>;
return (
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
@ -173,16 +150,6 @@ function TitleLink({
`linked` is the one thing a caller turns off: a card on the
event's own page shouldn't link to the page it's already on.
═══════════════════════════════════════════════════════════════ */
type CardProps = {
ev: EventListItem;
defaultColor?: string;
accent?: string;
compact?: boolean;
interactive?: boolean;
linked?: boolean;
showType?: boolean;
};
export function Card({
ev,
defaultColor = TEAL,
@ -191,7 +158,7 @@ export function Card({
interactive = true,
linked = true,
showType = false,
}: CardProps) {
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
@ -390,23 +357,14 @@ export function Card({
them visible is one tap, and the row reads as what the section
contains rather than as a form control.
═══════════════════════════════════════════════════════════════ */
type TypeFilterValue = EventType | "all";
type TypeFilterProps = {
types: typeof EVENT_TYPES;
active: TypeFilterValue;
setActive: (id: TypeFilterValue) => void;
accent: string;
};
export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps) {
const chip = (on: boolean) => ({
export function TypeFilter({ types, active, setActive, accent }) {
const chip = on => ({
border: `1px solid ${accent}`,
background: on ? accent : "transparent",
color: on ? "#ffffff" : accent,
});
const button = (id: TypeFilterValue, label: string) => (
const button = (id, label) => (
<button
key={id}
onClick={() => setActive(id)}
@ -434,12 +392,8 @@ export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps
/* ═══════════════════════════════════════════════════════════════
TOGGLE — the control for the section heading's action bar
═══════════════════════════════════════════════════════════════ */
export function EventCardsToggle({
view,
setView,
accent,
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
const btn = (active: boolean) => ({
export function EventCardsToggle({ view, setView, accent }) {
const btn = active => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
@ -491,14 +445,6 @@ export function EventCardsToggle({
differently to someone waiting, so they're distinguished rather
than all falling through to "coming soon".
═══════════════════════════════════════════════════════════════ */
type EventListCardsProps = EventFilter & {
/** "carousel" or "grid". */
view?: string;
accent?: string;
defaultColor?: string;
empty?: string;
};
export default function EventListCards({
section,
host,
@ -508,7 +454,7 @@ export default function EventListCards({
accent = TEAL,
defaultColor,
empty = "· Events coming soon, stay connected for announcements ·",
}: EventListCardsProps) {
}: any) {
const { events: fetched, loading, error } = useEvents({
section,
host,
@ -519,7 +465,7 @@ export default function EventListCards({
const [index, setIndex] = useState(0);
const [showPast, setShowPast] = useState(false);
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
const [activeType, setActiveType] = useState("all");
/* What this band holds, which is what the chips offer — not the
full list of declared types, three quarters of which would be
@ -573,7 +519,7 @@ export default function EventListCards({
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
// Arrows and dots follow the section accent, not the active card.
const arrowStyle = (enabled: boolean) => ({
const arrowStyle = enabled => ({
border: `1px solid ${accent}`,
background: "rgba(255,255,255,0.85)",
color: enabled ? accent : "#b8c6c9",
@ -581,7 +527,7 @@ export default function EventListCards({
opacity: enabled ? 1 : 0.4,
});
const notice = (text: string) => (
const notice = text => (
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
{text}
</p>

View file

@ -11,10 +11,10 @@
page to the nav adds it to this form too.
═══════════════════════════════════════════════════════════════ */
import { useState, type FormEvent, type ReactNode } 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 { useState } from "react";
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";
@ -46,7 +46,7 @@ function OptionalTag() {
);
}
function FieldError({ id, children }: { id: string; children?: ReactNode }) {
function FieldError({ id, children }) {
if (!children) return null;
return (
<p id={id} className="mt-2 text-sm text-[#b3261e]">
@ -56,15 +56,7 @@ function FieldError({ id, children }: { id: string; children?: ReactNode }) {
}
// Native select plus a chevron, since appearance-none strips the default one.
type SelectProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
children: ReactNode;
};
function Select({ id, label, value, onChange, children }: SelectProps) {
function Select({ id, label, value, onChange, children }) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
@ -101,13 +93,7 @@ function Select({ id, label, value, onChange, children }: SelectProps) {
/* ── Type picker ─────────────────────────────────────────────── */
function TypePicker({
value,
onChange,
}: {
value: string | null;
onChange: (id: string) => void;
}) {
function TypePicker({ value, onChange }) {
return (
<fieldset>
<legend className="text-base font-semibold text-[#26454c]">
@ -165,16 +151,7 @@ function TypePicker({
/* ── Where on the site ───────────────────────────────────────── */
type LocationPickerProps = {
/** A nav path, or SITE_WIDE. */
page: string;
/** A nav hash, or WHOLE_PAGE. */
section: string;
onPageChange: (page: string) => void;
onSectionChange: (section: string) => void;
};
function LocationPicker({ page, section, onPageChange, onSectionChange }: LocationPickerProps) {
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
return (
@ -228,7 +205,7 @@ function LocationPicker({ page, section, onPageChange, onSectionChange }: Locati
}
// Human-readable version of the picked location, for the thank-you panel.
function describeLocation(page: string, section: string) {
function describeLocation(page, section) {
if (page === SITE_WIDE) return null;
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
@ -239,11 +216,8 @@ function describeLocation(page: string, section: string) {
/* ── The form ────────────────────────────────────────────────── */
/* The fields server/src/routes/feedback.js can reject by name. */
type FeedbackFieldErrors = { message?: string; email?: string };
export default function FeedbackForm() {
const [type, setType] = useState<string | null>(null);
const [type, setType] = useState<any>(null);
const [page, setPage] = useState(SITE_WIDE);
const [section, setSection] = useState(WHOLE_PAGE);
const [message, setMessage] = useState("");
@ -254,14 +228,14 @@ export default function FeedbackForm() {
const [website, setWebsite] = useState("");
// idle → sending → sent, or back to idle with an error to show.
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FeedbackFieldErrors>({});
const [status, setStatus] = useState("idle");
const [formError, setFormError] = useState<any>(null);
const [fieldErrors, setFieldErrors] = useState<any>({});
const sending = status === "sending";
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event) {
event.preventDefault();
if (!ready || sending) return;

View file

@ -1,12 +1,10 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
import {
initialsFor,
orgPath,
useOrganizations,
} from "../../data/organizations.js";
} from "../../data/organizations.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — CARDS
@ -35,13 +33,7 @@ const FALLBACK_COLOR = "#4a6b72";
const CARD_MIN = "20rem";
const GRID_MAX = "88rem";
function OrgMark({
org,
color,
}: {
org: Pick<OrganizationListItem, "logo" | "name">;
color: string;
}) {
function OrgMark({ org, color }) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
@ -66,14 +58,7 @@ function OrgMark({
);
}
type CardLabels = { accent: string; pageLabel: string; siteLabel: string };
function OrgCard({
org,
accent,
pageLabel,
siteLabel,
}: CardLabels & { org: OrganizationListItem }) {
function OrgCard({ org, accent, pageLabel, siteLabel }) {
const color = org.color || accent;
const path = orgPath(org);
@ -151,13 +136,7 @@ function OrgCard({
);
}
function Block({
title,
orgs,
accent,
pageLabel,
siteLabel,
}: CardLabels & { title?: string; orgs: OrganizationListItem[] }) {
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
if (orgs.length === 0) return null;
return (
@ -188,30 +167,7 @@ function Block({
);
}
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
a.name.localeCompare(b.name);
/* One heading's worth of the grid. Labels override the grid's own. */
type OrgGroup = {
key: string;
title: string;
pageLabel?: string;
siteLabel?: string;
};
type OrgListCardsProps = {
kind?: OrgKind;
title?: string;
groups?: OrgGroup[];
/** Which group key an organization files under. */
groupBy?: (org: OrganizationListItem) => string | null | undefined;
accent?: string;
pageLabel?: string;
siteLabel?: string;
/** Null keeps the API's order. */
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
empty?: string;
};
const byName = (a, b) => a.name.localeCompare(b.name);
export default function OrgListCards({
kind,
@ -223,10 +179,10 @@ export default function OrgListCards({
siteLabel = "Visit site",
sort = byName,
empty = "· Nothing to show here just yet ·",
}: OrgListCardsProps) {
}) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = (children: ReactNode) => (
const shell = children => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
{children}
</div>

View file

@ -1,16 +1,9 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import {
useCommunity,
type Chapter,
type Community,
type Region,
} from "../../data/chapters.js";
import { useEffect, useRef, useState } from "react";
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, type AreaSlice } from "../../data/mapGrid.js";
import type { SectionToggleProps } from "../../lib/sections.tsx";
import type { ContentBlock, OrganizationListItem } from "../../lib/useContent.ts";
import { initialsFor, orgPath } from "../../data/organizations.ts";
import { AREAS, AREA_NAMES } from "../../data/mapGrid.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — MAP
@ -32,7 +25,7 @@ import type { ContentBlock, OrganizationListItem } from "../../lib/useContent.ts
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.
═══════════════════════════════════════════════════════════════ */
@ -55,16 +48,7 @@ const FALLBACK_COLOR = "#4a6b72";
/* organizations.color is nullable; an SVG fill left undefined paints
black, so an uncoloured region takes the same fallback as a card. */
const colorOf = (item: { color?: string | null }) => item.color || FALLBACK_COLOR;
/* Which region is picked and which is under the pointer, shared by
the map, the legend and the list. */
type Highlight = {
selected: string | null;
hovered: string | null;
setSelected: (id: string | null) => void;
setHovered: (id: string | null) => void;
};
const colorOf = (item) => item.color || FALLBACK_COLOR;
const US_TITLE = "US Unity Regions";
const INTL_TITLE = "International Unity Regions";
@ -75,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 }: { blocks?: ContentBlock[]; color: string }) {
function Blocks({ blocks = [] as any[], color }) {
if (blocks.length === 0) return null;
return (
@ -108,7 +92,7 @@ function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string
case "links":
return (
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
{(block.items ?? []).map((item, j) => (
{block.items.map((item, j) => (
<li key={j}>
{item.url ? (
<a
@ -154,19 +138,6 @@ function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string
subtracts, and the order the slices arrive in doesn't change
what's drawn.
───────────────────────────────────────────────────────────── */
type TileProps = Highlight & {
code: string;
x: number;
y: number;
size: number;
width?: number;
label?: string;
slices?: AreaSlice[];
count?: number;
onPick?: (code: string) => void;
fontSize?: number;
};
function Tile({
code,
x,
@ -174,7 +145,7 @@ function Tile({
size,
width = size,
label,
slices = [],
slices = [] as any[],
count = 0,
selected,
hovered,
@ -182,12 +153,11 @@ function Tile({
setHovered,
onPick,
fontSize = 34,
}: TileProps) {
}) {
if (slices.length === 0) return null;
const primary = slices[0];
// Nullable so indexOf and includes take `selected` as it is.
const ids: Array<string | null> = slices.map(s => s.regionId);
const ids = slices.map(s => s.regionId);
const active = ids.includes(selected) || ids.includes(hovered);
const dimmed = selected && !ids.includes(selected);
const clipId = `clip-${code}`;
@ -278,13 +248,7 @@ function Tile({
);
}
type RegionMapProps = Highlight & {
slices: Record<string, AreaSlice[]>;
chapterCounts: Record<string, number>;
onPick?: (code: string) => void;
};
function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
function RegionMap({ slices, chapterCounts, ...props }: any) {
const size = TILE - PAD * 2;
return (
@ -315,15 +279,8 @@ function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
);
}
function LegendButton({
region,
selected,
setSelected,
hovered,
setHovered,
}: Highlight & { region: Region }) {
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
const on = selected === region.id || hovered === region.id;
const color = colorOf(region);
return (
<button
onClick={() => setSelected(selected === region.id ? null : region.id)}
@ -334,36 +291,29 @@ function LegendButton({
aria-pressed={selected === region.id}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
style={{
border: `1px solid ${color}`,
background: on ? color : "transparent",
color: on ? "#ffffff" : color,
border: `1px solid ${colorOf(region)}`,
background: on ? colorOf(region) : "transparent",
color: on ? "#ffffff" : colorOf(region),
opacity: selected && selected !== region.id ? 0.45 : 1,
}}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: on ? "#ffffff" : color }}
style={{ background: on ? "#ffffff" : colorOf(region) }}
/>
{region.name}
</button>
);
}
type LegendProps = Highlight & {
domestic: Region[];
international: Region[];
/** Regions that paint at least one tile. */
onMapIds: Set<string>;
};
function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
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
// to need a hardcoded exception here because its states arrived
// only through SPLITS; it has ordinary rows now, so the exception
// is gone.
const onMap = (region: Region) => onMapIds.has(region.id);
const onMap = region => onMapIds.has(region.id);
const us = domestic.filter(onMap);
const intl = international.filter(onMap);
@ -401,15 +351,6 @@ function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
);
}
type RegionBlockProps = Highlight & {
region: Region;
chapters: Chapter[];
subtext: string;
indent: boolean;
regionRefs?: RefObject<Record<string, HTMLDivElement | null>>;
chapterRefs?: RefObject<Record<string, HTMLLIElement | null>>;
};
function RegionBlock({
region,
chapters,
@ -420,15 +361,12 @@ function RegionBlock({
indent,
regionRefs,
chapterRefs,
}: RegionBlockProps) {
}) {
const on = selected === region.id;
const color = colorOf(region);
return (
<div
ref={el => {
if (regionRefs) regionRefs.current[region.id] = el;
}}
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
className="transition-opacity duration-200"
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
>
@ -440,11 +378,11 @@ function RegionBlock({
>
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: color }}
style={{ background: colorOf(region) }}
/>
<h4
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
style={{ color: color }}
style={{ color: colorOf(region) }}
>
{region.name}
</h4>
@ -465,16 +403,12 @@ function RegionBlock({
</p>
) : (
<ul className="ml-5 mb-4 flex flex-col gap-3">
{chapters.map(c => {
const path = orgPath(c);
return (
{chapters.map(c => (
<li
key={c.id}
ref={el => {
if (chapterRefs) chapterRefs.current[c.id] = el;
}}
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
className="pl-3 flex items-start gap-3"
style={{ borderLeft: `2px solid ${color}` }}
style={{ borderLeft: `2px solid ${colorOf(region)}` }}
>
<div className="min-w-0 flex-1">
<p className="font-700">{c.name}</p>
@ -490,7 +424,7 @@ function RegionBlock({
target="_blank"
rel="noopener noreferrer"
className="font-700 underline"
style={{ color: color }}
style={{ color: colorOf(region) }}
>
Details
</a>
@ -499,7 +433,7 @@ function RegionBlock({
<a
href={`mailto:${c.email}`}
className="font-700 underline"
style={{ color: color }}
style={{ color: colorOf(region) }}
>
Contact
</a>
@ -508,17 +442,16 @@ function RegionBlock({
)}
</div>
{path && (
{orgPath(c) && (
<ArrowLink
to={path}
to={orgPath(c)}
label={`${c.name} — chapter page`}
color={color}
color={colorOf(region)}
size="h-8 w-8"
/>
)}
</li>
);
})}
))}
</ul>
)}
</div>
@ -533,15 +466,7 @@ function RegionBlock({
═══════════════════════════════════════════════════════════════ */
/* Logo, or the organization's initials when there's no file. */
function OrgLogo({
org,
color,
size = "h-14 w-14",
}: {
org: Pick<OrganizationListItem, "logo" | "name">;
color: string;
size?: string;
}) {
function OrgLogo({ org, color, size = "h-14 w-14" }) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
@ -566,16 +491,7 @@ function OrgLogo({
);
}
type ChapterCardProps = {
chapter: Chapter;
color: string;
open: boolean;
onOpen: () => void;
};
function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
const path = orgPath(chapter);
function ChapterCard({ chapter, color, open, onOpen }) {
return (
<div
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
@ -627,9 +543,9 @@ function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
</svg>
</button>
{path && (
{orgPath(chapter) && (
<ArrowLink
to={path}
to={orgPath(chapter)}
label={`${chapter.name} — chapter page`}
color={color}
/>
@ -639,18 +555,7 @@ function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
);
}
function ChapterDetail({
chapter,
region,
onClose,
}: {
chapter: Chapter;
region: Region;
onClose: () => void;
}) {
const path = orgPath(chapter);
const color = colorOf(region);
function ChapterDetail({ chapter, region, onClose }) {
/* "Led by" comes from affiliations rather than a text field, so
it lists real people and stays empty until they exist. */
const leads = (chapter.leadership ?? [])
@ -659,23 +564,21 @@ function ChapterDetail({
)
.join(", ");
const rows = (
[
const rows = [
["Region", region.name],
["Where", chapter.venue],
["Meets", chapter.meets],
["Led by", leads],
["Since", chapter.started],
] satisfies Array<[label: string, value: string | null | undefined]>
).filter(([, v]) => v);
].filter(([, v]) => v);
return (
<div
className="rounded-2xl p-6 mb-6"
style={{ border: `2px solid ${color}`, background: `${color}0f` }}
style={{ border: `2px solid ${colorOf(region)}`, background: `${colorOf(region)}0f` }}
>
<div className="flex items-start gap-4">
<OrgLogo org={chapter} color={color} size="h-20 w-20" />
<OrgLogo org={chapter} color={colorOf(region)} size="h-20 w-20" />
<div className="min-w-0 flex-1">
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
@ -686,13 +589,13 @@ function ChapterDetail({
onClick={onClose}
aria-label="Close details"
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
style={{ border: `1px solid ${color}`, color: color }}
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
>
×
</button>
</div>
<Blocks blocks={chapter.blocks} color={color} />
<Blocks blocks={chapter.blocks} color={colorOf(region)} />
{rows.length > 0 && (
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
@ -710,11 +613,11 @@ function ChapterDetail({
)}
<div className="mt-5 flex flex-wrap gap-3">
{path && (
{orgPath(chapter) && (
<Link
to={path}
to={orgPath(chapter)!}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ background: color, color: "#ffffff" }}
style={{ background: colorOf(region), color: "#ffffff" }}
>
Chapter page
</Link>
@ -725,7 +628,7 @@ function ChapterDetail({
target="_blank"
rel="noopener noreferrer"
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${color}`, color: color }}
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
>
Visit site
</a>
@ -735,7 +638,7 @@ function ChapterDetail({
<a
href={`mailto:${chapter.email}`}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${color}`, color: color }}
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
>
Get in touch
</a>
@ -745,19 +648,7 @@ function ChapterDetail({
);
}
type ChapterGridProps = Pick<Community, "regions" | "chapters" | "chaptersIn" | "subtextFor"> & {
openId: string | null;
setOpenId: (id: string | null) => void;
};
function ChapterGrid({
regions,
chapters,
chaptersIn,
subtextFor,
openId,
setOpenId,
}: ChapterGridProps) {
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
// Only regions that actually have chapters get a grid.
const populated = regions
.map(region => ({ region, list: chaptersIn(region.id) }))
@ -769,15 +660,14 @@ function ChapterGrid({
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{populated.map(({ region, list }) => {
const subtext = subtextFor(region);
const color = colorOf(region);
return (
<section key={region.id} className="mb-12">
<div className="flex items-baseline gap-3 mb-1">
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: color }}
style={{ background: colorOf(region) }}
/>
<h3 className="text-2xl font-800" style={{ color: color }}>
<h3 className="text-2xl font-800" style={{ color: colorOf(region) }}>
{region.name}
</h3>
<span className="text-sm" style={{ color: MUTED }}>
@ -810,7 +700,7 @@ function ChapterGrid({
<ChapterCard
key={c.id}
chapter={c}
color={color}
color={colorOf(region)}
open={openId === c.id}
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
/>
@ -831,12 +721,8 @@ function ChapterGrid({
}
/* The control for the section heading's action bar. */
export function OrgMapToggle({
view,
setView,
accent,
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
const btn = (active: boolean) => ({
export function OrgMapToggle({ view, setView, accent }) {
const btn = active => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
@ -866,14 +752,7 @@ export function OrgMapToggle({
);
}
export default function OrgListMap({
view = "map",
accent = FALLBACK_COLOR,
}: {
/** "map" or "grid". */
view?: string;
accent?: string;
}) {
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
const {
loading,
error,
@ -890,16 +769,16 @@ export default function OrgListMap({
subtextFor,
} = useCommunity();
const [selected, setSelected] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(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<HTMLDivElement>(null);
const regionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const chapterRefs = useRef<Record<string, HTMLLIElement | null>>({});
const listRef = useRef<any>(null);
const regionRefs = useRef<any>({});
const chapterRefs = useRef<any>({});
const scrollListTo = (el: HTMLElement | null | undefined) => {
const scrollListTo = el => {
const box = listRef.current;
if (!box || !el) return;
// Only when the list is its own scroll area (lg and up). Below
@ -917,7 +796,7 @@ export default function OrgListMap({
// Clicking a tile jumps to its first chapter when it has one,
// otherwise to the region it belongs to.
const pickArea = (code: string) => {
const pickArea = code => {
const chapter = chapters.find(c => c.area_code === code);
if (chapter && chapterRefs.current[chapter.id]) {
return scrollListTo(chapterRefs.current[chapter.id]);
@ -926,7 +805,7 @@ export default function OrgListMap({
if (region) scrollListTo(regionRefs.current[region.id]);
};
const shell = (children: ReactNode) => (
const shell = children => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{children}
</div>
@ -962,7 +841,7 @@ export default function OrgListMap({
const shared = { selected, setSelected, hovered, setHovered };
const onMapIds = new Set(regionAreas.map(a => a.region_id));
const block = (region: Region, indent: boolean) => (
const block = (region, indent) => (
<RegionBlock
key={region.id}
region={region}

View file

@ -1,19 +1,11 @@
import {
useId,
useState,
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import { useId, useState } from "react";
import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
import {
areasSentence,
initialsFor,
orgPath,
useOrganizations,
} from "../../data/organizations.js";
} from "../../data/organizations.ts";
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — VERTICAL
@ -55,14 +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.
───────────────────────────────────────────────────────────── */
type RowButtonProps = {
as?: "a" | "button";
color: string;
children: ReactNode;
} & AnchorHTMLAttributes<HTMLAnchorElement> &
ButtonHTMLAttributes<HTMLButtonElement>;
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
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"
@ -74,13 +59,7 @@ function RowButton({ as: As = "button", color, children, ...rest }: RowButtonPro
);
}
function OrgMark({
org,
color,
}: {
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
color: string;
}) {
function OrgMark({ org, color }) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
@ -115,9 +94,7 @@ function OrgMark({
);
}
type RowLabels = { pageLabel: string; siteLabel: string };
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
function OrgRow({ org, pageLabel, siteLabel }) {
const [open, setOpen] = useState(false);
const panelId = useId();
@ -268,12 +245,7 @@ function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationLi
);
}
function Block({
title,
orgs,
pageLabel,
siteLabel,
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
function Block({ title, orgs, pageLabel, siteLabel }) {
if (orgs.length === 0) return null;
return (
@ -290,30 +262,7 @@ function Block({
);
}
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
a.name.localeCompare(b.name);
/* One heading's worth of the list. Labels override the list's own. */
type OrgGroup = {
key: string;
title: string;
pageLabel?: string;
siteLabel?: string;
};
type OrgListVerticalProps = {
kind?: OrgKind;
title?: string;
groups?: OrgGroup[];
/** Which group key an organization files under. */
groupBy?: (org: OrganizationListItem) => string | null | undefined;
accent?: string;
pageLabel?: string;
siteLabel?: string;
/** Null keeps the API's order. */
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
empty?: string;
};
const byName = (a, b) => a.name.localeCompare(b.name);
export default function OrgListVertical({
kind,
@ -325,10 +274,10 @@ export default function OrgListVertical({
siteLabel = "Visit site",
sort = byName,
empty = "· Nothing to show here just yet ·",
}: OrgListVerticalProps) {
}) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = (children: ReactNode) => (
const shell = children => (
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
);

View file

@ -17,6 +17,7 @@
"jsx": "react-jsx",
"types": ["node"],
"strict": true,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]