Type src/ against API and database shapes, fixing implicit anys #3

Merged
ngu-git-admin merged 1 commit from types/implicit-any into main 2026-09-25 10:25:05 +01:00
39 changed files with 1318 additions and 343 deletions
Showing only changes of commit 2428f4412a - Show all commits

View file

@ -4,7 +4,15 @@ import { Link } from "react-router-dom";
ARROW LINK ARROW LINK
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { 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) {
return ( return (
<Link <Link
to={to} to={to}

View file

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

View file

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

View file

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

View file

@ -32,7 +32,16 @@
able to read and copy. able to read and copy.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import { useRef, useState } from "react"; 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";
const input = const input =
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
@ -56,37 +65,56 @@ const inputLocked =
/* ── Dotted paths ────────────────────────────────────────────── */ /* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object, path) { export function getPath(object: unknown, path: string | null | undefined): unknown {
// An entity with no slug has no heading path either, and a missing // An entity with no slug has no heading path either, and a missing
// path should read as "no value" rather than throwing on .split. // path should read as "no value" rather than throwing on .split.
if (!path) return undefined; if (!path) return undefined;
return path.split(".").reduce((value, key) => value?.[key], object); return path
.split(".")
.reduce<unknown>((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object);
} }
export function setPath(object, path, value) { export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow {
const [head, ...rest] = path.split("."); const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value }; if (rest.length === 0) return { ...object, [head]: value };
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) }; const inner = (object?.[head] ?? {}) as AdminRow;
return { ...object, [head]: setPath(inner, rest.join("."), value) };
} }
/* ── Field ───────────────────────────────────────────────────── */ /* ── Field ───────────────────────────────────────────────────── */
export function Field({ field, value, row, options, error, onChange }) { /* [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) {
const id = `f-${field.path.replace(/\./g, "-")}`; const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text"; const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly); const locked = Boolean(field.readOnly);
const text = value == null ? "" : String(value);
let list = null; let list: Choice[] = [];
let orphaned = false; let orphaned = false;
if (widget === "select") { if (widget === "select") {
list = field.optionsFrom list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o]) ? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o])
: (field.options ?? []).map((o) => : (field.options ?? []).map((o): Choice =>
Array.isArray(o) ? [o[0], o[1]] : [o, o], typeof o === "string" ? [o, o] : [o[0], o[1]],
); );
if (field.filterBy && row) { const { filterBy } = field;
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row)); if (filterBy && row) {
list = list.filter(([, , raw]) => !raw || filterBy(raw, row));
} }
// A stored value with no matching option renders as the blank // A stored value with no matching option renders as the blank
@ -102,8 +130,9 @@ export function Field({ field, value, row, options, error, onChange }) {
const common = { const common = {
id, id,
className: `${input} ${error ? inputError : ""}`, className: `${input} ${error ? inputError : ""}`,
value: value ?? "", value: text,
onChange: (e) => onChange(e.target.value), onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
onChange(e.target.value),
}; };
return ( return (
@ -145,7 +174,7 @@ export function Field({ field, value, row, options, error, onChange }) {
}`} }`}
> >
<option value="">{field.blankLabel ?? "— choose —"}</option> <option value="">{field.blankLabel ?? "— choose —"}</option>
{orphaned && <option value={value}>{value} — no longer exists</option>} {orphaned && <option value={text}>{text} — no longer exists</option>}
{list.map(([id2, label]) => ( {list.map(([id2, label]) => (
<option key={id2} value={id2}> <option key={id2} value={id2}>
{label} {label}
@ -156,7 +185,7 @@ export function Field({ field, value, row, options, error, onChange }) {
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="color" type="color"
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"} value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#138ba0"}
disabled={locked} disabled={locked}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50" className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
@ -188,7 +217,7 @@ export function Field({ field, value, row, options, error, onChange }) {
<input <input
id={id} id={id}
type="text" type="text"
value={value ?? ""} value={text}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45" className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
@ -222,26 +251,36 @@ export function Field({ field, value, row, options, error, onChange }) {
); );
} }
export function FieldGrid({ children }) { export function FieldGrid({ children }: { children: ReactNode }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>; return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
} }
/* ── Repeater ────────────────────────────────────────────────── */ /* ── Repeater ────────────────────────────────────────────────── */
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) { 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) {
const list = rows ?? []; const list = rows ?? [];
// Which row is in flight, and which one it's currently over. // Which row is in flight, and which one it's currently over.
// Both are per-Repeater, which is what keeps a drag inside a // Both are per-Repeater, which is what keeps a drag inside a
// nested collection from being accepted by the outer one. // nested collection from being accepted by the outer one.
const [dragIndex, setDragIndex] = useState(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState(null); const [overIndex, setOverIndex] = useState<number | null>(null);
const rowRefs = useRef([]); const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const update = (index, next) => const update = (index: number, next: AdminRow) =>
onChange(list.map((row, i) => (i === index ? next : row))); onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index, delta) => { const move = (index: number, delta: number) => {
const target = index + delta; const target = index + delta;
if (target < 0 || target >= list.length) return; if (target < 0 || target >= list.length) return;
const next = [...list]; const next = [...list];
@ -249,7 +288,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
onChange(next); onChange(next);
}; };
const relocate = (from, to) => { const relocate = (from: number | null, to: number | null) => {
if (from === to || from == null || to == null) return; if (from === to || from == null || to == null) return;
const next = [...list]; const next = [...list];
const [moved] = next.splice(from, 1); const [moved] = next.splice(from, 1);
@ -382,7 +421,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2"> <div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
<Repeater <Repeater
spec={nested} spec={nested}
rows={row[nested.key]} rows={row[nested.key] as AdminRow[] | undefined}
options={options} options={options}
errors={errors} errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`} errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
@ -398,7 +437,15 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
); );
} }
function IconButton({ label, onClick, danger, disabled, children }) { type IconButtonProps = {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
children: ReactNode;
};
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
return ( return (
<button <button
type="button" type="button"

10
src/data/bannerConfig.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* 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;
};

47
src/data/chapters.d.ts vendored Normal file
View file

@ -0,0 +1,47 @@
/* 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;

27
src/data/eventData.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
/* 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 } 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[];
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[];

7
src/data/feedbackTypes.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/* 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 Normal file
View file

@ -0,0 +1,51 @@
/* 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;

21
src/data/organizations.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
/* 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;

118
src/lib/adminSchema.d.ts vendored Normal file
View file

@ -0,0 +1,118 @@
/* 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";
/** 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";
/** 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";
/* 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

@ -14,12 +14,14 @@
import { createContext, useContext, useEffect } from "react"; import { createContext, useContext, useEffect } from "react";
export const AdminTitleContext = createContext(null); export type AdminTitleValue = { setDetail: (value: string | null) => void };
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
/* Publish the name of whatever this page is showing. Clears on /* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in unmount, so navigating away can't leave a stale record name in
the tab. */ the tab. */
export function useAdminDetail(name) { export function useAdminDetail(name: string | null | undefined) {
const setDetail = useContext(AdminTitleContext)?.setDetail; const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => { useEffect(() => {

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

@ -0,0 +1,26 @@
/* 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

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

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, type ComponentType, type ReactNode } from "react";
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
SECTION MANIFEST SECTION MANIFEST
@ -33,25 +33,69 @@ import { useState } from "react";
want them. want them.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
export function useSectionManifest(manifest) { /* 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[] {
// One entry per section that has a toggle, seeded from its // One entry per section that has a toggle, seeded from its
// declared default so the control is right before anything loads. // declared default so the control is right before anything loads.
const [views, setViews] = useState(() => const [views, setViews] = useState<Record<string, string | null>>(() =>
Object.fromEntries( Object.fromEntries(
manifest manifest
.filter(entry => entry.views) .filter(entry => entry.views)
.map(entry => [ .map(entry => [
entry.id, entry.id,
entry.views.default ?? entry.views.options?.[0] ?? null, entry.views?.default ?? entry.views?.options[0] ?? null,
]), ]),
), ),
); );
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value })); const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
return manifest.map(entry => { return manifest.map(entry => {
const { Component, props, views: spec, ...heading } = entry; const { Component, props, views: spec, ...heading } = entry;
const view = views[entry.id]; const view = views[entry.id] ?? undefined;
const Toggle = spec?.Toggle; const Toggle = spec?.Toggle;
return { return {
@ -59,7 +103,7 @@ export function useSectionManifest(manifest) {
actions: Toggle ? ( actions: Toggle ? (
<Toggle <Toggle
view={view} view={view ?? ""}
setView={value => setView(entry.id, value)} setView={value => setView(entry.id, value)}
accent={entry.accent} accent={entry.accent}
options={spec.options} options={spec.options}

View file

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

View file

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

View file

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

20
src/navConfig.d.ts vendored Normal file
View file

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

View file

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

View file

@ -149,7 +149,15 @@ const EDGE_FADE = `linear-gradient(to right,
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`; transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
{/* Functions */} {/* Functions */}
function WaveText({ text, baseDelay = 0, step = 0.1 }) { function WaveText({
text,
baseDelay = 0,
step = 0.1,
}: {
text: string;
baseDelay?: number;
step?: number;
}) {
return ( return (
<> <>
{text.split("").map((char, i) => ( {text.split("").map((char, i) => (
@ -179,7 +187,7 @@ export default function Home() {
const prev = () => setIndex(i => Math.max(0, i - 1)); const prev = () => setIndex(i => Math.max(0, i - 1));
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1)); const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
const arrowStyle = enabled => ({ const arrowStyle = (enabled: boolean) => ({
border: `1px solid ${TEAL}`, border: `1px solid ${TEAL}`,
background: "rgba(255,255,255,0.6)", background: "rgba(255,255,255,0.6)",
color: enabled ? TEAL : "#b8c6c9", color: enabled ? TEAL : "#b8c6c9",

View file

@ -331,7 +331,13 @@ function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
</a> </a>
))} ))}
{[org.website, org.email, ...org.socials] {/* website and email arrive as bare strings (splitLinks in
shape.js); socials are whole link rows. */}
{[
org.website ? { url: org.website, label: 'Website' } : null,
org.email ? { url: `mailto:${org.email}`, label: org.email } : null,
...org.socials,
]
.filter((link): link is NonNullable<typeof link> => Boolean(link)) .filter((link): link is NonNullable<typeof link> => Boolean(link))
.map((link) => ( .map((link) => (
<a <a

View file

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

View file

@ -25,9 +25,37 @@ import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts"; import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js"; import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"]; /* feedback.status's CHECK values, in triage order. */
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
const STATUS_STYLE = { 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> = {
new: "bg-[#138ba0] text-white", new: "bg-[#138ba0] text-white",
read: "bg-[#eef9fb] text-[#138ba0]", read: "bg-[#eef9fb] text-[#138ba0]",
actioned: "bg-[#eaf3e2] text-[#4a6b2f]", actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
@ -37,7 +65,7 @@ const STATUS_STYLE = {
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari // created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
// won't parse without the T and the Z. // won't parse without the T and the Z.
function formatDate(value) { function formatDate(value: string) {
const date = new Date(`${value.replace(" ", "T")}Z`); const date = new Date(`${value.replace(" ", "T")}Z`);
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
dateStyle: "medium", dateStyle: "medium",
@ -45,26 +73,34 @@ function formatDate(value) {
}); });
} }
function locationOf(row) { function locationOf(row: FeedbackRow) {
if (!row.page_path) return "Not page-specific"; if (!row.page_path) return "Not page-specific";
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path; return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
} }
/* ── One submission ──────────────────────────────────────────── */ /* ── One submission ──────────────────────────────────────────── */
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) { type FeedbackCardProps = {
row: FeedbackRow;
onChange: (row: FeedbackRow) => void;
onRemove: (row: FeedbackRow) => void;
canWrite: boolean;
canRemove: boolean;
};
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
const [note, setNote] = useState(row.admin_note ?? ""); const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? ""); const noteDirty = note !== (row.admin_note ?? "");
async function save(changes) { async function save(changes: FeedbackChanges) {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const data = await patch(`/admin/feedback/${row.id}`, changes); const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
onChange(data.feedback); onChange(data.feedback);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't save that."); setError(err instanceof ApiError ? err.message : "Couldn't save that.");
@ -141,7 +177,8 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
id={`status-${row.id}`} id={`status-${row.id}`}
value={row.status} value={row.status}
disabled={busy} disabled={busy}
onChange={(e) => save({ status: e.target.value })} // The options are STATUSES, so the value is always one of them.
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
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" 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) => ( {STATUSES.map((s) => (
@ -228,22 +265,22 @@ export default function AdminFeedback() {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState("new"); const [status, setStatus] = useState<StatusFilter>("new");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [search, setSearch] = useState(""); // applied, not typed const [search, setSearch] = useState(""); // applied, not typed
const [rows, setRows] = useState([]); const [rows, setRows] = useState<FeedbackRow[]>([]);
const [counts, setCounts] = useState({}); const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
const [cursor, setCursor] = useState(null); const [cursor, setCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
// Minimums, not equality — see lib/roles.ts. // Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user); const canWrite = roleCanWrite(user);
const canRemove = canDelete(user); const canRemove = canDelete(user);
const load = useCallback( const load = useCallback(
async (before = null) => { async (before: number | null = null) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -253,7 +290,7 @@ export default function AdminFeedback() {
if (before) params.set("before", String(before)); if (before) params.set("before", String(before));
try { try {
const data = await get(`/admin/feedback?${params}`, { ttl: 0 }); const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback)); setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
setCounts(data.counts); setCounts(data.counts);
setCursor(data.nextCursor); setCursor(data.nextCursor);
@ -277,7 +314,7 @@ export default function AdminFeedback() {
load(); load();
}, [load]); }, [load]);
function replaceRow(updated) { function replaceRow(updated: FeedbackRow) {
setRows((prev) => setRows((prev) =>
prev prev
.map((row) => (row.id === updated.id ? updated : row)) .map((row) => (row.id === updated.id ? updated : row))
@ -290,7 +327,7 @@ export default function AdminFeedback() {
// The deleted row is passed whole rather than by id: its status // The deleted row is passed whole rather than by id: its status
// is what says which tab count to drop. // is what says which tab count to drop.
function removeRow(removed) { function removeRow(removed: FeedbackRow) {
setRows((prev) => prev.filter((row) => row.id !== removed.id)); setRows((prev) => prev.filter((row) => row.id !== removed.id));
setCounts((prev) => ({ setCounts((prev) => ({
...prev, ...prev,
@ -298,7 +335,7 @@ export default function AdminFeedback() {
})); }));
} }
const tabs = [ const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })), ...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
]; ];

View file

@ -18,17 +18,18 @@
record) wants to be a separate component that fails on its own. record) wants to be a separate component that fails on its own.
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx"; import { useAuth } from "../../lib/auth.tsx";
import { SITE_VERSION } from "../../lib/version.ts"; import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts"; import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js"; import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } from "./adminNav.js";
/* One card. The title link is stretched over the whole card with /* One card. The title link is stretched over the whole card with
`after:absolute`, which makes the card clickable without nesting `after:absolute`, which makes the card clickable without nesting
an anchor inside an anchor; the sub-links sit above it on z-10 so an anchor inside an anchor; the sub-links sit above it on z-10 so
they stay separately clickable. */ they stay separately clickable. */
function NavCard({ item }) { function NavCard({ item }: { item: AdminNavItem }) {
const to = target(item); const to = target(item);
// Drop the child that just repeats the card's own destination — // Drop the child that just repeats the card's own destination —
@ -75,7 +76,15 @@ function NavCard({ item }) {
); );
} }
function CardBlock({ title, blurb, children }) { function CardBlock({
title,
blurb,
children,
}: {
title: string;
blurb?: string;
children: ReactNode;
}) {
return ( return (
<div className="mt-10"> <div className="mt-10">
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-3">
@ -87,7 +96,7 @@ function CardBlock({ title, blurb, children }) {
); );
} }
function PanelSection({ title, children }) { function PanelSection({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0"> <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"> <h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
@ -107,7 +116,7 @@ const QUICK_ADD = [
export default function AdminHome() { export default function AdminHome() {
const { user } = useAuth(); const { user } = useAuth();
const role = ROLE_LABELS[user?.role] ?? user?.role; const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
return ( return (
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start"> <div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">

View file

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

View file

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

View file

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

View file

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

View file

@ -17,20 +17,25 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { get, ApiError } from "../../lib/api.js"; import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js"; import {
ADMIN_ENTITIES,
type AdminOptions,
type AdminRow,
type ListFilter,
} from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts"; import { atLeast } from "../../lib/roles.ts";
export default function EntityList() { export default function EntityList() {
const { entity: entityKey } = useParams(); const { entity: entityKey } = useParams();
const manifest = ADMIN_ENTITIES[entityKey]; const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const [rows, setRows] = useState([]); const [rows, setRows] = useState<AdminRow[]>([]);
const [options, setOptions] = useState({}); const [options, setOptions] = useState<AdminOptions>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState(params.get("q") ?? ""); const [query, setQuery] = useState(params.get("q") ?? "");
// Minimum rank, never equality. POST /api/admin/:entity is gated // Minimum rank, never equality. POST /api/admin/:entity is gated
@ -46,8 +51,8 @@ export default function EntityList() {
setError(null); setError(null);
try { try {
const [list, opts] = await Promise.all([ const [list, opts] = await Promise.all([
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }), get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get("/admin/options", { ttl: 60_000 }), get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
]); ]);
setRows(list.rows); setRows(list.rows);
setOptions(opts.options); setOptions(opts.options);
@ -67,18 +72,18 @@ export default function EntityList() {
return <p className="text-[#4a6b72]">No such thing to edit.</p>; return <p className="text-[#4a6b72]">No such thing to edit.</p>;
} }
function setParam(key, value) { function setParam(key: string, value: string) {
const next = new URLSearchParams(params); const next = new URLSearchParams(params);
if (value) next.set(key, value); if (value) next.set(key, value);
else next.delete(key); else next.delete(key);
setParams(next, { replace: true }); setParams(next, { replace: true });
} }
function labelFor(filter, value) { function labelFor(filter: ListFilter): Array<readonly [string, string]> {
if (filter.optionsFrom) { if (filter.optionsFrom) {
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]); return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
} }
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o])); return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
} }
return ( return (
@ -159,7 +164,7 @@ export default function EntityList() {
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr <tr
key={row.id} key={String(row.id)}
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]" className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)} onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
> >
@ -174,10 +179,12 @@ export default function EntityList() {
? row[column.key] ? row[column.key]
? "Yes" ? "Yes"
: "—" : "—"
: row[column.key] || "—"} : row[column.key]
? String(row[column.key])
: "—"}
</td> </td>
))} ))}
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td> <td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -18,10 +18,10 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx"; import { useAuth } from "../../lib/auth.tsx";
import { atLeast } from "../../lib/roles.ts"; import { atLeast, type Role } from "../../lib/roles.ts";
import { ADMIN_HOME } from "./adminNav.js"; import { ADMIN_HOME } from "./adminNav.js";
export default function RequireRole({ role = "superadmin" }) { export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
// RequireAuth is already showing its own placeholder above this. // RequireAuth is already showing its own placeholder above this.

45
src/pages/admin/adminNav.d.ts vendored Normal file
View file

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

View file

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

View file

@ -1,5 +1,7 @@
import { useState } from "react"; import { useState, type ReactNode } from "react";
import ArrowLink from "../../components/ArrowLink.tsx"; import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
import { import {
initialsFor, initialsFor,
orgPath, orgPath,
@ -33,7 +35,13 @@ const FALLBACK_COLOR = "#4a6b72";
const CARD_MIN = "20rem"; const CARD_MIN = "20rem";
const GRID_MAX = "88rem"; const GRID_MAX = "88rem";
function OrgMark({ org, color }) { function OrgMark({
org,
color,
}: {
org: Pick<OrganizationListItem, "logo" | "name">;
color: string;
}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (org.logo && !failed) { if (org.logo && !failed) {
@ -58,7 +66,14 @@ function OrgMark({ org, color }) {
); );
} }
function OrgCard({ org, accent, pageLabel, siteLabel }) { type CardLabels = { accent: string; pageLabel: string; siteLabel: string };
function OrgCard({
org,
accent,
pageLabel,
siteLabel,
}: CardLabels & { org: OrganizationListItem }) {
const color = org.color || accent; const color = org.color || accent;
const path = orgPath(org); const path = orgPath(org);
@ -136,7 +151,13 @@ function OrgCard({ org, accent, pageLabel, siteLabel }) {
); );
} }
function Block({ title, orgs, accent, pageLabel, siteLabel }) { function Block({
title,
orgs,
accent,
pageLabel,
siteLabel,
}: CardLabels & { title?: string; orgs: OrganizationListItem[] }) {
if (orgs.length === 0) return null; if (orgs.length === 0) return null;
return ( return (
@ -167,7 +188,30 @@ function Block({ title, orgs, accent, pageLabel, siteLabel }) {
); );
} }
const byName = (a, b) => a.name.localeCompare(b.name); 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;
};
export default function OrgListCards({ export default function OrgListCards({
kind, kind,
@ -179,10 +223,10 @@ export default function OrgListCards({
siteLabel = "Visit site", siteLabel = "Visit site",
sort = byName, sort = byName,
empty = "· Nothing to show here just yet ·", empty = "· Nothing to show here just yet ·",
}) { }: OrgListCardsProps) {
const { organizations, loading, error } = useOrganizations(kind); const { organizations, loading, error } = useOrganizations(kind);
const shell = children => ( const shell = (children: ReactNode) => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}> <div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
{children} {children}
</div> </div>

View file

@ -1,9 +1,16 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { useCommunity } from "../../data/chapters.js"; import {
useCommunity,
type Chapter,
type Community,
type Region,
} from "../../data/chapters.js";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import ArrowLink from "../../components/ArrowLink.tsx"; import ArrowLink from "../../components/ArrowLink.tsx";
import { initialsFor, orgPath } from "../../data/organizations.js"; import { initialsFor, orgPath } from "../../data/organizations.js";
import { AREAS, AREA_NAMES } from "../../data/mapGrid.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";
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
ORGANIZATION LIST — MAP ORGANIZATION LIST — MAP
@ -46,6 +53,19 @@ const RULE = "#cfe3e7";
const INK = "#2c4a50"; const INK = "#2c4a50";
const FALLBACK_COLOR = "#4a6b72"; 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 US_TITLE = "US Unity Regions"; const US_TITLE = "US Unity Regions";
const INTL_TITLE = "International Unity Regions"; const INTL_TITLE = "International Unity Regions";
@ -55,7 +75,7 @@ const INTL_TITLE = "International Unity Regions";
vanishing. When organization and person pages arrive this should vanishing. When organization and person pages arrive this should
move to a shared component; small enough to live here until then. move to a shared component; small enough to live here until then.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
function Blocks({ blocks = [], color }) { function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string }) {
if (blocks.length === 0) return null; if (blocks.length === 0) return null;
return ( return (
@ -88,7 +108,7 @@ function Blocks({ blocks = [], color }) {
case "links": case "links":
return ( return (
<ul key={i} className="list-disc ml-5 flex flex-col gap-1"> <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}> <li key={j}>
{item.url ? ( {item.url ? (
<a <a
@ -134,6 +154,19 @@ function Blocks({ blocks = [], color }) {
subtracts, and the order the slices arrive in doesn't change subtracts, and the order the slices arrive in doesn't change
what's drawn. 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({ function Tile({
code, code,
x, x,
@ -149,11 +182,12 @@ function Tile({
setHovered, setHovered,
onPick, onPick,
fontSize = 34, fontSize = 34,
}) { }: TileProps) {
if (slices.length === 0) return null; if (slices.length === 0) return null;
const primary = slices[0]; const primary = slices[0];
const ids = slices.map(s => s.regionId); // Nullable so indexOf and includes take `selected` as it is.
const ids: Array<string | null> = slices.map(s => s.regionId);
const active = ids.includes(selected) || ids.includes(hovered); const active = ids.includes(selected) || ids.includes(hovered);
const dimmed = selected && !ids.includes(selected); const dimmed = selected && !ids.includes(selected);
const clipId = `clip-${code}`; const clipId = `clip-${code}`;
@ -196,7 +230,7 @@ function Tile({
y={top} y={top}
width={width} width={width}
height={h} height={h}
fill={slice.color} fill={colorOf(slice)}
fillOpacity={opacity} fillOpacity={opacity}
className="transition-all duration-200" className="transition-all duration-200"
/> />
@ -211,7 +245,7 @@ function Tile({
height={size} height={size}
rx={14} rx={14}
fill="none" fill="none"
stroke={active ? primary.color : "#ffffff"} stroke={active ? colorOf(primary) : "#ffffff"}
strokeOpacity={active ? 1 : 0.55} strokeOpacity={active ? 1 : 0.55}
strokeWidth={active ? 4 : 2} strokeWidth={active ? 4 : 2}
className="transition-all duration-200" className="transition-all duration-200"
@ -224,7 +258,7 @@ function Tile({
dominantBaseline="middle" dominantBaseline="middle"
fontSize={fontSize} fontSize={fontSize}
fontWeight="800" fontWeight="800"
fill={count ? "#ffffff" : primary.color} fill={count ? "#ffffff" : colorOf(primary)}
style={{ pointerEvents: "none" }} style={{ pointerEvents: "none" }}
> >
{label || code} {label || code}
@ -244,7 +278,13 @@ function Tile({
); );
} }
function RegionMap({ slices, chapterCounts, ...props }) { type RegionMapProps = Highlight & {
slices: Record<string, AreaSlice[]>;
chapterCounts: Record<string, number>;
onPick?: (code: string) => void;
};
function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
const size = TILE - PAD * 2; const size = TILE - PAD * 2;
return ( return (
@ -275,8 +315,15 @@ function RegionMap({ slices, chapterCounts, ...props }) {
); );
} }
function LegendButton({ region, selected, setSelected, hovered, setHovered }) { function LegendButton({
region,
selected,
setSelected,
hovered,
setHovered,
}: Highlight & { region: Region }) {
const on = selected === region.id || hovered === region.id; const on = selected === region.id || hovered === region.id;
const color = colorOf(region);
return ( return (
<button <button
onClick={() => setSelected(selected === region.id ? null : region.id)} onClick={() => setSelected(selected === region.id ? null : region.id)}
@ -287,29 +334,36 @@ function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
aria-pressed={selected === region.id} 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" className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
style={{ style={{
border: `1px solid ${region.color}`, border: `1px solid ${color}`,
background: on ? region.color : "transparent", background: on ? color : "transparent",
color: on ? "#ffffff" : region.color, color: on ? "#ffffff" : color,
opacity: selected && selected !== region.id ? 0.45 : 1, opacity: selected && selected !== region.id ? 0.45 : 1,
}} }}
> >
<span <span
className="h-2.5 w-2.5 rounded-full" className="h-2.5 w-2.5 rounded-full"
style={{ background: on ? "#ffffff" : region.color }} style={{ background: on ? "#ffffff" : color }}
/> />
{region.name} {region.name}
</button> </button>
); );
} }
function Legend({ domestic, international, onMapIds, ...props }) { type LegendProps = Highlight & {
domestic: Region[];
international: Region[];
/** Regions that paint at least one tile. */
onMapIds: Set<string>;
};
function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
const { selected, setSelected } = props; const { selected, setSelected } = props;
// A region is on the map if it paints a tile. West Central used // A region is on the map if it paints a tile. West Central used
// to need a hardcoded exception here because its states arrived // to need a hardcoded exception here because its states arrived
// only through SPLITS; it has ordinary rows now, so the exception // only through SPLITS; it has ordinary rows now, so the exception
// is gone. // is gone.
const onMap = region => onMapIds.has(region.id); const onMap = (region: Region) => onMapIds.has(region.id);
const us = domestic.filter(onMap); const us = domestic.filter(onMap);
const intl = international.filter(onMap); const intl = international.filter(onMap);
@ -347,6 +401,15 @@ function Legend({ domestic, international, onMapIds, ...props }) {
); );
} }
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({ function RegionBlock({
region, region,
chapters, chapters,
@ -357,12 +420,15 @@ function RegionBlock({
indent, indent,
regionRefs, regionRefs,
chapterRefs, chapterRefs,
}) { }: RegionBlockProps) {
const on = selected === region.id; const on = selected === region.id;
const color = colorOf(region);
return ( return (
<div <div
ref={el => regionRefs && (regionRefs.current[region.id] = el)} ref={el => {
if (regionRefs) regionRefs.current[region.id] = el;
}}
className="transition-opacity duration-200" className="transition-opacity duration-200"
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }} style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
> >
@ -374,11 +440,11 @@ function RegionBlock({
> >
<span <span
className="h-3 w-3 rounded-full shrink-0" className="h-3 w-3 rounded-full shrink-0"
style={{ background: region.color }} style={{ background: color }}
/> />
<h4 <h4
className={`font-800 ${indent ? "text-lg" : "text-xl"}`} className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
style={{ color: region.color }} style={{ color: color }}
> >
{region.name} {region.name}
</h4> </h4>
@ -399,55 +465,60 @@ function RegionBlock({
</p> </p>
) : ( ) : (
<ul className="ml-5 mb-4 flex flex-col gap-3"> <ul className="ml-5 mb-4 flex flex-col gap-3">
{chapters.map(c => ( {chapters.map(c => {
<li const path = orgPath(c);
key={c.id} return (
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)} <li
className="pl-3 flex items-start gap-3" key={c.id}
style={{ borderLeft: `2px solid ${region.color}` }} ref={el => {
> if (chapterRefs) chapterRefs.current[c.id] = el;
<div className="min-w-0 flex-1"> }}
<p className="font-700">{c.name}</p> className="pl-3 flex items-start gap-3"
<p className="text-sm" style={{ color: FALLBACK_COLOR }}> style={{ borderLeft: `2px solid ${color}` }}
{c.location_label} >
{c.meets ? ` · ${c.meets}` : ""} <div className="min-w-0 flex-1">
</p> <p className="font-700">{c.name}</p>
{(c.website || c.email) && ( <p className="text-sm" style={{ color: FALLBACK_COLOR }}>
<p className="text-sm mt-1 flex gap-4"> {c.location_label}
{c.website && ( {c.meets ? ` · ${c.meets}` : ""}
<a
href={c.website}
target="_blank"
rel="noopener noreferrer"
className="font-700 underline"
style={{ color: region.color }}
>
Details
</a>
)}
{c.email && (
<a
href={`mailto:${c.email}`}
className="font-700 underline"
style={{ color: region.color }}
>
Contact
</a>
)}
</p> </p>
)} {(c.website || c.email) && (
</div> <p className="text-sm mt-1 flex gap-4">
{c.website && (
<a
href={c.website}
target="_blank"
rel="noopener noreferrer"
className="font-700 underline"
style={{ color: color }}
>
Details
</a>
)}
{c.email && (
<a
href={`mailto:${c.email}`}
className="font-700 underline"
style={{ color: color }}
>
Contact
</a>
)}
</p>
)}
</div>
{orgPath(c) && ( {path && (
<ArrowLink <ArrowLink
to={orgPath(c)} to={path}
label={`${c.name} — chapter page`} label={`${c.name} — chapter page`}
color={region.color} color={color}
size="h-8 w-8" size="h-8 w-8"
/> />
)} )}
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</div> </div>
@ -462,7 +533,15 @@ function RegionBlock({
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
/* Logo, or the organization's initials when there's no file. */ /* Logo, or the organization's initials when there's no file. */
function OrgLogo({ org, color, size = "h-14 w-14" }) { function OrgLogo({
org,
color,
size = "h-14 w-14",
}: {
org: Pick<OrganizationListItem, "logo" | "name">;
color: string;
size?: string;
}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (org.logo && !failed) { if (org.logo && !failed) {
@ -487,7 +566,16 @@ function OrgLogo({ org, color, size = "h-14 w-14" }) {
); );
} }
function ChapterCard({ chapter, color, open, onOpen }) { type ChapterCardProps = {
chapter: Chapter;
color: string;
open: boolean;
onOpen: () => void;
};
function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
const path = orgPath(chapter);
return ( return (
<div <div
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200" className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
@ -539,9 +627,9 @@ function ChapterCard({ chapter, color, open, onOpen }) {
</svg> </svg>
</button> </button>
{orgPath(chapter) && ( {path && (
<ArrowLink <ArrowLink
to={orgPath(chapter)} to={path}
label={`${chapter.name} — chapter page`} label={`${chapter.name} — chapter page`}
color={color} color={color}
/> />
@ -551,7 +639,18 @@ function ChapterCard({ chapter, color, open, onOpen }) {
); );
} }
function ChapterDetail({ chapter, region, onClose }) { function ChapterDetail({
chapter,
region,
onClose,
}: {
chapter: Chapter;
region: Region;
onClose: () => void;
}) {
const path = orgPath(chapter);
const color = colorOf(region);
/* "Led by" comes from affiliations rather than a text field, so /* "Led by" comes from affiliations rather than a text field, so
it lists real people and stays empty until they exist. */ it lists real people and stays empty until they exist. */
const leads = (chapter.leadership ?? []) const leads = (chapter.leadership ?? [])
@ -560,21 +659,23 @@ function ChapterDetail({ chapter, region, onClose }) {
) )
.join(", "); .join(", ");
const rows = [ const rows = (
["Region", region.name], [
["Where", chapter.venue], ["Region", region.name],
["Meets", chapter.meets], ["Where", chapter.venue],
["Led by", leads], ["Meets", chapter.meets],
["Since", chapter.started], ["Led by", leads],
].filter(([, v]) => v); ["Since", chapter.started],
] satisfies Array<[label: string, value: string | null | undefined]>
).filter(([, v]) => v);
return ( return (
<div <div
className="rounded-2xl p-6 mb-6" className="rounded-2xl p-6 mb-6"
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }} style={{ border: `2px solid ${color}`, background: `${color}0f` }}
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" /> <OrgLogo org={chapter} color={color} size="h-20 w-20" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-2xl font-900 leading-tight">{chapter.name}</p> <p className="text-2xl font-900 leading-tight">{chapter.name}</p>
@ -585,13 +686,13 @@ function ChapterDetail({ chapter, region, onClose }) {
onClick={onClose} onClick={onClose}
aria-label="Close details" 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" 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 ${region.color}`, color: region.color }} style={{ border: `1px solid ${color}`, color: color }}
> >
× ×
</button> </button>
</div> </div>
<Blocks blocks={chapter.blocks} color={region.color} /> <Blocks blocks={chapter.blocks} color={color} />
{rows.length > 0 && ( {rows.length > 0 && (
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm"> <dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
@ -609,11 +710,11 @@ function ChapterDetail({ chapter, region, onClose }) {
)} )}
<div className="mt-5 flex flex-wrap gap-3"> <div className="mt-5 flex flex-wrap gap-3">
{orgPath(chapter) && ( {path && (
<Link <Link
to={orgPath(chapter)} to={path}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105" className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ background: region.color, color: "#ffffff" }} style={{ background: color, color: "#ffffff" }}
> >
Chapter page Chapter page
</Link> </Link>
@ -624,7 +725,7 @@ function ChapterDetail({ chapter, region, onClose }) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105" className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${region.color}`, color: region.color }} style={{ border: `1px solid ${color}`, color: color }}
> >
Visit site Visit site
</a> </a>
@ -634,7 +735,7 @@ function ChapterDetail({ chapter, region, onClose }) {
<a <a
href={`mailto:${chapter.email}`} href={`mailto:${chapter.email}`}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105" className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${region.color}`, color: region.color }} style={{ border: `1px solid ${color}`, color: color }}
> >
Get in touch Get in touch
</a> </a>
@ -644,7 +745,19 @@ function ChapterDetail({ chapter, region, onClose }) {
); );
} }
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) { 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) {
// Only regions that actually have chapters get a grid. // Only regions that actually have chapters get a grid.
const populated = regions const populated = regions
.map(region => ({ region, list: chaptersIn(region.id) })) .map(region => ({ region, list: chaptersIn(region.id) }))
@ -656,14 +769,15 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}> <div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{populated.map(({ region, list }) => { {populated.map(({ region, list }) => {
const subtext = subtextFor(region); const subtext = subtextFor(region);
const color = colorOf(region);
return ( return (
<section key={region.id} className="mb-12"> <section key={region.id} className="mb-12">
<div className="flex items-baseline gap-3 mb-1"> <div className="flex items-baseline gap-3 mb-1">
<span <span
className="h-3 w-3 rounded-full shrink-0" className="h-3 w-3 rounded-full shrink-0"
style={{ background: region.color }} style={{ background: color }}
/> />
<h3 className="text-2xl font-800" style={{ color: region.color }}> <h3 className="text-2xl font-800" style={{ color: color }}>
{region.name} {region.name}
</h3> </h3>
<span className="text-sm" style={{ color: MUTED }}> <span className="text-sm" style={{ color: MUTED }}>
@ -696,7 +810,7 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
<ChapterCard <ChapterCard
key={c.id} key={c.id}
chapter={c} chapter={c}
color={region.color} color={color}
open={openId === c.id} open={openId === c.id}
onOpen={() => setOpenId(openId === c.id ? null : c.id)} onOpen={() => setOpenId(openId === c.id ? null : c.id)}
/> />
@ -717,8 +831,12 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
} }
/* The control for the section heading's action bar. */ /* The control for the section heading's action bar. */
export function OrgMapToggle({ view, setView, accent }) { export function OrgMapToggle({
const btn = active => ({ view,
setView,
accent,
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
const btn = (active: boolean) => ({
background: active ? accent : "transparent", background: active ? accent : "transparent",
color: active ? "#ffffff" : accent, color: active ? "#ffffff" : accent,
}); });
@ -748,7 +866,14 @@ export function OrgMapToggle({ view, setView, accent }) {
); );
} }
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) { export default function OrgListMap({
view = "map",
accent = FALLBACK_COLOR,
}: {
/** "map" or "grid". */
view?: string;
accent?: string;
}) {
const { const {
loading, loading,
error, error,
@ -765,16 +890,16 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
subtextFor, subtextFor,
} = useCommunity(); } = useCommunity();
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState<string | null>(null);
const [hovered, setHovered] = useState(null); const [hovered, setHovered] = useState<string | null>(null);
const [openId, setOpenId] = useState(null); // no card open on arrival const [openId, setOpenId] = useState<string | null>(null); // no card open on arrival
// The list scrolls itself to whatever the map or legend points at. // The list scrolls itself to whatever the map or legend points at.
const listRef = useRef(null); const listRef = useRef<HTMLDivElement>(null);
const regionRefs = useRef({}); const regionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const chapterRefs = useRef({}); const chapterRefs = useRef<Record<string, HTMLLIElement | null>>({});
const scrollListTo = el => { const scrollListTo = (el: HTMLElement | null | undefined) => {
const box = listRef.current; const box = listRef.current;
if (!box || !el) return; if (!box || !el) return;
// Only when the list is its own scroll area (lg and up). Below // Only when the list is its own scroll area (lg and up). Below
@ -792,7 +917,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
// Clicking a tile jumps to its first chapter when it has one, // Clicking a tile jumps to its first chapter when it has one,
// otherwise to the region it belongs to. // otherwise to the region it belongs to.
const pickArea = code => { const pickArea = (code: string) => {
const chapter = chapters.find(c => c.area_code === code); const chapter = chapters.find(c => c.area_code === code);
if (chapter && chapterRefs.current[chapter.id]) { if (chapter && chapterRefs.current[chapter.id]) {
return scrollListTo(chapterRefs.current[chapter.id]); return scrollListTo(chapterRefs.current[chapter.id]);
@ -801,7 +926,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
if (region) scrollListTo(regionRefs.current[region.id]); if (region) scrollListTo(regionRefs.current[region.id]);
}; };
const shell = children => ( const shell = (children: ReactNode) => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}> <div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{children} {children}
</div> </div>
@ -837,7 +962,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
const shared = { selected, setSelected, hovered, setHovered }; const shared = { selected, setSelected, hovered, setHovered };
const onMapIds = new Set(regionAreas.map(a => a.region_id)); const onMapIds = new Set(regionAreas.map(a => a.region_id));
const block = (region, indent) => ( const block = (region: Region, indent: boolean) => (
<RegionBlock <RegionBlock
key={region.id} key={region.id}
region={region} region={region}

View file

@ -1,5 +1,13 @@
import { useId, useState } from "react"; import {
useId,
useState,
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import ArrowLink from "../../components/ArrowLink.tsx"; import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
import { import {
areasSentence, areasSentence,
initialsFor, initialsFor,
@ -47,7 +55,14 @@ const FALLBACK_COLOR = "#4a6b72";
it — a link nested in a button is invalid, and a screen reader it — a link nested in a button is invalid, and a screen reader
announces the whole row as one confused control. announces the whole row as one confused control.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
function RowButton({ as: As = "button", color, children, ...rest }) { type RowButtonProps = {
as?: "a" | "button";
color: string;
children: ReactNode;
} & AnchorHTMLAttributes<HTMLAnchorElement> &
ButtonHTMLAttributes<HTMLButtonElement>;
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
return ( return (
<As <As
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105" className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
@ -59,7 +74,13 @@ function RowButton({ as: As = "button", color, children, ...rest }) {
); );
} }
function OrgMark({ org, color }) { function OrgMark({
org,
color,
}: {
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
color: string;
}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (org.logo && !failed) { if (org.logo && !failed) {
@ -94,7 +115,9 @@ function OrgMark({ org, color }) {
); );
} }
function OrgRow({ org, pageLabel, siteLabel }) { type RowLabels = { pageLabel: string; siteLabel: string };
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const panelId = useId(); const panelId = useId();
@ -245,7 +268,12 @@ function OrgRow({ org, pageLabel, siteLabel }) {
); );
} }
function Block({ title, orgs, pageLabel, siteLabel }) { function Block({
title,
orgs,
pageLabel,
siteLabel,
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
if (orgs.length === 0) return null; if (orgs.length === 0) return null;
return ( return (
@ -262,7 +290,30 @@ function Block({ title, orgs, pageLabel, siteLabel }) {
); );
} }
const byName = (a, b) => a.name.localeCompare(b.name); 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;
};
export default function OrgListVertical({ export default function OrgListVertical({
kind, kind,
@ -274,10 +325,10 @@ export default function OrgListVertical({
siteLabel = "Visit site", siteLabel = "Visit site",
sort = byName, sort = byName,
empty = "· Nothing to show here just yet ·", empty = "· Nothing to show here just yet ·",
}) { }: OrgListVerticalProps) {
const { organizations, loading, error } = useOrganizations(kind); const { organizations, loading, error } = useOrganizations(kind);
const shell = children => ( const shell = (children: ReactNode) => (
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div> <div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
); );