Compare commits

..

No commits in common. "9b91a9aa781853096b06f9c74acd95ec10b52a88" and "5286cae9ca8410b0b1b75702bbc46e6e503df2ce" have entirely different histories.

39 changed files with 343 additions and 1318 deletions

View file

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

View file

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

View file

@ -29,27 +29,9 @@
/>
═══════════════════════════════════════════════════════════════ */
import type { ReactNode } from "react";
const TEAL = "#138ba0";
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 }) {
export function Section({ section }) {
const {
id,
title,
@ -88,7 +70,7 @@ export function Section({ section }: { section: ShellSection }) {
);
}
export default function PageShell({ title, intro, sections }: PageShellProps) {
export default function PageShell({ title, intro, sections }) {
return (
<>
{/* Page header */}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
/* Types for eventData.js. Rows are GET /events as shapeEvent in
server/src/routes/content.js sends them. */
import type { EventType } from "../lib/eventTypes.ts";
import type { EventListItem } 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[];

View file

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

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

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

View file

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

View file

@ -1,118 +0,0 @@
/* Types for adminSchema.js: the client half of the descriptor-driven
admin CRUD engine. Rows are whatever columns the server-side
descriptor in server/src/admin-schema.js declares, so they stay a
string-keyed record; the descriptors are what's fixed. */
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
* ('region.scope') are side-table objects under their key, and
* child collections are arrays of rows under theirs. */
export type AdminRow = Record<string, unknown>;
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
* along on the lists that filterBy narrows. */
export type AdminOption = {
id: string;
label: string;
kind?: string;
org_id?: string | null;
};
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
export type AdminOptions = Record<string, AdminOption[]>;
export type FieldWidget =
| "text"
| "textarea"
| "select"
| "checkbox"
| "color"
| "number"
| "date";
/** 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,14 +14,12 @@
import { createContext, useContext, useEffect } from "react";
export type AdminTitleValue = { setDetail: (value: string | null) => void };
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
export const AdminTitleContext = createContext(null);
/* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in
the tab. */
export function useAdminDetail(name: string | null | undefined) {
export function useAdminDetail(name) {
const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => {

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

20
src/navConfig.d.ts vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -331,13 +331,7 @@ function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
</a>
))}
{/* 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,
]
{[org.website, org.email, ...org.socials]
.filter((link): link is NonNullable<typeof link> => Boolean(link))
.map((link) => (
<a

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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