Compare commits
2 commits
5286cae9ca
...
9b91a9aa78
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b91a9aa78 | |||
|
|
2428f4412a |
39 changed files with 1318 additions and 343 deletions
|
|
@ -4,7 +4,15 @@ import { Link } from "react-router-dom";
|
|||
ARROW LINK
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
|
||||
type ArrowLinkProps = {
|
||||
to: string;
|
||||
label: string;
|
||||
color: string;
|
||||
/** Tailwind size classes for the circle. */
|
||||
size?: string;
|
||||
};
|
||||
|
||||
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default function Layout() {
|
|||
|
||||
const targets = sections
|
||||
.map((s) => document.querySelector(s.hash))
|
||||
.filter(Boolean);
|
||||
.filter((el): el is Element => el !== null);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
|
|
|
|||
|
|
@ -29,9 +29,27 @@
|
|||
/>
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TEAL = "#138ba0";
|
||||
|
||||
export function Section({ section }) {
|
||||
export type ShellSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb?: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
actions?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type PageShellProps = {
|
||||
title: ReactNode;
|
||||
intro?: ReactNode;
|
||||
sections: ShellSection[];
|
||||
};
|
||||
|
||||
export function Section({ section }: { section: ShellSection }) {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
|
|
@ -70,7 +88,7 @@ export function Section({ section }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function PageShell({ title, intro, sections }) {
|
||||
export default function PageShell({ title, intro, sections }: PageShellProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Page header */}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export default function PeopleTiles({
|
|||
|
||||
Promise.all(
|
||||
specs.map((spec) =>
|
||||
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
|
||||
get<TeamResponse>(`/teams/${spec.id}/people`, { ttl }).then((data) => ({
|
||||
spec,
|
||||
data,
|
||||
})),
|
||||
|
|
@ -198,8 +198,8 @@ export default function PeopleTiles({
|
|||
let live = true;
|
||||
setFailed(false);
|
||||
|
||||
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
|
||||
.then((data: { people: Person[] }) => {
|
||||
get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
|
||||
.then((data) => {
|
||||
if (!live) return;
|
||||
const byId: Record<string, Person> = {};
|
||||
for (const person of data.people) byId[String(person.id)] = person;
|
||||
|
|
@ -304,11 +304,10 @@ function resolveAll(
|
|||
}
|
||||
|
||||
const { peopleslug, ...overrides } = entry;
|
||||
const merged: Person = { ...base };
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
resolved.push(merged);
|
||||
const defined: Partial<Person> = Object.fromEntries(
|
||||
Object.entries(overrides).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
resolved.push({ ...base, ...defined });
|
||||
}
|
||||
|
||||
return resolved;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,16 @@
|
|||
able to read and copy.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, type ChangeEvent, type ReactNode } from "react";
|
||||
|
||||
import type { FieldErrors } from "../../lib/api.js";
|
||||
import type {
|
||||
AdminFieldSpec,
|
||||
AdminOption,
|
||||
AdminOptions,
|
||||
AdminRow,
|
||||
CollectionSpec,
|
||||
} from "../../lib/adminSchema.js";
|
||||
|
||||
const input =
|
||||
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
||||
|
|
@ -56,37 +65,56 @@ const inputLocked =
|
|||
|
||||
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||
|
||||
export function getPath(object, path) {
|
||||
export function getPath(object: unknown, path: string | null | undefined): unknown {
|
||||
// An entity with no slug has no heading path either, and a missing
|
||||
// path should read as "no value" rather than throwing on .split.
|
||||
if (!path) return undefined;
|
||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object);
|
||||
}
|
||||
|
||||
export function setPath(object, path, value) {
|
||||
export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow {
|
||||
const [head, ...rest] = path.split(".");
|
||||
if (rest.length === 0) return { ...object, [head]: value };
|
||||
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
|
||||
const inner = (object?.[head] ?? {}) as AdminRow;
|
||||
return { ...object, [head]: setPath(inner, rest.join("."), value) };
|
||||
}
|
||||
|
||||
/* ── Field ───────────────────────────────────────────────────── */
|
||||
|
||||
export function Field({ field, value, row, options, error, onChange }) {
|
||||
/* [value, label, the option row it came from]. Manifest options
|
||||
have no row, which is what filterBy's `!raw` lets through. */
|
||||
type Choice = [id: string, label: string, raw?: AdminOption];
|
||||
|
||||
type FieldProps = {
|
||||
field: AdminFieldSpec;
|
||||
/* Whatever the row holds at field.path; shown as text. */
|
||||
value: unknown;
|
||||
row?: AdminRow;
|
||||
options?: AdminOptions | null;
|
||||
error?: string;
|
||||
onChange: (value: string | number) => void;
|
||||
};
|
||||
|
||||
export function Field({ field, value, row, options, error, onChange }: FieldProps) {
|
||||
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||
const widget = field.widget ?? "text";
|
||||
const locked = Boolean(field.readOnly);
|
||||
const text = value == null ? "" : String(value);
|
||||
|
||||
let list = null;
|
||||
let list: Choice[] = [];
|
||||
let orphaned = false;
|
||||
|
||||
if (widget === "select") {
|
||||
list = field.optionsFrom
|
||||
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
|
||||
: (field.options ?? []).map((o) =>
|
||||
Array.isArray(o) ? [o[0], o[1]] : [o, o],
|
||||
? (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]],
|
||||
);
|
||||
if (field.filterBy && row) {
|
||||
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
|
||||
const { filterBy } = field;
|
||||
if (filterBy && row) {
|
||||
list = list.filter(([, , raw]) => !raw || filterBy(raw, row));
|
||||
}
|
||||
|
||||
// A stored value with no matching option renders as the blank
|
||||
|
|
@ -102,8 +130,9 @@ export function Field({ field, value, row, options, error, onChange }) {
|
|||
const common = {
|
||||
id,
|
||||
className: `${input} ${error ? inputError : ""}`,
|
||||
value: value ?? "",
|
||||
onChange: (e) => onChange(e.target.value),
|
||||
value: text,
|
||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||
onChange(e.target.value),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -145,7 +174,7 @@ export function Field({ field, value, row, options, error, onChange }) {
|
|||
}`}
|
||||
>
|
||||
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
||||
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
||||
{orphaned && <option value={text}>{text} — no longer exists</option>}
|
||||
{list.map(([id2, label]) => (
|
||||
<option key={id2} value={id2}>
|
||||
{label}
|
||||
|
|
@ -156,7 +185,7 @@ export function Field({ field, value, row, options, error, onChange }) {
|
|||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
||||
value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#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"
|
||||
|
|
@ -188,7 +217,7 @@ export function Field({ field, value, row, options, error, onChange }) {
|
|||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
value={text}
|
||||
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"
|
||||
|
|
@ -222,26 +251,36 @@ export function Field({ field, value, row, options, error, onChange }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function FieldGrid({ children }) {
|
||||
export function FieldGrid({ children }: { children: ReactNode }) {
|
||||
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
/* ── Repeater ────────────────────────────────────────────────── */
|
||||
|
||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
||||
type RepeaterProps = {
|
||||
spec: CollectionSpec;
|
||||
rows: AdminRow[] | null | undefined;
|
||||
options?: AdminOptions | null;
|
||||
errors?: Partial<FieldErrors> | null;
|
||||
/** Where this collection's rows sit in the server's error keys. */
|
||||
errorPrefix: string;
|
||||
onChange: (rows: AdminRow[]) => void;
|
||||
};
|
||||
|
||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }: RepeaterProps) {
|
||||
const list = rows ?? [];
|
||||
|
||||
// Which row is in flight, and which one it's currently over.
|
||||
// Both are per-Repeater, which is what keeps a drag inside a
|
||||
// nested collection from being accepted by the outer one.
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
const [overIndex, setOverIndex] = useState(null);
|
||||
const rowRefs = useRef([]);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
|
||||
const update = (index, next) =>
|
||||
const update = (index: number, next: AdminRow) =>
|
||||
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||
|
||||
const move = (index, delta) => {
|
||||
const move = (index: number, delta: number) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= list.length) return;
|
||||
const next = [...list];
|
||||
|
|
@ -249,7 +288,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
|
|||
onChange(next);
|
||||
};
|
||||
|
||||
const relocate = (from, to) => {
|
||||
const relocate = (from: number | null, to: number | null) => {
|
||||
if (from === to || from == null || to == null) return;
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(from, 1);
|
||||
|
|
@ -382,7 +421,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
|
|||
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
||||
<Repeater
|
||||
spec={nested}
|
||||
rows={row[nested.key]}
|
||||
rows={row[nested.key] as AdminRow[] | undefined}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
||||
|
|
@ -398,7 +437,15 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
|
|||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, onClick, danger, disabled, children }) {
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
10
src/data/bannerConfig.d.ts
vendored
Normal file
10
src/data/bannerConfig.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* Types for bannerConfig.js. */
|
||||
|
||||
export type BannerPart = { text: string; href?: string; external?: boolean };
|
||||
|
||||
export declare const SITE_BANNER: {
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
content: BannerPart[];
|
||||
dismissible: boolean;
|
||||
};
|
||||
47
src/data/chapters.d.ts
vendored
Normal file
47
src/data/chapters.d.ts
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* Types for chapters.js: GET /organizations rows with their
|
||||
kind-specific details lifted to the top level. */
|
||||
|
||||
import type { OrganizationListItem, RegionArea, RegionScope } from "../lib/useContent.ts";
|
||||
import type { AreaSlice, RegionAreaRow } from "./mapGrid.js";
|
||||
|
||||
export { initialsFor } from "./organizations.js";
|
||||
|
||||
export type Region = OrganizationListItem & {
|
||||
scope: RegionScope | null;
|
||||
map_note: string | null;
|
||||
areas: RegionArea[];
|
||||
};
|
||||
|
||||
export type Chapter = OrganizationListItem & {
|
||||
region_id: string | null;
|
||||
region_name: string | null;
|
||||
region_color: string | null;
|
||||
meets: string | null;
|
||||
started: string | null;
|
||||
/** The map tile this chapter lights up, or null if it has none. */
|
||||
area_code: string | null;
|
||||
};
|
||||
|
||||
export type Community = {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
|
||||
regions: Region[];
|
||||
regionAreas: RegionAreaRow[];
|
||||
regionById: Record<string, Region>;
|
||||
domestic: Region[];
|
||||
international: Region[];
|
||||
virtual: Region[];
|
||||
|
||||
chapters: Chapter[];
|
||||
chaptersIn: (regionId: string) => Chapter[];
|
||||
|
||||
slices: Record<string, AreaSlice[]>;
|
||||
chapterCounts: Record<string, number>;
|
||||
regionsForArea: (areaCode: string) => Region[];
|
||||
|
||||
areasLabelFor: (regionId: string) => string;
|
||||
subtextFor: (region: Pick<Region, "id" | "map_note">) => string;
|
||||
};
|
||||
|
||||
export declare function useCommunity(): Community;
|
||||
27
src/data/eventData.d.ts
vendored
Normal file
27
src/data/eventData.d.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* Types for eventData.js. Rows are GET /events as shapeEvent in
|
||||
server/src/routes/content.js sends them. */
|
||||
|
||||
import type { EventType } from "../lib/eventTypes.ts";
|
||||
import type { EventListItem } from "../lib/useContent.ts";
|
||||
|
||||
export type EventFilter = {
|
||||
section?: string;
|
||||
host?: string;
|
||||
status?: EventListItem["status"];
|
||||
type?: EventType | EventType[];
|
||||
};
|
||||
|
||||
export declare function useEvents(filter?: EventFilter): {
|
||||
events: EventListItem[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export declare function splitByStatus<T extends { status: string }>(
|
||||
events?: T[],
|
||||
): { upcoming: T[]; past: T[] };
|
||||
|
||||
export declare function typesPresent<D extends { id: string }>(
|
||||
events?: Array<{ event_type: string }>,
|
||||
declared?: readonly D[],
|
||||
): D[];
|
||||
7
src/data/feedbackTypes.d.ts
vendored
Normal file
7
src/data/feedbackTypes.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/* Types for feedbackTypes.js. */
|
||||
|
||||
export type FeedbackType = { id: string; label: string; hint: string };
|
||||
|
||||
export declare const FEEDBACK_TYPES: FeedbackType[];
|
||||
|
||||
export declare function feedbackTypeLabel(id: string): string;
|
||||
51
src/data/mapGrid.d.ts
vendored
Normal file
51
src/data/mapGrid.d.ts
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/* Types for mapGrid.js. */
|
||||
|
||||
import type { RegionArea } from "../lib/useContent.ts";
|
||||
|
||||
export declare const GRID_COLS: number;
|
||||
export declare const GRID_ROWS: number;
|
||||
|
||||
export declare const AREA_NAMES: Record<string, string>;
|
||||
|
||||
/** One tile: a state, or a band such as CANADA with a span. */
|
||||
export type MapArea = {
|
||||
code: string;
|
||||
name: string;
|
||||
col: number;
|
||||
row: number;
|
||||
span: number;
|
||||
isState: boolean;
|
||||
};
|
||||
|
||||
export declare const AREAS: readonly MapArea[];
|
||||
export declare const AREA_BY_CODE: Record<string, MapArea>;
|
||||
|
||||
/** A region_areas row flattened with its region, as buildAreaSlices takes it. */
|
||||
export type RegionAreaRow = RegionArea & { region_id: string };
|
||||
|
||||
/** One region's share of a tile. */
|
||||
export type AreaSlice = {
|
||||
regionId: string;
|
||||
name: string;
|
||||
color: string | null | undefined;
|
||||
share: number;
|
||||
edge: RegionArea["edge"];
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
type Locatable = {
|
||||
is_online?: boolean;
|
||||
country?: string | null;
|
||||
state_code?: string | null;
|
||||
};
|
||||
|
||||
export declare function areaForChapter(chapter: Locatable | null | undefined): string | null;
|
||||
|
||||
export declare function buildAreaSlices(
|
||||
regionAreas?: RegionAreaRow[],
|
||||
regions?: Array<{ id: string; name: string; color?: string | null }>,
|
||||
): Record<string, AreaSlice[]>;
|
||||
|
||||
export declare function countChaptersByArea(chapters?: Locatable[]): Record<string, number>;
|
||||
|
||||
export declare function areasLabel(regionId: string, regionAreas?: RegionAreaRow[]): string;
|
||||
21
src/data/organizations.d.ts
vendored
Normal file
21
src/data/organizations.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/* Types for organizations.js. Rows are GET /organizations as
|
||||
shapeOrganization in server/src/routes/content.js sends them. */
|
||||
|
||||
import type { OrgKind } from "../lib/hrefs.ts";
|
||||
import type { OrganizationListItem, RegionArea } from "../lib/useContent.ts";
|
||||
|
||||
export declare function useOrganizations(kind?: OrgKind): {
|
||||
organizations: OrganizationListItem[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export declare function orgPath(
|
||||
org: { id: string; kind?: string | null } | null | undefined,
|
||||
): string | null;
|
||||
|
||||
export declare function areasSentence(
|
||||
areas?: Array<Pick<RegionArea, "area_code" | "note">>,
|
||||
): string;
|
||||
|
||||
export declare function initialsFor(name?: string): string;
|
||||
118
src/lib/adminSchema.d.ts
vendored
Normal file
118
src/lib/adminSchema.d.ts
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/* Types for adminSchema.js: the client half of the descriptor-driven
|
||||
admin CRUD engine. Rows are whatever columns the server-side
|
||||
descriptor in server/src/admin-schema.js declares, so they stay a
|
||||
string-keyed record; the descriptors are what's fixed. */
|
||||
|
||||
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
|
||||
* ('region.scope') are side-table objects under their key, and
|
||||
* child collections are arrays of rows under theirs. */
|
||||
export type AdminRow = Record<string, unknown>;
|
||||
|
||||
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
|
||||
* along on the lists that filterBy narrows. */
|
||||
export type AdminOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
org_id?: string | null;
|
||||
};
|
||||
|
||||
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
|
||||
export type AdminOptions = Record<string, AdminOption[]>;
|
||||
|
||||
export type FieldWidget =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "color"
|
||||
| "number"
|
||||
| "date";
|
||||
|
||||
/** A bare value, or [value, label]. */
|
||||
export type SelectOption = string | readonly [string, string];
|
||||
|
||||
/** Show a group or collection only when another field has this value. */
|
||||
export type FieldCondition = { path: string; value: unknown };
|
||||
|
||||
export type AdminFieldSpec = {
|
||||
path: string;
|
||||
label: string;
|
||||
widget?: FieldWidget;
|
||||
required?: boolean;
|
||||
full?: boolean;
|
||||
help?: string;
|
||||
options?: readonly SelectOption[];
|
||||
optionsFrom?: string;
|
||||
blankLabel?: string;
|
||||
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
|
||||
/* Set by EntityEdit on the id field rather than in a manifest. */
|
||||
readOnly?: boolean;
|
||||
prefix?: string;
|
||||
prefixPending?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type FieldGroupSpec = {
|
||||
legend: string;
|
||||
note?: string;
|
||||
when?: FieldCondition;
|
||||
fields: AdminFieldSpec[];
|
||||
};
|
||||
|
||||
export type CollectionSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
addLabel?: string;
|
||||
note?: string;
|
||||
when?: FieldCondition;
|
||||
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
|
||||
blank: AdminRow;
|
||||
fields: AdminFieldSpec[];
|
||||
children?: CollectionSpec[];
|
||||
};
|
||||
|
||||
export type ListColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
widget?: "bool";
|
||||
};
|
||||
|
||||
export type ListFilter = {
|
||||
key: string;
|
||||
label: string;
|
||||
options?: readonly SelectOption[];
|
||||
optionsFrom?: string;
|
||||
};
|
||||
|
||||
export type EntitySpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
singular: string;
|
||||
idLabel: string;
|
||||
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
||||
idKind?: "auto";
|
||||
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
||||
slugFrom?: string | string[];
|
||||
titleFrom?: string;
|
||||
list: { columns: ListColumn[]; filters: ListFilter[] };
|
||||
groups: FieldGroupSpec[];
|
||||
children?: CollectionSpec[];
|
||||
};
|
||||
|
||||
export type AdminEntityKey =
|
||||
| "organizations"
|
||||
| "events"
|
||||
| "people"
|
||||
| "teams"
|
||||
| "awards"
|
||||
| "timeline";
|
||||
|
||||
/* Indexed by route param as often as by name, so any other string
|
||||
reads as possibly missing. */
|
||||
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
|
||||
readonly [key: string]: EntitySpec | undefined;
|
||||
};
|
||||
|
||||
export declare function slugify(value: unknown): string;
|
||||
|
|
@ -14,12 +14,14 @@
|
|||
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
|
||||
export const AdminTitleContext = createContext(null);
|
||||
export type AdminTitleValue = { setDetail: (value: string | null) => void };
|
||||
|
||||
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
|
||||
|
||||
/* Publish the name of whatever this page is showing. Clears on
|
||||
unmount, so navigating away can't leave a stale record name in
|
||||
the tab. */
|
||||
export function useAdminDetail(name) {
|
||||
export function useAdminDetail(name: string | null | undefined) {
|
||||
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
26
src/lib/api.d.ts
vendored
Normal file
26
src/lib/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* Types for api.js. The response type is the caller's to name:
|
||||
every endpoint sends a different body, so get<T> defaults to
|
||||
unknown rather than pretending to know. */
|
||||
|
||||
/** Per-field messages, as admin-crud.js and feedback.js send them. */
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
export declare class ApiError extends Error {
|
||||
status?: number;
|
||||
fields?: FieldErrors;
|
||||
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
|
||||
}
|
||||
|
||||
export declare function get<T = unknown>(
|
||||
path: string,
|
||||
options?: { ttl?: number; fallback?: T },
|
||||
): Promise<T>;
|
||||
|
||||
export declare function invalidate(path?: string): void;
|
||||
|
||||
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
|
||||
|
||||
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
|
||||
|
||||
/* A 204 comes back as null. */
|
||||
export declare function del<T = null>(path: string): Promise<T>;
|
||||
|
|
@ -13,21 +13,40 @@
|
|||
staring at an empty table.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { get, post, ApiError } from "./api.js";
|
||||
import type { Role } from "./roles.ts";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
/* What currentUser() in server/src/auth.js returns, and what
|
||||
/auth/me and /auth/login send under `user`. */
|
||||
export type AdminUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(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);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
get("/auth/me", { ttl: 0 })
|
||||
get<AuthResponse>("/auth/me", { ttl: 0 })
|
||||
.then((data) => {
|
||||
if (!ignore) setUser(data.user);
|
||||
})
|
||||
|
|
@ -43,8 +62,8 @@ export function AuthProvider({ children }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const data = await post("/auth/login", { email, password });
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await post<AuthResponse>("/auth/login", { email, password });
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}, []);
|
||||
|
|
@ -73,7 +92,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) {
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ComponentType, type ReactNode } from "react";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION MANIFEST
|
||||
|
|
@ -33,25 +33,69 @@ import { useState } from "react";
|
|||
want them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export function useSectionManifest(manifest) {
|
||||
/* What every section Component is handed on top of its own props. */
|
||||
export type SectionInjected = { accent: string; view?: string };
|
||||
|
||||
export type SectionToggleProps = {
|
||||
view: string;
|
||||
setView: (value: string) => void;
|
||||
accent: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type SectionViews = {
|
||||
options: string[];
|
||||
default?: string;
|
||||
Toggle: ComponentType<SectionToggleProps>;
|
||||
};
|
||||
|
||||
export type SectionHeading = {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb?: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
};
|
||||
|
||||
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
|
||||
Component: ComponentType<P & SectionInjected>;
|
||||
/* The Component decides P; props are checked against it. */
|
||||
props?: NoInfer<P>;
|
||||
views?: SectionViews;
|
||||
};
|
||||
|
||||
/* A manifest mixes components with different props, which no single
|
||||
array element type can check. This checks each entry against its
|
||||
own Component where it's written, then forgets P so the entries
|
||||
fit one array. */
|
||||
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
|
||||
return entry as unknown as SectionEntry;
|
||||
}
|
||||
|
||||
export type ManifestSection = SectionHeading & {
|
||||
actions?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
|
||||
// One entry per section that has a toggle, seeded from its
|
||||
// declared default so the control is right before anything loads.
|
||||
const [views, setViews] = useState(() =>
|
||||
const [views, setViews] = useState<Record<string, string | null>>(() =>
|
||||
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, value) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
|
||||
return manifest.map(entry => {
|
||||
const { Component, props, views: spec, ...heading } = entry;
|
||||
const view = views[entry.id];
|
||||
const view = views[entry.id] ?? undefined;
|
||||
const Toggle = spec?.Toggle;
|
||||
|
||||
return {
|
||||
|
|
@ -59,7 +103,7 @@ export function useSectionManifest(manifest) {
|
|||
|
||||
actions: Toggle ? (
|
||||
<Toggle
|
||||
view={view}
|
||||
view={view ?? ""}
|
||||
setView={value => setView(entry.id, value)}
|
||||
accent={entry.accent}
|
||||
options={spec.options}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
* and the public/ layout stay the frontend's business.
|
||||
*/
|
||||
|
||||
import type { OrgKind } from './hrefs.ts'
|
||||
|
||||
export type DatePrecision = 'year' | 'month' | 'day'
|
||||
|
||||
/** What an entry is about. Drives the marker and the body layout. */
|
||||
|
|
@ -41,6 +43,8 @@ export type TimelineRef = {
|
|||
kind: RefKind
|
||||
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||
id: string
|
||||
/** Only on organizations: history.js copies v_timeline.org_kind. */
|
||||
orgKind?: OrgKind
|
||||
}
|
||||
|
||||
/** Filename plus the table it came from; the directory is derived
|
||||
|
|
|
|||
|
|
@ -103,12 +103,22 @@ export type EventRecord = {
|
|||
hosts: EventHost[]
|
||||
description: string[]
|
||||
links: Link[]
|
||||
instagram?: Link | null
|
||||
/** The instagram link's label, the handle. See splitLinks in shape.js. */
|
||||
instagram?: string | null
|
||||
blocks: ContentBlock[]
|
||||
people: EventPerson[]
|
||||
awards: EventAward[]
|
||||
}
|
||||
|
||||
/** One row of GET /events: shapeEvent without the detail-only
|
||||
* blocks, people and awards. */
|
||||
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
|
||||
|
||||
/** An event_sections row, as GET /events sends it beside the list. */
|
||||
export type EventSection = { id: string; name: string; sort_order: number }
|
||||
|
||||
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
|
||||
|
||||
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||
|
||||
|
|
@ -154,6 +164,17 @@ export type OrgEvent = {
|
|||
color?: string | null
|
||||
}
|
||||
|
||||
/** regions.scope's CHECK values. */
|
||||
export type RegionScope = 'domestic' | 'international' | 'virtual'
|
||||
|
||||
/** A region_areas row as attachRegionDetails sends it. */
|
||||
export type RegionArea = {
|
||||
area_code: string
|
||||
share: number
|
||||
edge: 'top' | 'bottom' | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export type OrganizationRecord = {
|
||||
id: string
|
||||
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||
|
|
@ -173,14 +194,16 @@ export type OrganizationRecord = {
|
|||
blocks: ContentBlock[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
website?: Link | null
|
||||
email?: Link | null
|
||||
instagram?: Link | null
|
||||
/* splitLinks in shape.js lifts these out as bare strings: the
|
||||
website's url, the email's label, the instagram handle. */
|
||||
website?: string | null
|
||||
email?: string | null
|
||||
instagram?: string | null
|
||||
/** Shape depends on `kind`; empty object for national and partner. */
|
||||
details: {
|
||||
scope?: string | null
|
||||
scope?: RegionScope | null
|
||||
map_note?: string | null
|
||||
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
||||
areas?: RegionArea[]
|
||||
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||
region_id?: string | null
|
||||
region_name?: string | null
|
||||
|
|
@ -194,6 +217,14 @@ export type OrganizationRecord = {
|
|||
events: OrgEvent[]
|
||||
}
|
||||
|
||||
/** One row of GET /organizations: the card surface and details,
|
||||
* without the sections only the org's own page loads. */
|
||||
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
|
||||
|
||||
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||
|
||||
|
|
@ -211,7 +242,8 @@ export type TeamRecord = {
|
|||
description: string[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
instagram?: Link | null
|
||||
/** The instagram handle. See splitLinks in shape.js. */
|
||||
instagram?: string | null
|
||||
blocks: ContentBlock[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
|||
setError(null)
|
||||
setNotFound(false)
|
||||
try {
|
||||
const body = await get(path as string)
|
||||
const body = await get<Record<string, unknown> | null>(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
Normal file
20
src/navConfig.d.ts
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/* Types for navConfig.js. */
|
||||
|
||||
export type PageLink = { label: string; path: string };
|
||||
|
||||
export type PageSectionLink = { label: string; hash: string };
|
||||
|
||||
export type NavAction = {
|
||||
label: string;
|
||||
to: string;
|
||||
/** Picks the styling, not the destination. */
|
||||
variant: "ghost" | "fancy";
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export declare const PAGE_LINKS: PageLink[];
|
||||
|
||||
/** Keyed by the owning page's path. */
|
||||
export declare const PAGE_SECTIONS: Record<string, PageSectionLink[]>;
|
||||
|
||||
export declare const NAV_ACTIONS: NavAction[];
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import PageShell from "../components/PageShell.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
|
||||
import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
|
||||
import 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() {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell from '../components/PageShell.tsx'
|
||||
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||
|
|
@ -85,7 +85,7 @@ export default function EventDetail() {
|
|||
const accent = event.color || TEAL
|
||||
const groups = peopleGroups(event.people, accent)
|
||||
|
||||
const sections = [
|
||||
const sections: ShellSection[] = [
|
||||
{
|
||||
id: 'about',
|
||||
title: event.theme || 'About',
|
||||
|
|
|
|||
|
|
@ -149,7 +149,15 @@ const EDGE_FADE = `linear-gradient(to right,
|
|||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||||
|
||||
{/* Functions */}
|
||||
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
|
||||
function WaveText({
|
||||
text,
|
||||
baseDelay = 0,
|
||||
step = 0.1,
|
||||
}: {
|
||||
text: string;
|
||||
baseDelay?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{text.split("").map((char, i) => (
|
||||
|
|
@ -179,7 +187,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 => ({
|
||||
const arrowStyle = (enabled: boolean) => ({
|
||||
border: `1px solid ${TEAL}`,
|
||||
background: "rgba(255,255,255,0.6)",
|
||||
color: enabled ? TEAL : "#b8c6c9",
|
||||
|
|
|
|||
|
|
@ -331,7 +331,13 @@ function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
|
|||
</a>
|
||||
))}
|
||||
|
||||
{[org.website, org.email, ...org.socials]
|
||||
{/* website and email arrive as bare strings (splitLinks in
|
||||
shape.js); socials are whole link rows. */}
|
||||
{[
|
||||
org.website ? { url: org.website, label: 'Website' } : null,
|
||||
org.email ? { url: `mailto:${org.email}`, label: org.email } : null,
|
||||
...org.socials,
|
||||
]
|
||||
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||
.map((link) => (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import PageShell from "../components/PageShell.tsx";
|
||||
import { useSectionManifest } from "../lib/sections.tsx";
|
||||
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
|
||||
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
|
|
@ -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" };
|
||||
const RETREATS = { type: "retreat" } as const;
|
||||
|
||||
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" },
|
||||
},
|
||||
}),
|
||||
|
||||
*/
|
||||
];
|
||||
|
|
|
|||
|
|
@ -25,9 +25,37 @@ import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
|||
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
/* feedback.status's CHECK values, in triage order. */
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
|
||||
|
||||
const STATUS_STYLE = {
|
||||
type FeedbackStatus = (typeof STATUSES)[number];
|
||||
|
||||
/* The columns GET /api/admin/feedback selects. */
|
||||
type FeedbackRow = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
feedback_type: string;
|
||||
message: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
page_path: string | null;
|
||||
section_id: string | null;
|
||||
status: FeedbackStatus;
|
||||
admin_note: string | null;
|
||||
};
|
||||
|
||||
type FeedbackPage = {
|
||||
feedback: FeedbackRow[];
|
||||
/** Unfiltered, one per status. */
|
||||
counts: Record<FeedbackStatus, number>;
|
||||
nextCursor: number | null;
|
||||
};
|
||||
|
||||
type FeedbackChanges = { status?: FeedbackStatus; admin_note?: string };
|
||||
|
||||
type StatusFilter = FeedbackStatus | "all";
|
||||
|
||||
const STATUS_STYLE: Record<FeedbackStatus, string> = {
|
||||
new: "bg-[#138ba0] text-white",
|
||||
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||
|
|
@ -37,7 +65,7 @@ const STATUS_STYLE = {
|
|||
|
||||
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
||||
// won't parse without the T and the Z.
|
||||
function formatDate(value) {
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
|
|
@ -45,26 +73,34 @@ function formatDate(value) {
|
|||
});
|
||||
}
|
||||
|
||||
function locationOf(row) {
|
||||
function locationOf(row: FeedbackRow) {
|
||||
if (!row.page_path) return "Not page-specific";
|
||||
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
||||
}
|
||||
|
||||
/* ── One submission ──────────────────────────────────────────── */
|
||||
|
||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
||||
type FeedbackCardProps = {
|
||||
row: FeedbackRow;
|
||||
onChange: (row: FeedbackRow) => void;
|
||||
onRemove: (row: FeedbackRow) => void;
|
||||
canWrite: boolean;
|
||||
canRemove: boolean;
|
||||
};
|
||||
|
||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
|
||||
const [note, setNote] = useState(row.admin_note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const noteDirty = note !== (row.admin_note ?? "");
|
||||
|
||||
async function save(changes) {
|
||||
async function save(changes: FeedbackChanges) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||
const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
|
||||
onChange(data.feedback);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||
|
|
@ -141,7 +177,8 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
|||
id={`status-${row.id}`}
|
||||
value={row.status}
|
||||
disabled={busy}
|
||||
onChange={(e) => save({ status: e.target.value })}
|
||||
// The options are STATUSES, so the value is always one of them.
|
||||
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
|
||||
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
|
|
@ -228,22 +265,22 @@ export default function AdminFeedback() {
|
|||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState("new");
|
||||
const [status, setStatus] = useState<StatusFilter>("new");
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState(""); // applied, not typed
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [counts, setCounts] = useState({});
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [rows, setRows] = useState<FeedbackRow[]>([]);
|
||||
const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Minimums, not equality — see lib/roles.ts.
|
||||
const canWrite = roleCanWrite(user);
|
||||
const canRemove = canDelete(user);
|
||||
|
||||
const load = useCallback(
|
||||
async (before = null) => {
|
||||
async (before: number | null = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -253,7 +290,7 @@ export default function AdminFeedback() {
|
|||
if (before) params.set("before", String(before));
|
||||
|
||||
try {
|
||||
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||
setCounts(data.counts);
|
||||
setCursor(data.nextCursor);
|
||||
|
|
@ -277,7 +314,7 @@ export default function AdminFeedback() {
|
|||
load();
|
||||
}, [load]);
|
||||
|
||||
function replaceRow(updated) {
|
||||
function replaceRow(updated: FeedbackRow) {
|
||||
setRows((prev) =>
|
||||
prev
|
||||
.map((row) => (row.id === updated.id ? updated : row))
|
||||
|
|
@ -290,7 +327,7 @@ export default function AdminFeedback() {
|
|||
|
||||
// The deleted row is passed whole rather than by id: its status
|
||||
// is what says which tab count to drop.
|
||||
function removeRow(removed) {
|
||||
function removeRow(removed: FeedbackRow) {
|
||||
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -298,7 +335,7 @@ export default function AdminFeedback() {
|
|||
}));
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
|
||||
{ id: "all", label: "All" },
|
||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,17 +18,18 @@
|
|||
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 } from "./adminNav.js";
|
||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } from "./adminNav.js";
|
||||
|
||||
/* One card. The title link is stretched over the whole card with
|
||||
`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 }) {
|
||||
function NavCard({ item }: { item: AdminNavItem }) {
|
||||
const to = target(item);
|
||||
|
||||
// Drop the child that just repeats the card's own destination —
|
||||
|
|
@ -75,7 +76,15 @@ function NavCard({ item }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CardBlock({ title, blurb, children }) {
|
||||
function CardBlock({
|
||||
title,
|
||||
blurb,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
blurb?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex items-baseline gap-3">
|
||||
|
|
@ -87,7 +96,7 @@ function CardBlock({ title, blurb, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, children }) {
|
||||
function PanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -107,7 +116,7 @@ const QUICK_ADD = [
|
|||
|
||||
export default function AdminHome() {
|
||||
const { user } = useAuth();
|
||||
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||
const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||
|
|
|
|||
|
|
@ -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(null);
|
||||
const stableSet = useCallback((value) => setDetail(value), []);
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const stableSet = useCallback((value: string | null) => setDetail(value), []);
|
||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
starts working.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } 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(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||
|
||||
async function handleSubmit(event) {
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,23 +24,54 @@
|
|||
the reason shows up before the click rather than after it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { del, get, patch } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||
import { ROLES, ROLE_LABELS, ROLE_NOTES, type Role } from "../../lib/roles.ts";
|
||||
|
||||
/* An admin_users row as panel.js selects it, with its live session count. */
|
||||
type PanelUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
sessions: number;
|
||||
};
|
||||
|
||||
/* GET /api/admin/panel/overview. A count is null when its table
|
||||
doesn't exist yet. */
|
||||
type Overview = {
|
||||
system: {
|
||||
schemaVersion: number;
|
||||
dbPath: string | null;
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
uptimeSeconds: number;
|
||||
startedAt: string;
|
||||
sessions: number | null;
|
||||
roles: Role[];
|
||||
};
|
||||
content: Array<{ label: string; count: number | null }>;
|
||||
users: PanelUser[];
|
||||
};
|
||||
|
||||
type UserResponse = { user: PanelUser };
|
||||
|
||||
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
||||
marker that says so. Left alone, browsers read it as local time
|
||||
and last-login drifts by the timezone offset. */
|
||||
function when(value) {
|
||||
function when(value: string | null | undefined) {
|
||||
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) {
|
||||
function uptime(seconds: number | null | undefined) {
|
||||
if (seconds == null) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
|
|
@ -50,7 +81,15 @@ function uptime(seconds) {
|
|||
return `${m}m`;
|
||||
}
|
||||
|
||||
function Block({ title, note, children }) {
|
||||
function Block({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mt-8 first:mt-0">
|
||||
<div className="flex items-baseline gap-3">
|
||||
|
|
@ -62,7 +101,7 @@ function Block({ title, note, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -77,23 +116,23 @@ export default function AdminPanel() {
|
|||
const { user: me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [data, setData] = useState<Overview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||
// the row so a failure on one account doesn't blank the table.
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [rowError, setRowError] = useState(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||
setData(await get<Overview>("/admin/panel/overview", { ttl: 0 }));
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setError(err.message || "Couldn't load the panel.");
|
||||
setError((err instanceof Error && err.message) || "Couldn't load the panel.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -105,7 +144,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) {
|
||||
function mergeUser(updated: PanelUser) {
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
|
|
@ -116,30 +155,37 @@ export default function AdminPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
async function run(id, work) {
|
||||
async function run(id: number, work: () => Promise<PanelUser>) {
|
||||
setBusyId(id);
|
||||
setRowError(null);
|
||||
try {
|
||||
mergeUser(await work());
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setRowError({ id, message: err.message || "That didn't work." });
|
||||
setRowError({ id, message: (err instanceof Error && err.message) || "That didn't work." });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = (row, role) =>
|
||||
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||
|
||||
const setActive = (row, is_active) =>
|
||||
const changeRole = (row: PanelUser, role: Role) =>
|
||||
run(
|
||||
row.id,
|
||||
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||
async () => (await patch<UserResponse>(`/admin/panel/users/${row.id}`, { role })).user,
|
||||
);
|
||||
|
||||
const revoke = (row) =>
|
||||
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).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,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -164,6 +210,9 @@ export default function AdminPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
// Not loading and no error means the overview arrived.
|
||||
if (!data) return null;
|
||||
|
||||
const { system, content, users } = data;
|
||||
const activeSupers = users.filter(
|
||||
(u) => u.role === "superadmin" && u.is_active === 1,
|
||||
|
|
@ -254,7 +303,7 @@ export default function AdminPanel() {
|
|||
<select
|
||||
value={row.role}
|
||||
disabled={locked || busy}
|
||||
onChange={(e) => changeRole(row, e.target.value)}
|
||||
onChange={(e) => changeRole(row, e.target.value as Role)}
|
||||
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
||||
title={
|
||||
isMe
|
||||
|
|
|
|||
|
|
@ -43,26 +43,36 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||
import { get, post, patch, del, ApiError, type FieldErrors } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||
import {
|
||||
ADMIN_ENTITIES,
|
||||
slugify,
|
||||
type AdminOptions,
|
||||
type AdminRow,
|
||||
type FieldCondition,
|
||||
} 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, singular) {
|
||||
function friendly(message: string, singular: string): string {
|
||||
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 = ADMIN_ENTITIES[entityKey];
|
||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
|
@ -79,9 +89,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 = Array.isArray(manifest?.slugFrom)
|
||||
const slugPaths: string[] = Array.isArray(manifest?.slugFrom)
|
||||
? manifest.slugFrom
|
||||
: [manifest?.slugFrom].filter(Boolean);
|
||||
: [manifest?.slugFrom].filter((path): path is string => Boolean(path));
|
||||
|
||||
// Everything before the last path is a qualifier: a fact about
|
||||
// another field rather than something to type. It renders as
|
||||
|
|
@ -91,7 +101,7 @@ export default function EntityEdit() {
|
|||
|
||||
// Empty until every qualifier is chosen, because half a prefix
|
||||
// would be saved into an id that then never matches.
|
||||
const prefixOf = (source) => {
|
||||
const prefixOf = (source: AdminRow | null) => {
|
||||
if (qualifierPaths.length === 0) return "";
|
||||
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||
if (parts.some((part) => !part)) return "";
|
||||
|
|
@ -103,9 +113,9 @@ export default function EntityEdit() {
|
|||
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||
: "";
|
||||
|
||||
const tailOf = (source) => {
|
||||
const tailOf = (source: AdminRow | null) => {
|
||||
const prefix = prefixOf(source);
|
||||
const value = source?.id ?? "";
|
||||
const value = String(source?.id ?? "");
|
||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
};
|
||||
|
||||
|
|
@ -114,24 +124,27 @@ export default function EntityEdit() {
|
|||
// names the field to read instead.
|
||||
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [options, setOptions] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [message, setMessage] = useState(null);
|
||||
// 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 [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
// The last state the server confirmed. Everything else compares
|
||||
// against this to decide whether there's anything to lose.
|
||||
const baseline = useRef(null);
|
||||
const baseline = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||
const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 });
|
||||
setOptions(opts.options);
|
||||
|
||||
if (isNew) {
|
||||
|
|
@ -141,12 +154,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 = autoId ? {} : { id: "" };
|
||||
const blank: AdminRow = autoId ? {} : { id: "" };
|
||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||
setForm(blank);
|
||||
baseline.current = JSON.stringify(blank);
|
||||
} else {
|
||||
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
const data = await get<RowResponse>(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
}
|
||||
|
|
@ -181,7 +194,7 @@ export default function EntityEdit() {
|
|||
: isNew
|
||||
? `New ${manifest.singular}`
|
||||
: form
|
||||
? getPath(form, headingPath) || form.id
|
||||
? headingOf(form)
|
||||
: null,
|
||||
);
|
||||
|
||||
|
|
@ -189,7 +202,7 @@ export default function EntityEdit() {
|
|||
// Router entirely, so the only hook available is this one.
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const warn = (event) => {
|
||||
const warn = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
|
|
@ -202,18 +215,19 @@ export default function EntityEdit() {
|
|||
|
||||
/* ── Heading ───────────────────────────────────────────────── */
|
||||
|
||||
const heading = getPath(form, headingPath) || form.id;
|
||||
const heading = headingOf(form);
|
||||
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
||||
|
||||
const children = manifest.children ?? [];
|
||||
|
||||
/* ── Actions ───────────────────────────────────────────────── */
|
||||
|
||||
const leave = (to) => {
|
||||
const leave = (to: string) => {
|
||||
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const change = (path, value) => {
|
||||
const change = (path: string, value: unknown) => {
|
||||
setForm((prev) => {
|
||||
let next = setPath(prev, path, value);
|
||||
// Recompose the id whenever one of its sources moves. The
|
||||
|
|
@ -235,20 +249,20 @@ export default function EntityEdit() {
|
|||
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = isNew
|
||||
? await post(`/admin/${manifest.key}`, form)
|
||||
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||
? await post<RowResponse>(`/admin/${manifest.key}`, form)
|
||||
: await patch<RowResponse>(`/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}/${data.row.id}`, { replace: true });
|
||||
if (isNew) navigate(`/admin/${manifest.key}/${String(data.row.id)}`, { replace: true });
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
|
||||
|
|
@ -273,9 +287,9 @@ export default function EntityEdit() {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function remove() {
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||
|
||||
try {
|
||||
|
|
@ -291,9 +305,9 @@ export default function EntityEdit() {
|
|||
: "Couldn't delete that.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||
const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value;
|
||||
|
||||
return (
|
||||
<div className="pb-24">
|
||||
|
|
@ -316,8 +330,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} #{form.id}
|
||||
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||
{manifest.idLabel} #{String(form.id)}
|
||||
{updatedAt && <> · last saved {updatedAt}</>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -344,9 +358,9 @@ export default function EntityEdit() {
|
|||
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||
}}
|
||||
/>
|
||||
{!isNew && form.updated_at && (
|
||||
{!isNew && updatedAt && (
|
||||
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||
Last saved {form.updated_at}
|
||||
Last saved {updatedAt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -388,7 +402,7 @@ export default function EntityEdit() {
|
|||
<Repeater
|
||||
key={child.key}
|
||||
spec={child}
|
||||
rows={form[child.key]}
|
||||
rows={form[child.key] as AdminRow[] | undefined}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${child.key}.`}
|
||||
|
|
|
|||
|
|
@ -17,20 +17,25 @@ 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 } from "../../lib/adminSchema.js";
|
||||
import {
|
||||
ADMIN_ENTITIES,
|
||||
type AdminOptions,
|
||||
type AdminRow,
|
||||
type ListFilter,
|
||||
} from "../../lib/adminSchema.js";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
|
||||
export default function EntityList() {
|
||||
const { entity: entityKey } = useParams();
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [options, setOptions] = useState({});
|
||||
const [rows, setRows] = useState<AdminRow[]>([]);
|
||||
const [options, setOptions] = useState<AdminOptions>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
|
||||
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||
|
|
@ -46,8 +51,8 @@ export default function EntityList() {
|
|||
setError(null);
|
||||
try {
|
||||
const [list, opts] = await Promise.all([
|
||||
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get("/admin/options", { ttl: 60_000 }),
|
||||
get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
|
||||
]);
|
||||
setRows(list.rows);
|
||||
setOptions(opts.options);
|
||||
|
|
@ -67,18 +72,18 @@ export default function EntityList() {
|
|||
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||
}
|
||||
|
||||
function setParam(key, value) {
|
||||
function setParam(key: string, value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
function labelFor(filter, value) {
|
||||
function labelFor(filter: ListFilter): Array<readonly [string, string]> {
|
||||
if (filter.optionsFrom) {
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
|
||||
}
|
||||
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
||||
return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -159,7 +164,7 @@ export default function EntityList() {
|
|||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
key={String(row.id)}
|
||||
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
||||
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
||||
>
|
||||
|
|
@ -174,10 +179,12 @@ export default function EntityList() {
|
|||
? row[column.key]
|
||||
? "Yes"
|
||||
: "—"
|
||||
: row[column.key] || "—"}
|
||||
: row[column.key]
|
||||
? String(row[column.key])
|
||||
: "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@
|
|||
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
import { atLeast, type Role } from "../../lib/roles.ts";
|
||||
import { ADMIN_HOME } from "./adminNav.js";
|
||||
|
||||
export default function RequireRole({ role = "superadmin" }) {
|
||||
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// RequireAuth is already showing its own placeholder above this.
|
||||
|
|
|
|||
45
src/pages/admin/adminNav.d.ts
vendored
Normal file
45
src/pages/admin/adminNav.d.ts
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* Types for adminNav.js. */
|
||||
|
||||
import type { AdminUser } from "../../lib/auth.tsx";
|
||||
|
||||
export declare const ADMIN_HOME: string;
|
||||
export declare const ADMIN_PANEL: string;
|
||||
|
||||
export type AdminArea = "home" | "cms" | "panel";
|
||||
|
||||
export declare const AREA_TITLES: Record<AdminArea, string>;
|
||||
|
||||
export type AdminNavLink = {
|
||||
to: string;
|
||||
label: string;
|
||||
/** Only read by the home cards. */
|
||||
blurb?: string;
|
||||
};
|
||||
|
||||
type AdminNavBase = {
|
||||
label: string;
|
||||
blurb?: string;
|
||||
separated?: boolean;
|
||||
superOnly?: boolean;
|
||||
};
|
||||
|
||||
/** A tab. One with no `to` of its own opens its first child, so it
|
||||
* must have one. */
|
||||
export type AdminNavItem = AdminNavBase &
|
||||
(
|
||||
| { to: string; children?: AdminNavLink[] }
|
||||
| { to?: undefined; children: [AdminNavLink, ...AdminNavLink[]] }
|
||||
);
|
||||
|
||||
export declare const CMS_NAV: AdminNavItem[];
|
||||
export declare const FORMS_NAV: AdminNavItem & { children: [AdminNavLink, ...AdminNavLink[]] };
|
||||
export declare const PANEL_NAV: AdminNavItem;
|
||||
export declare const NAV: AdminNavItem[];
|
||||
|
||||
export declare function navFor(user: AdminUser | null | undefined): AdminNavItem[];
|
||||
|
||||
export declare const matches: (pathname: string, to: string | null | undefined) => boolean;
|
||||
|
||||
export declare const target: (item: AdminNavItem) => string;
|
||||
|
||||
export declare function areaFor(pathname: string): AdminArea;
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
||||
import {
|
||||
splitByStatus,
|
||||
typesPresent,
|
||||
useEvents,
|
||||
type EventFilter,
|
||||
} from "../../data/eventData.js";
|
||||
import { eventHref } from "../../lib/hrefs.ts";
|
||||
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
|
||||
import type { EventListItem } from "../../lib/useContent.ts";
|
||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT LIST — CARDS
|
||||
|
|
@ -24,7 +31,7 @@ import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
|||
nothing but retreats shows no control at all.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
|
@ -33,7 +40,7 @@ const LOGOS = Object.fromEntries(
|
|||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||
);
|
||||
|
||||
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
|
||||
|
||||
/* Last resort only. The API already falls back to the host
|
||||
organization's logo when an event doesn't name its own, so this
|
||||
|
|
@ -41,7 +48,15 @@ const logoSrc = file => (file && LOGOS[file]) || null;
|
|||
const DEFAULT_ORG_LOGO = null;
|
||||
|
||||
/* An <img> that removes itself if the file 404s. */
|
||||
function Logo({ file, alt = "", className }) {
|
||||
function Logo({
|
||||
file,
|
||||
alt = "",
|
||||
className,
|
||||
}: {
|
||||
file: string | null | undefined;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = logoSrc(file);
|
||||
if (!src || failed) return null;
|
||||
|
|
@ -122,7 +137,15 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
|||
carousel has the same constraint — it needs the card's click to
|
||||
mean "bring this one to the front" on anything that isn't the
|
||||
active slide. */
|
||||
function TitleLink({ ev, linked, children }) {
|
||||
function TitleLink({
|
||||
ev,
|
||||
linked,
|
||||
children,
|
||||
}: {
|
||||
ev: Pick<EventListItem, "id">;
|
||||
linked: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!linked) return <>{children}</>;
|
||||
return (
|
||||
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||
|
|
@ -149,6 +172,16 @@ function TitleLink({ ev, linked, children }) {
|
|||
`linked` is the one thing a caller turns off: a card on the
|
||||
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,
|
||||
|
|
@ -157,7 +190,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;
|
||||
|
|
@ -353,14 +386,23 @@ export function Card({
|
|||
them visible is one tap, and the row reads as what the section
|
||||
contains rather than as a form control.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function TypeFilter({ types, active, setActive, accent }) {
|
||||
const chip = on => ({
|
||||
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) => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: on ? accent : "transparent",
|
||||
color: on ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
const button = (id, label) => (
|
||||
const button = (id: TypeFilterValue, label: string) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActive(id)}
|
||||
|
|
@ -388,8 +430,12 @@ export function TypeFilter({ types, active, setActive, accent }) {
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function EventCardsToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
export function EventCardsToggle({
|
||||
view,
|
||||
setView,
|
||||
accent,
|
||||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
||||
const btn = (active: boolean) => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
|
@ -441,6 +487,14 @@ export function EventCardsToggle({ view, setView, accent }) {
|
|||
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,
|
||||
|
|
@ -450,7 +504,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,
|
||||
|
|
@ -461,7 +515,7 @@ export default function EventListCards({
|
|||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [activeType, setActiveType] = useState("all");
|
||||
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
|
||||
|
||||
/* What this band holds, which is what the chips offer — not the
|
||||
full list of declared types, three quarters of which would be
|
||||
|
|
@ -515,7 +569,7 @@ export default function EventListCards({
|
|||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||||
|
||||
// Arrows and dots follow the section accent, not the active card.
|
||||
const arrowStyle = enabled => ({
|
||||
const arrowStyle = (enabled: boolean) => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: enabled ? accent : "#b8c6c9",
|
||||
|
|
@ -523,7 +577,7 @@ export default function EventListCards({
|
|||
opacity: enabled ? 1 : 0.4,
|
||||
});
|
||||
|
||||
const notice = text => (
|
||||
const notice = (text: string) => (
|
||||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||||
{text}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
page to the nav adds it to this form too.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { post, ApiError } from "../../lib/api.js";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
||||
|
|
@ -46,7 +46,7 @@ function OptionalTag() {
|
|||
);
|
||||
}
|
||||
|
||||
function FieldError({ id, children }) {
|
||||
function FieldError({ id, children }: { id: string; children?: ReactNode }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
||||
|
|
@ -56,7 +56,15 @@ function FieldError({ id, children }) {
|
|||
}
|
||||
|
||||
// Native select plus a chevron, since appearance-none strips the default one.
|
||||
function Select({ id, label, value, onChange, children }) {
|
||||
type SelectProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Select({ id, label, value, onChange, children }: SelectProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
|
|
@ -93,7 +101,13 @@ function Select({ id, label, value, onChange, children }) {
|
|||
|
||||
/* ── Type picker ─────────────────────────────────────────────── */
|
||||
|
||||
function TypePicker({ value, onChange }) {
|
||||
function TypePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-base font-semibold text-[#26454c]">
|
||||
|
|
@ -151,7 +165,16 @@ function TypePicker({ value, onChange }) {
|
|||
|
||||
/* ── Where on the site ───────────────────────────────────────── */
|
||||
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
||||
type LocationPickerProps = {
|
||||
/** A nav path, or SITE_WIDE. */
|
||||
page: string;
|
||||
/** A nav hash, or WHOLE_PAGE. */
|
||||
section: string;
|
||||
onPageChange: (page: string) => void;
|
||||
onSectionChange: (section: string) => void;
|
||||
};
|
||||
|
||||
function LocationPicker({ page, section, onPageChange, onSectionChange }: LocationPickerProps) {
|
||||
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
||||
|
||||
return (
|
||||
|
|
@ -205,7 +228,7 @@ function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
|||
}
|
||||
|
||||
// Human-readable version of the picked location, for the thank-you panel.
|
||||
function describeLocation(page, section) {
|
||||
function describeLocation(page: string, section: string) {
|
||||
if (page === SITE_WIDE) return null;
|
||||
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
||||
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
||||
|
|
@ -216,8 +239,11 @@ function describeLocation(page, section) {
|
|||
|
||||
/* ── 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(null);
|
||||
const [type, setType] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(SITE_WIDE);
|
||||
const [section, setSection] = useState(WHOLE_PAGE);
|
||||
const [message, setMessage] = useState("");
|
||||
|
|
@ -228,14 +254,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");
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<FeedbackFieldErrors>({});
|
||||
|
||||
const sending = status === "sending";
|
||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!ready || sending) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ReactNode } 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,
|
||||
|
|
@ -33,7 +35,13 @@ const FALLBACK_COLOR = "#4a6b72";
|
|||
const CARD_MIN = "20rem";
|
||||
const GRID_MAX = "88rem";
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
function OrgMark({
|
||||
org,
|
||||
color,
|
||||
}: {
|
||||
org: Pick<OrganizationListItem, "logo" | "name">;
|
||||
color: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
|
|
@ -58,7 +66,14 @@ function OrgMark({ org, color }) {
|
|||
);
|
||||
}
|
||||
|
||||
function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
||||
type CardLabels = { accent: string; pageLabel: string; siteLabel: string };
|
||||
|
||||
function OrgCard({
|
||||
org,
|
||||
accent,
|
||||
pageLabel,
|
||||
siteLabel,
|
||||
}: CardLabels & { org: OrganizationListItem }) {
|
||||
const color = org.color || accent;
|
||||
const path = orgPath(org);
|
||||
|
||||
|
|
@ -136,7 +151,13 @@ function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
||||
function Block({
|
||||
title,
|
||||
orgs,
|
||||
accent,
|
||||
pageLabel,
|
||||
siteLabel,
|
||||
}: CardLabels & { title?: string; orgs: OrganizationListItem[] }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -167,7 +188,30 @@ function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
|||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
|
||||
a.name.localeCompare(b.name);
|
||||
|
||||
/* One heading's worth of the grid. Labels override the grid's own. */
|
||||
type OrgGroup = {
|
||||
key: string;
|
||||
title: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
};
|
||||
|
||||
type OrgListCardsProps = {
|
||||
kind?: OrgKind;
|
||||
title?: string;
|
||||
groups?: OrgGroup[];
|
||||
/** Which group key an organization files under. */
|
||||
groupBy?: (org: OrganizationListItem) => string | null | undefined;
|
||||
accent?: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
/** Null keeps the API's order. */
|
||||
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
|
||||
empty?: string;
|
||||
};
|
||||
|
||||
export default function OrgListCards({
|
||||
kind,
|
||||
|
|
@ -179,10 +223,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 => (
|
||||
const shell = (children: ReactNode) => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommunity } from "../../data/chapters.js";
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
useCommunity,
|
||||
type Chapter,
|
||||
type Community,
|
||||
type Region,
|
||||
} 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 } from "../../data/mapGrid.js";
|
||||
import { AREAS, AREA_NAMES, type AreaSlice } from "../../data/mapGrid.js";
|
||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||||
import type { ContentBlock, OrganizationListItem } from "../../lib/useContent.ts";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION LIST — MAP
|
||||
|
|
@ -46,6 +53,19 @@ 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";
|
||||
|
||||
|
|
@ -55,7 +75,7 @@ const INTL_TITLE = "International Unity Regions";
|
|||
vanishing. When organization and person pages arrive this should
|
||||
move to a shared component; small enough to live here until then.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function Blocks({ blocks = [], color }) {
|
||||
function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string }) {
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -88,7 +108,7 @@ function Blocks({ blocks = [], color }) {
|
|||
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
|
||||
|
|
@ -134,6 +154,19 @@ function Blocks({ blocks = [], color }) {
|
|||
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,
|
||||
|
|
@ -149,11 +182,12 @@ function Tile({
|
|||
setHovered,
|
||||
onPick,
|
||||
fontSize = 34,
|
||||
}) {
|
||||
}: TileProps) {
|
||||
if (slices.length === 0) return null;
|
||||
|
||||
const primary = slices[0];
|
||||
const ids = slices.map(s => s.regionId);
|
||||
// Nullable so indexOf and includes take `selected` as it is.
|
||||
const ids: Array<string | null> = slices.map(s => s.regionId);
|
||||
const active = ids.includes(selected) || ids.includes(hovered);
|
||||
const dimmed = selected && !ids.includes(selected);
|
||||
const clipId = `clip-${code}`;
|
||||
|
|
@ -196,7 +230,7 @@ function Tile({
|
|||
y={top}
|
||||
width={width}
|
||||
height={h}
|
||||
fill={slice.color}
|
||||
fill={colorOf(slice)}
|
||||
fillOpacity={opacity}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
|
@ -211,7 +245,7 @@ function Tile({
|
|||
height={size}
|
||||
rx={14}
|
||||
fill="none"
|
||||
stroke={active ? primary.color : "#ffffff"}
|
||||
stroke={active ? colorOf(primary) : "#ffffff"}
|
||||
strokeOpacity={active ? 1 : 0.55}
|
||||
strokeWidth={active ? 4 : 2}
|
||||
className="transition-all duration-200"
|
||||
|
|
@ -224,7 +258,7 @@ function Tile({
|
|||
dominantBaseline="middle"
|
||||
fontSize={fontSize}
|
||||
fontWeight="800"
|
||||
fill={count ? "#ffffff" : primary.color}
|
||||
fill={count ? "#ffffff" : colorOf(primary)}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{label || code}
|
||||
|
|
@ -244,7 +278,13 @@ function Tile({
|
|||
);
|
||||
}
|
||||
|
||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
||||
type RegionMapProps = Highlight & {
|
||||
slices: Record<string, AreaSlice[]>;
|
||||
chapterCounts: Record<string, number>;
|
||||
onPick?: (code: string) => void;
|
||||
};
|
||||
|
||||
function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
|
||||
const size = TILE - PAD * 2;
|
||||
|
||||
return (
|
||||
|
|
@ -275,8 +315,15 @@ function RegionMap({ slices, chapterCounts, ...props }) {
|
|||
);
|
||||
}
|
||||
|
||||
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||
function LegendButton({
|
||||
region,
|
||||
selected,
|
||||
setSelected,
|
||||
hovered,
|
||||
setHovered,
|
||||
}: Highlight & { region: Region }) {
|
||||
const on = selected === region.id || hovered === region.id;
|
||||
const color = colorOf(region);
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
||||
|
|
@ -287,29 +334,36 @@ function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
|||
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 ${region.color}`,
|
||||
background: on ? region.color : "transparent",
|
||||
color: on ? "#ffffff" : region.color,
|
||||
border: `1px solid ${color}`,
|
||||
background: on ? color : "transparent",
|
||||
color: on ? "#ffffff" : color,
|
||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: on ? "#ffffff" : region.color }}
|
||||
style={{ background: on ? "#ffffff" : color }}
|
||||
/>
|
||||
{region.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
||||
type LegendProps = Highlight & {
|
||||
domestic: Region[];
|
||||
international: Region[];
|
||||
/** Regions that paint at least one tile. */
|
||||
onMapIds: Set<string>;
|
||||
};
|
||||
|
||||
function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
|
||||
const { selected, setSelected } = props;
|
||||
|
||||
// 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 => onMapIds.has(region.id);
|
||||
const onMap = (region: Region) => onMapIds.has(region.id);
|
||||
const us = domestic.filter(onMap);
|
||||
const intl = international.filter(onMap);
|
||||
|
||||
|
|
@ -347,6 +401,15 @@ function Legend({ domestic, international, onMapIds, ...props }) {
|
|||
);
|
||||
}
|
||||
|
||||
type RegionBlockProps = Highlight & {
|
||||
region: Region;
|
||||
chapters: Chapter[];
|
||||
subtext: string;
|
||||
indent: boolean;
|
||||
regionRefs?: RefObject<Record<string, HTMLDivElement | null>>;
|
||||
chapterRefs?: RefObject<Record<string, HTMLLIElement | null>>;
|
||||
};
|
||||
|
||||
function RegionBlock({
|
||||
region,
|
||||
chapters,
|
||||
|
|
@ -357,12 +420,15 @@ function RegionBlock({
|
|||
indent,
|
||||
regionRefs,
|
||||
chapterRefs,
|
||||
}) {
|
||||
}: RegionBlockProps) {
|
||||
const on = selected === region.id;
|
||||
const color = colorOf(region);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
|
||||
ref={el => {
|
||||
if (regionRefs) regionRefs.current[region.id] = el;
|
||||
}}
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||
>
|
||||
|
|
@ -374,11 +440,11 @@ function RegionBlock({
|
|||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ background: region.color }}
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<h4
|
||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||
style={{ color: region.color }}
|
||||
style={{ color: color }}
|
||||
>
|
||||
{region.name}
|
||||
</h4>
|
||||
|
|
@ -399,12 +465,16 @@ function RegionBlock({
|
|||
</p>
|
||||
) : (
|
||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||
{chapters.map(c => (
|
||||
{chapters.map(c => {
|
||||
const path = orgPath(c);
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||
ref={el => {
|
||||
if (chapterRefs) chapterRefs.current[c.id] = el;
|
||||
}}
|
||||
className="pl-3 flex items-start gap-3"
|
||||
style={{ borderLeft: `2px solid ${region.color}` }}
|
||||
style={{ borderLeft: `2px solid ${color}` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-700">{c.name}</p>
|
||||
|
|
@ -420,7 +490,7 @@ function RegionBlock({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
style={{ color: color }}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
|
|
@ -429,7 +499,7 @@ function RegionBlock({
|
|||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
className="font-700 underline"
|
||||
style={{ color: region.color }}
|
||||
style={{ color: color }}
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
|
|
@ -438,16 +508,17 @@ function RegionBlock({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{orgPath(c) && (
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={orgPath(c)}
|
||||
to={path}
|
||||
label={`${c.name} — chapter page`}
|
||||
color={region.color}
|
||||
color={color}
|
||||
size="h-8 w-8"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -462,7 +533,15 @@ function RegionBlock({
|
|||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Logo, or the organization's initials when there's no file. */
|
||||
function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
||||
function OrgLogo({
|
||||
org,
|
||||
color,
|
||||
size = "h-14 w-14",
|
||||
}: {
|
||||
org: Pick<OrganizationListItem, "logo" | "name">;
|
||||
color: string;
|
||||
size?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
|
|
@ -487,7 +566,16 @@ function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||
type ChapterCardProps = {
|
||||
chapter: Chapter;
|
||||
color: string;
|
||||
open: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
||||
const path = orgPath(chapter);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||
|
|
@ -539,9 +627,9 @@ function ChapterCard({ chapter, color, open, onOpen }) {
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
{orgPath(chapter) && (
|
||||
{path && (
|
||||
<ArrowLink
|
||||
to={orgPath(chapter)}
|
||||
to={path}
|
||||
label={`${chapter.name} — chapter page`}
|
||||
color={color}
|
||||
/>
|
||||
|
|
@ -551,7 +639,18 @@ function ChapterCard({ chapter, color, open, onOpen }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ChapterDetail({ chapter, region, onClose }) {
|
||||
function ChapterDetail({
|
||||
chapter,
|
||||
region,
|
||||
onClose,
|
||||
}: {
|
||||
chapter: Chapter;
|
||||
region: Region;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const path = orgPath(chapter);
|
||||
const color = colorOf(region);
|
||||
|
||||
/* "Led by" comes from affiliations rather than a text field, so
|
||||
it lists real people and stays empty until they exist. */
|
||||
const leads = (chapter.leadership ?? [])
|
||||
|
|
@ -560,21 +659,23 @@ function ChapterDetail({ chapter, region, onClose }) {
|
|||
)
|
||||
.join(", ");
|
||||
|
||||
const rows = [
|
||||
const rows = (
|
||||
[
|
||||
["Region", region.name],
|
||||
["Where", chapter.venue],
|
||||
["Meets", chapter.meets],
|
||||
["Led by", leads],
|
||||
["Since", chapter.started],
|
||||
].filter(([, v]) => v);
|
||||
] satisfies Array<[label: string, value: string | null | undefined]>
|
||||
).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-6 mb-6"
|
||||
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
|
||||
style={{ border: `2px solid ${color}`, background: `${color}0f` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
|
||||
<OrgLogo org={chapter} color={color} size="h-20 w-20" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||
|
|
@ -585,13 +686,13 @@ function ChapterDetail({ chapter, region, onClose }) {
|
|||
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 ${region.color}`, color: region.color }}
|
||||
style={{ border: `1px solid ${color}`, color: color }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Blocks blocks={chapter.blocks} color={region.color} />
|
||||
<Blocks blocks={chapter.blocks} color={color} />
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
|
|
@ -609,11 +710,11 @@ function ChapterDetail({ chapter, region, onClose }) {
|
|||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{orgPath(chapter) && (
|
||||
{path && (
|
||||
<Link
|
||||
to={orgPath(chapter)}
|
||||
to={path}
|
||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
style={{ background: region.color, color: "#ffffff" }}
|
||||
style={{ background: color, color: "#ffffff" }}
|
||||
>
|
||||
Chapter page
|
||||
</Link>
|
||||
|
|
@ -624,7 +725,7 @@ function ChapterDetail({ chapter, region, onClose }) {
|
|||
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 ${region.color}`, color: region.color }}
|
||||
style={{ border: `1px solid ${color}`, color: color }}
|
||||
>
|
||||
Visit site
|
||||
</a>
|
||||
|
|
@ -634,7 +735,7 @@ function ChapterDetail({ chapter, region, onClose }) {
|
|||
<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 ${region.color}`, color: region.color }}
|
||||
style={{ border: `1px solid ${color}`, color: color }}
|
||||
>
|
||||
Get in touch
|
||||
</a>
|
||||
|
|
@ -644,7 +745,19 @@ function ChapterDetail({ chapter, region, onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
|
||||
type ChapterGridProps = Pick<Community, "regions" | "chapters" | "chaptersIn" | "subtextFor"> & {
|
||||
openId: string | null;
|
||||
setOpenId: (id: string | null) => void;
|
||||
};
|
||||
|
||||
function ChapterGrid({
|
||||
regions,
|
||||
chapters,
|
||||
chaptersIn,
|
||||
subtextFor,
|
||||
openId,
|
||||
setOpenId,
|
||||
}: ChapterGridProps) {
|
||||
// Only regions that actually have chapters get a grid.
|
||||
const populated = regions
|
||||
.map(region => ({ region, list: chaptersIn(region.id) }))
|
||||
|
|
@ -656,14 +769,15 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
|
|||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{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: region.color }}
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
||||
<h3 className="text-2xl font-800" style={{ color: color }}>
|
||||
{region.name}
|
||||
</h3>
|
||||
<span className="text-sm" style={{ color: MUTED }}>
|
||||
|
|
@ -696,7 +810,7 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
|
|||
<ChapterCard
|
||||
key={c.id}
|
||||
chapter={c}
|
||||
color={region.color}
|
||||
color={color}
|
||||
open={openId === c.id}
|
||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||
/>
|
||||
|
|
@ -717,8 +831,12 @@ function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpe
|
|||
}
|
||||
|
||||
/* The control for the section heading's action bar. */
|
||||
export function OrgMapToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
export function OrgMapToggle({
|
||||
view,
|
||||
setView,
|
||||
accent,
|
||||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
||||
const btn = (active: boolean) => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
|
@ -748,7 +866,14 @@ export function OrgMapToggle({ view, setView, accent }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||
export default function OrgListMap({
|
||||
view = "map",
|
||||
accent = FALLBACK_COLOR,
|
||||
}: {
|
||||
/** "map" or "grid". */
|
||||
view?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
|
|
@ -765,16 +890,16 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
|||
subtextFor,
|
||||
} = useCommunity();
|
||||
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null); // no card open on arrival
|
||||
|
||||
// The list scrolls itself to whatever the map or legend points at.
|
||||
const listRef = useRef(null);
|
||||
const regionRefs = useRef({});
|
||||
const chapterRefs = useRef({});
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const regionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const chapterRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||
|
||||
const scrollListTo = el => {
|
||||
const scrollListTo = (el: HTMLElement | null | undefined) => {
|
||||
const box = listRef.current;
|
||||
if (!box || !el) return;
|
||||
// Only when the list is its own scroll area (lg and up). Below
|
||||
|
|
@ -792,7 +917,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
|||
|
||||
// Clicking a tile jumps to its first chapter when it has one,
|
||||
// otherwise to the region it belongs to.
|
||||
const pickArea = code => {
|
||||
const pickArea = (code: string) => {
|
||||
const chapter = chapters.find(c => c.area_code === code);
|
||||
if (chapter && chapterRefs.current[chapter.id]) {
|
||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||
|
|
@ -801,7 +926,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
|||
if (region) scrollListTo(regionRefs.current[region.id]);
|
||||
};
|
||||
|
||||
const shell = children => (
|
||||
const shell = (children: ReactNode) => (
|
||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -837,7 +962,7 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
|||
const shared = { selected, setSelected, hovered, setHovered };
|
||||
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
||||
|
||||
const block = (region, indent) => (
|
||||
const block = (region: Region, indent: boolean) => (
|
||||
<RegionBlock
|
||||
key={region.id}
|
||||
region={region}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { useId, useState } from "react";
|
||||
import {
|
||||
useId,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||
import type { OrgKind } from "../../lib/hrefs.ts";
|
||||
import type { OrganizationListItem } from "../../lib/useContent.ts";
|
||||
import {
|
||||
areasSentence,
|
||||
initialsFor,
|
||||
|
|
@ -47,7 +55,14 @@ const FALLBACK_COLOR = "#4a6b72";
|
|||
it — a link nested in a button is invalid, and a screen reader
|
||||
announces the whole row as one confused control.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
||||
type RowButtonProps = {
|
||||
as?: "a" | "button";
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
} & AnchorHTMLAttributes<HTMLAnchorElement> &
|
||||
ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
|
||||
return (
|
||||
<As
|
||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||
|
|
@ -59,7 +74,13 @@ function RowButton({ as: As = "button", color, children, ...rest }) {
|
|||
);
|
||||
}
|
||||
|
||||
function OrgMark({ org, color }) {
|
||||
function OrgMark({
|
||||
org,
|
||||
color,
|
||||
}: {
|
||||
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
|
||||
color: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (org.logo && !failed) {
|
||||
|
|
@ -94,7 +115,9 @@ function OrgMark({ org, color }) {
|
|||
);
|
||||
}
|
||||
|
||||
function OrgRow({ org, pageLabel, siteLabel }) {
|
||||
type RowLabels = { pageLabel: string; siteLabel: string };
|
||||
|
||||
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
|
|
@ -245,7 +268,12 @@ function OrgRow({ org, pageLabel, siteLabel }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Block({ title, orgs, pageLabel, siteLabel }) {
|
||||
function Block({
|
||||
title,
|
||||
orgs,
|
||||
pageLabel,
|
||||
siteLabel,
|
||||
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -262,7 +290,30 @@ function Block({ title, orgs, pageLabel, siteLabel }) {
|
|||
);
|
||||
}
|
||||
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
|
||||
a.name.localeCompare(b.name);
|
||||
|
||||
/* One heading's worth of the list. Labels override the list's own. */
|
||||
type OrgGroup = {
|
||||
key: string;
|
||||
title: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
};
|
||||
|
||||
type OrgListVerticalProps = {
|
||||
kind?: OrgKind;
|
||||
title?: string;
|
||||
groups?: OrgGroup[];
|
||||
/** Which group key an organization files under. */
|
||||
groupBy?: (org: OrganizationListItem) => string | null | undefined;
|
||||
accent?: string;
|
||||
pageLabel?: string;
|
||||
siteLabel?: string;
|
||||
/** Null keeps the API's order. */
|
||||
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
|
||||
empty?: string;
|
||||
};
|
||||
|
||||
export default function OrgListVertical({
|
||||
kind,
|
||||
|
|
@ -274,10 +325,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 => (
|
||||
const shell = (children: ReactNode) => (
|
||||
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue