Revert the implicit-any typing of src/
Reverts 2428f44. The .d.ts files beside the JS modules and the
annotations threaded through src/ to satisfy noImplicitAny go;
tsconfig turns noImplicitAny off instead. The JS modules become
TypeScript in the next commit, so their types come from inference.
Kept from that commit, since they're runtime fixes rather than types:
- OrganizationDetail's website and email pills get an href and label
(they arrive as bare strings, not link objects).
- The chapter map falls back to FALLBACK_COLOR for a region with no
colour instead of painting its tiles black.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4618ad8e67
commit
5cedc68fd7
38 changed files with 340 additions and 1302 deletions
|
|
@ -4,15 +4,7 @@ import { Link } from "react-router-dom";
|
||||||
ARROW LINK
|
ARROW LINK
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
type ArrowLinkProps = {
|
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
|
||||||
to: string;
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
/** Tailwind size classes for the circle. */
|
|
||||||
size?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) {
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export default function Layout() {
|
||||||
|
|
||||||
const targets = sections
|
const targets = sections
|
||||||
.map((s) => document.querySelector(s.hash))
|
.map((s) => document.querySelector(s.hash))
|
||||||
.filter((el): el is Element => el !== null);
|
.filter(Boolean);
|
||||||
if (targets.length === 0) return;
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
|
|
|
||||||
|
|
@ -29,27 +29,9 @@
|
||||||
/>
|
/>
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
const TEAL = "#138ba0";
|
const TEAL = "#138ba0";
|
||||||
|
|
||||||
export type ShellSection = {
|
export function Section({ section }) {
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
blurb?: string;
|
|
||||||
accent: string;
|
|
||||||
background: string;
|
|
||||||
actions?: ReactNode;
|
|
||||||
content: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageShellProps = {
|
|
||||||
title: ReactNode;
|
|
||||||
intro?: ReactNode;
|
|
||||||
sections: ShellSection[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Section({ section }: { section: ShellSection }) {
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
|
@ -88,7 +70,7 @@ export function Section({ section }: { section: ShellSection }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageShell({ title, intro, sections }: PageShellProps) {
|
export default function PageShell({ title, intro, sections }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ export default function PeopleTiles({
|
||||||
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
specs.map((spec) =>
|
specs.map((spec) =>
|
||||||
get<TeamResponse>(`/teams/${spec.id}/people`, { ttl }).then((data) => ({
|
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
|
||||||
spec,
|
spec,
|
||||||
data,
|
data,
|
||||||
})),
|
})),
|
||||||
|
|
@ -208,8 +208,8 @@ export default function PeopleTiles({
|
||||||
let live = true;
|
let live = true;
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
|
|
||||||
get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
|
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
|
||||||
.then((data) => {
|
.then((data: { people: Person[] }) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
const byId: Record<string, Person> = {};
|
const byId: Record<string, Person> = {};
|
||||||
for (const person of data.people) byId[String(person.id)] = person;
|
for (const person of data.people) byId[String(person.id)] = person;
|
||||||
|
|
@ -314,10 +314,11 @@ function resolveAll(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { peopleslug, ...overrides } = entry;
|
const { peopleslug, ...overrides } = entry;
|
||||||
const defined: Partial<Person> = Object.fromEntries(
|
const merged: Person = { ...base };
|
||||||
Object.entries(overrides).filter(([, value]) => value !== undefined),
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
);
|
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
||||||
resolved.push({ ...base, ...defined });
|
}
|
||||||
|
resolved.push(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,7 @@
|
||||||
able to read and copy.
|
able to read and copy.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useRef, useState, type ChangeEvent, type ReactNode } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import type { FieldErrors } from "../../lib/api.js";
|
|
||||||
import type {
|
|
||||||
AdminFieldSpec,
|
|
||||||
AdminOption,
|
|
||||||
AdminOptions,
|
|
||||||
AdminRow,
|
|
||||||
CollectionSpec,
|
|
||||||
} from "../../lib/adminSchema.js";
|
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
||||||
|
|
@ -65,56 +56,37 @@ const inputLocked =
|
||||||
|
|
||||||
/* ── Dotted paths ────────────────────────────────────────────── */
|
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function getPath(object: unknown, path: string | null | undefined): unknown {
|
export function getPath(object, path) {
|
||||||
// An entity with no slug has no heading path either, and a missing
|
// An entity with no slug has no heading path either, and a missing
|
||||||
// path should read as "no value" rather than throwing on .split.
|
// path should read as "no value" rather than throwing on .split.
|
||||||
if (!path) return undefined;
|
if (!path) return undefined;
|
||||||
return path
|
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||||
.split(".")
|
|
||||||
.reduce<unknown>((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow {
|
export function setPath(object, path, value) {
|
||||||
const [head, ...rest] = path.split(".");
|
const [head, ...rest] = path.split(".");
|
||||||
if (rest.length === 0) return { ...object, [head]: value };
|
if (rest.length === 0) return { ...object, [head]: value };
|
||||||
const inner = (object?.[head] ?? {}) as AdminRow;
|
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
|
||||||
return { ...object, [head]: setPath(inner, rest.join("."), value) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Field ───────────────────────────────────────────────────── */
|
/* ── Field ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* [value, label, the option row it came from]. Manifest options
|
export function Field({ field, value, row, options, error, onChange }) {
|
||||||
have no row, which is what filterBy's `!raw` lets through. */
|
|
||||||
type Choice = [id: string, label: string, raw?: AdminOption];
|
|
||||||
|
|
||||||
type FieldProps = {
|
|
||||||
field: AdminFieldSpec;
|
|
||||||
/* Whatever the row holds at field.path; shown as text. */
|
|
||||||
value: unknown;
|
|
||||||
row?: AdminRow;
|
|
||||||
options?: AdminOptions | null;
|
|
||||||
error?: string;
|
|
||||||
onChange: (value: string | number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Field({ field, value, row, options, error, onChange }: FieldProps) {
|
|
||||||
const id = `f-${field.path.replace(/\./g, "-")}`;
|
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||||
const widget = field.widget ?? "text";
|
const widget = field.widget ?? "text";
|
||||||
const locked = Boolean(field.readOnly);
|
const locked = Boolean(field.readOnly);
|
||||||
const text = value == null ? "" : String(value);
|
|
||||||
|
|
||||||
let list: Choice[] = [];
|
let list = null;
|
||||||
let orphaned = false;
|
let orphaned = false;
|
||||||
|
|
||||||
if (widget === "select") {
|
if (widget === "select") {
|
||||||
list = field.optionsFrom
|
list = field.optionsFrom
|
||||||
? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o])
|
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
|
||||||
: (field.options ?? []).map((o): Choice =>
|
: (field.options ?? []).map((o) =>
|
||||||
typeof o === "string" ? [o, o] : [o[0], o[1]],
|
Array.isArray(o) ? [o[0], o[1]] : [o, o],
|
||||||
);
|
);
|
||||||
const { filterBy } = field;
|
if (field.filterBy && row) {
|
||||||
if (filterBy && row) {
|
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
|
||||||
list = list.filter(([, , raw]) => !raw || filterBy(raw, row));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored value with no matching option renders as the blank
|
// A stored value with no matching option renders as the blank
|
||||||
|
|
@ -130,9 +102,8 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
const common = {
|
const common = {
|
||||||
id,
|
id,
|
||||||
className: `${input} ${error ? inputError : ""}`,
|
className: `${input} ${error ? inputError : ""}`,
|
||||||
value: text,
|
value: value ?? "",
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
onChange: (e) => onChange(e.target.value),
|
||||||
onChange(e.target.value),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -174,7 +145,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
||||||
{orphaned && <option value={text}>{text} — no longer exists</option>}
|
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
||||||
{list.map(([id2, label]) => (
|
{list.map(([id2, label]) => (
|
||||||
<option key={id2} value={id2}>
|
<option key={id2} value={id2}>
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -185,7 +156,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#138ba0"}
|
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
||||||
|
|
@ -217,7 +188,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="text"
|
type="text"
|
||||||
value={text}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
||||||
|
|
@ -253,36 +224,26 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldGrid({ children }: { children: ReactNode }) {
|
export function FieldGrid({ children }) {
|
||||||
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Repeater ────────────────────────────────────────────────── */
|
/* ── Repeater ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
type RepeaterProps = {
|
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
||||||
spec: CollectionSpec;
|
|
||||||
rows: AdminRow[] | null | undefined;
|
|
||||||
options?: AdminOptions | null;
|
|
||||||
errors?: Partial<FieldErrors> | null;
|
|
||||||
/** Where this collection's rows sit in the server's error keys. */
|
|
||||||
errorPrefix: string;
|
|
||||||
onChange: (rows: AdminRow[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }: RepeaterProps) {
|
|
||||||
const list = rows ?? [];
|
const list = rows ?? [];
|
||||||
|
|
||||||
// Which row is in flight, and which one it's currently over.
|
// Which row is in flight, and which one it's currently over.
|
||||||
// Both are per-Repeater, which is what keeps a drag inside a
|
// Both are per-Repeater, which is what keeps a drag inside a
|
||||||
// nested collection from being accepted by the outer one.
|
// nested collection from being accepted by the outer one.
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState(null);
|
||||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
const [overIndex, setOverIndex] = useState(null);
|
||||||
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
const rowRefs = useRef([]);
|
||||||
|
|
||||||
const update = (index: number, next: AdminRow) =>
|
const update = (index, next) =>
|
||||||
onChange(list.map((row, i) => (i === index ? next : row)));
|
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||||
|
|
||||||
const move = (index: number, delta: number) => {
|
const move = (index, delta) => {
|
||||||
const target = index + delta;
|
const target = index + delta;
|
||||||
if (target < 0 || target >= list.length) return;
|
if (target < 0 || target >= list.length) return;
|
||||||
const next = [...list];
|
const next = [...list];
|
||||||
|
|
@ -290,7 +251,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
||||||
onChange(next);
|
onChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const relocate = (from: number | null, to: number | null) => {
|
const relocate = (from, to) => {
|
||||||
if (from === to || from == null || to == null) return;
|
if (from === to || from == null || to == null) return;
|
||||||
const next = [...list];
|
const next = [...list];
|
||||||
const [moved] = next.splice(from, 1);
|
const [moved] = next.splice(from, 1);
|
||||||
|
|
@ -423,7 +384,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
||||||
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
||||||
<Repeater
|
<Repeater
|
||||||
spec={nested}
|
spec={nested}
|
||||||
rows={row[nested.key] as AdminRow[] | undefined}
|
rows={row[nested.key]}
|
||||||
options={options}
|
options={options}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
||||||
|
|
@ -439,15 +400,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type IconButtonProps = {
|
function IconButton({ label, onClick, danger, disabled, children }) {
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function IconButton({ label, onClick, danger, disabled, children }: IconButtonProps) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
10
src/data/bannerConfig.d.ts
vendored
10
src/data/bannerConfig.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
/* Types for bannerConfig.js. */
|
|
||||||
|
|
||||||
export type BannerPart = { text: string; href?: string; external?: boolean };
|
|
||||||
|
|
||||||
export declare const SITE_BANNER: {
|
|
||||||
enabled: boolean;
|
|
||||||
id: string;
|
|
||||||
content: BannerPart[];
|
|
||||||
dismissible: boolean;
|
|
||||||
};
|
|
||||||
47
src/data/chapters.d.ts
vendored
47
src/data/chapters.d.ts
vendored
|
|
@ -1,47 +0,0 @@
|
||||||
/* Types for chapters.js: GET /organizations rows with their
|
|
||||||
kind-specific details lifted to the top level. */
|
|
||||||
|
|
||||||
import type { OrganizationListItem, RegionArea, RegionScope } from "../lib/useContent.ts";
|
|
||||||
import type { AreaSlice, RegionAreaRow } from "./mapGrid.js";
|
|
||||||
|
|
||||||
export { initialsFor } from "./organizations.js";
|
|
||||||
|
|
||||||
export type Region = OrganizationListItem & {
|
|
||||||
scope: RegionScope | null;
|
|
||||||
map_note: string | null;
|
|
||||||
areas: RegionArea[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Chapter = OrganizationListItem & {
|
|
||||||
region_id: string | null;
|
|
||||||
region_name: string | null;
|
|
||||||
region_color: string | null;
|
|
||||||
meets: string | null;
|
|
||||||
started: string | null;
|
|
||||||
/** The map tile this chapter lights up, or null if it has none. */
|
|
||||||
area_code: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Community = {
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
|
|
||||||
regions: Region[];
|
|
||||||
regionAreas: RegionAreaRow[];
|
|
||||||
regionById: Record<string, Region>;
|
|
||||||
domestic: Region[];
|
|
||||||
international: Region[];
|
|
||||||
virtual: Region[];
|
|
||||||
|
|
||||||
chapters: Chapter[];
|
|
||||||
chaptersIn: (regionId: string) => Chapter[];
|
|
||||||
|
|
||||||
slices: Record<string, AreaSlice[]>;
|
|
||||||
chapterCounts: Record<string, number>;
|
|
||||||
regionsForArea: (areaCode: string) => Region[];
|
|
||||||
|
|
||||||
areasLabelFor: (regionId: string) => string;
|
|
||||||
subtextFor: (region: Pick<Region, "id" | "map_note">) => string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function useCommunity(): Community;
|
|
||||||
29
src/data/eventData.d.ts
vendored
29
src/data/eventData.d.ts
vendored
|
|
@ -1,29 +0,0 @@
|
||||||
/* Types for eventData.js. Rows are GET /events as shapeEvent in
|
|
||||||
server/src/routes/content.js sends them. */
|
|
||||||
|
|
||||||
import type { EventType } from "../lib/eventTypes.ts";
|
|
||||||
import type { EventListItem, EventSection } from "../lib/useContent.ts";
|
|
||||||
|
|
||||||
export type EventFilter = {
|
|
||||||
section?: string;
|
|
||||||
host?: string;
|
|
||||||
status?: EventListItem["status"];
|
|
||||||
type?: EventType | EventType[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function useEvents(filter?: EventFilter): {
|
|
||||||
events: EventListItem[];
|
|
||||||
/** event_sections, in scope order. Empty until loaded. */
|
|
||||||
sections: EventSection[];
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function splitByStatus<T extends { status: string }>(
|
|
||||||
events?: T[],
|
|
||||||
): { upcoming: T[]; past: T[] };
|
|
||||||
|
|
||||||
export declare function typesPresent<D extends { id: string }>(
|
|
||||||
events?: Array<{ event_type: string }>,
|
|
||||||
declared?: readonly D[],
|
|
||||||
): D[];
|
|
||||||
7
src/data/feedbackTypes.d.ts
vendored
7
src/data/feedbackTypes.d.ts
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
/* Types for feedbackTypes.js. */
|
|
||||||
|
|
||||||
export type FeedbackType = { id: string; label: string; hint: string };
|
|
||||||
|
|
||||||
export declare const FEEDBACK_TYPES: FeedbackType[];
|
|
||||||
|
|
||||||
export declare function feedbackTypeLabel(id: string): string;
|
|
||||||
51
src/data/mapGrid.d.ts
vendored
51
src/data/mapGrid.d.ts
vendored
|
|
@ -1,51 +0,0 @@
|
||||||
/* Types for mapGrid.js. */
|
|
||||||
|
|
||||||
import type { RegionArea } from "../lib/useContent.ts";
|
|
||||||
|
|
||||||
export declare const GRID_COLS: number;
|
|
||||||
export declare const GRID_ROWS: number;
|
|
||||||
|
|
||||||
export declare const AREA_NAMES: Record<string, string>;
|
|
||||||
|
|
||||||
/** One tile: a state, or a band such as CANADA with a span. */
|
|
||||||
export type MapArea = {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
col: number;
|
|
||||||
row: number;
|
|
||||||
span: number;
|
|
||||||
isState: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare const AREAS: readonly MapArea[];
|
|
||||||
export declare const AREA_BY_CODE: Record<string, MapArea>;
|
|
||||||
|
|
||||||
/** A region_areas row flattened with its region, as buildAreaSlices takes it. */
|
|
||||||
export type RegionAreaRow = RegionArea & { region_id: string };
|
|
||||||
|
|
||||||
/** One region's share of a tile. */
|
|
||||||
export type AreaSlice = {
|
|
||||||
regionId: string;
|
|
||||||
name: string;
|
|
||||||
color: string | null | undefined;
|
|
||||||
share: number;
|
|
||||||
edge: RegionArea["edge"];
|
|
||||||
note: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Locatable = {
|
|
||||||
is_online?: boolean;
|
|
||||||
country?: string | null;
|
|
||||||
state_code?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function areaForChapter(chapter: Locatable | null | undefined): string | null;
|
|
||||||
|
|
||||||
export declare function buildAreaSlices(
|
|
||||||
regionAreas?: RegionAreaRow[],
|
|
||||||
regions?: Array<{ id: string; name: string; color?: string | null }>,
|
|
||||||
): Record<string, AreaSlice[]>;
|
|
||||||
|
|
||||||
export declare function countChaptersByArea(chapters?: Locatable[]): Record<string, number>;
|
|
||||||
|
|
||||||
export declare function areasLabel(regionId: string, regionAreas?: RegionAreaRow[]): string;
|
|
||||||
21
src/data/organizations.d.ts
vendored
21
src/data/organizations.d.ts
vendored
|
|
@ -1,21 +0,0 @@
|
||||||
/* Types for organizations.js. Rows are GET /organizations as
|
|
||||||
shapeOrganization in server/src/routes/content.js sends them. */
|
|
||||||
|
|
||||||
import type { OrgKind } from "../lib/hrefs.ts";
|
|
||||||
import type { OrganizationListItem, RegionArea } from "../lib/useContent.ts";
|
|
||||||
|
|
||||||
export declare function useOrganizations(kind?: OrgKind): {
|
|
||||||
organizations: OrganizationListItem[];
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function orgPath(
|
|
||||||
org: { id: string; kind?: string | null } | null | undefined,
|
|
||||||
): string | null;
|
|
||||||
|
|
||||||
export declare function areasSentence(
|
|
||||||
areas?: Array<Pick<RegionArea, "area_code" | "note">>,
|
|
||||||
): string;
|
|
||||||
|
|
||||||
export declare function initialsFor(name?: string): string;
|
|
||||||
123
src/lib/adminSchema.d.ts
vendored
123
src/lib/adminSchema.d.ts
vendored
|
|
@ -1,123 +0,0 @@
|
||||||
/* Types for adminSchema.js: the client half of the descriptor-driven
|
|
||||||
admin CRUD engine. Rows are whatever columns the server-side
|
|
||||||
descriptor in server/src/admin-schema.js declares, so they stay a
|
|
||||||
string-keyed record; the descriptors are what's fixed. */
|
|
||||||
|
|
||||||
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
|
|
||||||
* ('region.scope') are side-table objects under their key, and
|
|
||||||
* child collections are arrays of rows under theirs. */
|
|
||||||
export type AdminRow = Record<string, unknown>;
|
|
||||||
|
|
||||||
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
|
|
||||||
* along on the lists that filterBy narrows. */
|
|
||||||
export type AdminOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
kind?: string;
|
|
||||||
org_id?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
|
|
||||||
export type AdminOptions = Record<string, AdminOption[]>;
|
|
||||||
|
|
||||||
export type FieldWidget =
|
|
||||||
| "text"
|
|
||||||
| "textarea"
|
|
||||||
| "select"
|
|
||||||
| "checkbox"
|
|
||||||
| "color"
|
|
||||||
| "number"
|
|
||||||
| "date"
|
|
||||||
| "time";
|
|
||||||
|
|
||||||
/** A bare value, or [value, label]. */
|
|
||||||
export type SelectOption = string | readonly [string, string];
|
|
||||||
|
|
||||||
/** Show a group or collection only when another field has this value. */
|
|
||||||
export type FieldCondition = { path: string; value: unknown };
|
|
||||||
|
|
||||||
export type AdminFieldSpec = {
|
|
||||||
path: string;
|
|
||||||
label: string;
|
|
||||||
widget?: FieldWidget;
|
|
||||||
required?: boolean;
|
|
||||||
full?: boolean;
|
|
||||||
help?: string;
|
|
||||||
options?: readonly SelectOption[];
|
|
||||||
optionsFrom?: string;
|
|
||||||
blankLabel?: string;
|
|
||||||
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
|
|
||||||
/* Set by EntityEdit on the id field rather than in a manifest. */
|
|
||||||
readOnly?: boolean;
|
|
||||||
prefix?: string;
|
|
||||||
prefixPending?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FieldGroupSpec = {
|
|
||||||
legend: string;
|
|
||||||
note?: string;
|
|
||||||
when?: FieldCondition;
|
|
||||||
fields: AdminFieldSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CollectionSpec = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
addLabel?: string;
|
|
||||||
note?: string;
|
|
||||||
when?: FieldCondition;
|
|
||||||
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
|
|
||||||
blank: AdminRow;
|
|
||||||
fields: AdminFieldSpec[];
|
|
||||||
children?: CollectionSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListColumn = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
primary?: boolean;
|
|
||||||
widget?: "bool";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListFilter = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
options?: readonly SelectOption[];
|
|
||||||
optionsFrom?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EntitySpec = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
singular: string;
|
|
||||||
idLabel: string;
|
|
||||||
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
|
||||||
idKind?: "auto";
|
|
||||||
/** The one id a singleton entity has. The list opens it directly,
|
|
||||||
* and the editor offers no slug, back link or delete. */
|
|
||||||
singleton?: string;
|
|
||||||
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
|
||||||
slugFrom?: string | string[];
|
|
||||||
titleFrom?: string;
|
|
||||||
list: { columns: ListColumn[]; filters: ListFilter[] };
|
|
||||||
groups: FieldGroupSpec[];
|
|
||||||
children?: CollectionSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminEntityKey =
|
|
||||||
| "organizations"
|
|
||||||
| "events"
|
|
||||||
| "people"
|
|
||||||
| "teams"
|
|
||||||
| "awards"
|
|
||||||
| "timeline"
|
|
||||||
| "front_page";
|
|
||||||
|
|
||||||
/* Indexed by route param as often as by name, so any other string
|
|
||||||
reads as possibly missing. */
|
|
||||||
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
|
|
||||||
readonly [key: string]: EntitySpec | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function slugify(value: unknown): string;
|
|
||||||
|
|
@ -14,14 +14,12 @@
|
||||||
|
|
||||||
import { createContext, useContext, useEffect } from "react";
|
import { createContext, useContext, useEffect } from "react";
|
||||||
|
|
||||||
export type AdminTitleValue = { setDetail: (value: string | null) => void };
|
export const AdminTitleContext = createContext(null);
|
||||||
|
|
||||||
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
|
|
||||||
|
|
||||||
/* Publish the name of whatever this page is showing. Clears on
|
/* Publish the name of whatever this page is showing. Clears on
|
||||||
unmount, so navigating away can't leave a stale record name in
|
unmount, so navigating away can't leave a stale record name in
|
||||||
the tab. */
|
the tab. */
|
||||||
export function useAdminDetail(name: string | null | undefined) {
|
export function useAdminDetail(name) {
|
||||||
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
26
src/lib/api.d.ts
vendored
26
src/lib/api.d.ts
vendored
|
|
@ -1,26 +0,0 @@
|
||||||
/* Types for api.js. The response type is the caller's to name:
|
|
||||||
every endpoint sends a different body, so get<T> defaults to
|
|
||||||
unknown rather than pretending to know. */
|
|
||||||
|
|
||||||
/** Per-field messages, as admin-crud.js and feedback.js send them. */
|
|
||||||
export type FieldErrors = Record<string, string>;
|
|
||||||
|
|
||||||
export declare class ApiError extends Error {
|
|
||||||
status?: number;
|
|
||||||
fields?: FieldErrors;
|
|
||||||
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare function get<T = unknown>(
|
|
||||||
path: string,
|
|
||||||
options?: { ttl?: number; fallback?: T },
|
|
||||||
): Promise<T>;
|
|
||||||
|
|
||||||
export declare function invalidate(path?: string): void;
|
|
||||||
|
|
||||||
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
|
|
||||||
|
|
||||||
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
|
|
||||||
|
|
||||||
/* A 204 comes back as null. */
|
|
||||||
export declare function del<T = null>(path: string): Promise<T>;
|
|
||||||
|
|
@ -13,40 +13,21 @@
|
||||||
staring at an empty table.
|
staring at an empty table.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { get, post, ApiError } from "./api.js";
|
import { get, post, ApiError } from "./api.js";
|
||||||
import type { Role } from "./roles.ts";
|
|
||||||
|
|
||||||
/* What currentUser() in server/src/auth.js returns, and what
|
const AuthContext = createContext(null);
|
||||||
/auth/me and /auth/login send under `user`. */
|
|
||||||
export type AdminUser = {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
role: Role;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuthResponse = { user: AdminUser };
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
type AuthValue = {
|
|
||||||
user: AdminUser | null;
|
|
||||||
loading: boolean;
|
|
||||||
login: (email: string, password: string) => Promise<AdminUser>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthValue | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [user, setUser] = useState<AdminUser | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
|
|
||||||
get<AuthResponse>("/auth/me", { ttl: 0 })
|
get("/auth/me", { ttl: 0 })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!ignore) setUser(data.user);
|
if (!ignore) setUser(data.user);
|
||||||
})
|
})
|
||||||
|
|
@ -62,8 +43,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email, password) => {
|
||||||
const data = await post<AuthResponse>("/auth/login", { email, password });
|
const data = await post("/auth/login", { email, password });
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
return data.user;
|
return data.user;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -92,7 +73,7 @@ export function useAuth() {
|
||||||
|
|
||||||
/* Signals a session that ended while the page was open — the
|
/* Signals a session that ended while the page was open — the
|
||||||
admin pages call this when a request comes back 401. */
|
admin pages call this when a request comes back 401. */
|
||||||
export function isUnauthorized(error: unknown): boolean {
|
export function isUnauthorized(error) {
|
||||||
return error instanceof ApiError && error.status === 401;
|
return error instanceof ApiError && error.status === 401;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, type ComponentType, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
SECTION MANIFEST
|
SECTION MANIFEST
|
||||||
|
|
@ -33,69 +33,25 @@ import { useState, type ComponentType, type ReactNode } from "react";
|
||||||
want them.
|
want them.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* What every section Component is handed on top of its own props. */
|
export function useSectionManifest(manifest) {
|
||||||
export type SectionInjected = { accent: string; view?: string };
|
|
||||||
|
|
||||||
export type SectionToggleProps = {
|
|
||||||
view: string;
|
|
||||||
setView: (value: string) => void;
|
|
||||||
accent: string;
|
|
||||||
options: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SectionViews = {
|
|
||||||
options: string[];
|
|
||||||
default?: string;
|
|
||||||
Toggle: ComponentType<SectionToggleProps>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SectionHeading = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
blurb?: string;
|
|
||||||
accent: string;
|
|
||||||
background: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
|
|
||||||
Component: ComponentType<P & SectionInjected>;
|
|
||||||
/* The Component decides P; props are checked against it. */
|
|
||||||
props?: NoInfer<P>;
|
|
||||||
views?: SectionViews;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* A manifest mixes components with different props, which no single
|
|
||||||
array element type can check. This checks each entry against its
|
|
||||||
own Component where it's written, then forgets P so the entries
|
|
||||||
fit one array. */
|
|
||||||
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
|
|
||||||
return entry as unknown as SectionEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ManifestSection = SectionHeading & {
|
|
||||||
actions?: ReactNode;
|
|
||||||
content: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
|
|
||||||
// One entry per section that has a toggle, seeded from its
|
// One entry per section that has a toggle, seeded from its
|
||||||
// declared default so the control is right before anything loads.
|
// declared default so the control is right before anything loads.
|
||||||
const [views, setViews] = useState<Record<string, string | null>>(() =>
|
const [views, setViews] = useState(() =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
manifest
|
manifest
|
||||||
.filter(entry => entry.views)
|
.filter(entry => entry.views)
|
||||||
.map(entry => [
|
.map(entry => [
|
||||||
entry.id,
|
entry.id,
|
||||||
entry.views?.default ?? entry.views?.options[0] ?? null,
|
entry.views.default ?? entry.views.options?.[0] ?? null,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
|
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
|
||||||
|
|
||||||
return manifest.map(entry => {
|
return manifest.map(entry => {
|
||||||
const { Component, props, views: spec, ...heading } = entry;
|
const { Component, props, views: spec, ...heading } = entry;
|
||||||
const view = views[entry.id] ?? undefined;
|
const view = views[entry.id];
|
||||||
const Toggle = spec?.Toggle;
|
const Toggle = spec?.Toggle;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -103,7 +59,7 @@ export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[]
|
||||||
|
|
||||||
actions: Toggle ? (
|
actions: Toggle ? (
|
||||||
<Toggle
|
<Toggle
|
||||||
view={view ?? ""}
|
view={view}
|
||||||
setView={value => setView(entry.id, value)}
|
setView={value => setView(entry.id, value)}
|
||||||
accent={entry.accent}
|
accent={entry.accent}
|
||||||
options={spec.options}
|
options={spec.options}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@
|
||||||
* and the public/ layout stay the frontend's business.
|
* and the public/ layout stay the frontend's business.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OrgKind } from './hrefs.ts'
|
|
||||||
|
|
||||||
export type DatePrecision = 'year' | 'month' | 'day'
|
export type DatePrecision = 'year' | 'month' | 'day'
|
||||||
|
|
||||||
/** What an entry is about. Drives the marker and the body layout. */
|
/** What an entry is about. Drives the marker and the body layout. */
|
||||||
|
|
@ -43,8 +41,6 @@ export type TimelineRef = {
|
||||||
kind: RefKind
|
kind: RefKind
|
||||||
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||||
id: string
|
id: string
|
||||||
/** Only on organizations: history.js copies v_timeline.org_kind. */
|
|
||||||
orgKind?: OrgKind
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filename plus the table it came from; the directory is derived
|
/** Filename plus the table it came from; the directory is derived
|
||||||
|
|
|
||||||
|
|
@ -106,22 +106,12 @@ export type EventRecord = {
|
||||||
hosts: EventHost[]
|
hosts: EventHost[]
|
||||||
description: string[]
|
description: string[]
|
||||||
links: Link[]
|
links: Link[]
|
||||||
/** The instagram link's label, the handle. See splitLinks in shape.js. */
|
instagram?: Link | null
|
||||||
instagram?: string | null
|
|
||||||
blocks: ContentBlock[]
|
blocks: ContentBlock[]
|
||||||
people: EventPerson[]
|
people: EventPerson[]
|
||||||
awards: EventAward[]
|
awards: EventAward[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of GET /events: shapeEvent without the detail-only
|
|
||||||
* blocks, people and awards. */
|
|
||||||
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
|
|
||||||
|
|
||||||
/** An event_sections row, as GET /events sends it beside the list. */
|
|
||||||
export type EventSection = { id: string; name: string; sort_order: number }
|
|
||||||
|
|
||||||
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
|
|
||||||
|
|
||||||
export const useEvent = (id?: string): Resource<EventRecord> =>
|
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||||
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||||
|
|
||||||
|
|
@ -167,17 +157,6 @@ export type OrgEvent = {
|
||||||
color?: string | null
|
color?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** regions.scope's CHECK values. */
|
|
||||||
export type RegionScope = 'domestic' | 'international' | 'virtual'
|
|
||||||
|
|
||||||
/** A region_areas row as attachRegionDetails sends it. */
|
|
||||||
export type RegionArea = {
|
|
||||||
area_code: string
|
|
||||||
share: number
|
|
||||||
edge: 'top' | 'bottom' | null
|
|
||||||
note: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OrganizationRecord = {
|
export type OrganizationRecord = {
|
||||||
id: string
|
id: string
|
||||||
kind: 'national' | 'region' | 'chapter' | 'partner'
|
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||||
|
|
@ -197,16 +176,14 @@ export type OrganizationRecord = {
|
||||||
blocks: ContentBlock[]
|
blocks: ContentBlock[]
|
||||||
links: Link[]
|
links: Link[]
|
||||||
socials: Link[]
|
socials: Link[]
|
||||||
/* splitLinks in shape.js lifts these out as bare strings: the
|
website?: Link | null
|
||||||
website's url, the email's label, the instagram handle. */
|
email?: Link | null
|
||||||
website?: string | null
|
instagram?: Link | null
|
||||||
email?: string | null
|
|
||||||
instagram?: string | null
|
|
||||||
/** Shape depends on `kind`; empty object for national and partner. */
|
/** Shape depends on `kind`; empty object for national and partner. */
|
||||||
details: {
|
details: {
|
||||||
scope?: RegionScope | null
|
scope?: string | null
|
||||||
map_note?: string | null
|
map_note?: string | null
|
||||||
areas?: RegionArea[]
|
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
||||||
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||||
region_id?: string | null
|
region_id?: string | null
|
||||||
region_name?: string | null
|
region_name?: string | null
|
||||||
|
|
@ -220,14 +197,6 @@ export type OrganizationRecord = {
|
||||||
events: OrgEvent[]
|
events: OrgEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of GET /organizations: the card surface and details,
|
|
||||||
* without the sections only the org's own page loads. */
|
|
||||||
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
|
|
||||||
|
|
||||||
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||||
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||||
|
|
||||||
|
|
@ -245,8 +214,7 @@ export type TeamRecord = {
|
||||||
description: string[]
|
description: string[]
|
||||||
links: Link[]
|
links: Link[]
|
||||||
socials: Link[]
|
socials: Link[]
|
||||||
/** The instagram handle. See splitLinks in shape.js. */
|
instagram?: Link | null
|
||||||
instagram?: string | null
|
|
||||||
blocks: ContentBlock[]
|
blocks: ContentBlock[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
||||||
setError(null)
|
setError(null)
|
||||||
setNotFound(false)
|
setNotFound(false)
|
||||||
try {
|
try {
|
||||||
const body = await get<Record<string, unknown> | null>(path as string)
|
const body = await get(path as string)
|
||||||
if (!live) return
|
if (!live) return
|
||||||
// A 200 with the key absent is a server-side shaping bug,
|
// A 200 with the key absent is a server-side shaping bug,
|
||||||
// not an empty record. Say so rather than rendering a page
|
// not an empty record. Say so rather than rendering a page
|
||||||
|
|
|
||||||
20
src/navConfig.d.ts
vendored
20
src/navConfig.d.ts
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
/* Types for navConfig.js. */
|
|
||||||
|
|
||||||
export type PageLink = { label: string; path: string };
|
|
||||||
|
|
||||||
export type PageSectionLink = { label: string; hash: string };
|
|
||||||
|
|
||||||
export type NavAction = {
|
|
||||||
label: string;
|
|
||||||
to: string;
|
|
||||||
/** Picks the styling, not the destination. */
|
|
||||||
variant: "ghost" | "fancy";
|
|
||||||
external?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare const PAGE_LINKS: PageLink[];
|
|
||||||
|
|
||||||
/** Keyed by the owning page's path. */
|
|
||||||
export declare const PAGE_SECTIONS: Record<string, PageSectionLink[]>;
|
|
||||||
|
|
||||||
export declare const NAV_ACTIONS: NavAction[];
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import PageShell from "../components/PageShell.tsx";
|
import PageShell from "../components/PageShell.tsx";
|
||||||
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
|
import { useSectionManifest } from "../lib/sections.tsx";
|
||||||
import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
|
import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
|
||||||
import OrgListVertical from "./sections/OrgList-Vertical.tsx";
|
import OrgListVertical from "./sections/OrgList-Vertical.tsx";
|
||||||
import OrgListCards from "./sections/OrgList-Card.tsx";
|
import OrgListCards from "./sections/OrgList-Card.tsx";
|
||||||
|
|
@ -13,7 +13,7 @@ import OrgListCards from "./sections/OrgList-Card.tsx";
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
defineSection({
|
{
|
||||||
id: "chapters",
|
id: "chapters",
|
||||||
title: "Local Chapters",
|
title: "Local Chapters",
|
||||||
blurb:
|
blurb:
|
||||||
|
|
@ -22,8 +22,8 @@ const SECTIONS = [
|
||||||
background: "#eef9fb",
|
background: "#eef9fb",
|
||||||
Component: OrgListMap,
|
Component: OrgListMap,
|
||||||
views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
|
views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
|
||||||
}),
|
},
|
||||||
defineSection({
|
{
|
||||||
id: "regions",
|
id: "regions",
|
||||||
title: "Unity Regions",
|
title: "Unity Regions",
|
||||||
blurb:
|
blurb:
|
||||||
|
|
@ -40,8 +40,8 @@ const SECTIONS = [
|
||||||
{ key: "international", title: "International Unity Regions" },
|
{ key: "international", title: "International Unity Regions" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
defineSection({
|
{
|
||||||
id: "partners",
|
id: "partners",
|
||||||
title: "Partner Organizations",
|
title: "Partner Organizations",
|
||||||
blurb:
|
blurb:
|
||||||
|
|
@ -54,7 +54,7 @@ const SECTIONS = [
|
||||||
pageLabel: "Partner page",
|
pageLabel: "Partner page",
|
||||||
empty: "· Partner organizations coming soon ·",
|
empty: "· Partner organizations coming soon ·",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function CommunityPage() {
|
export default function CommunityPage() {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
import PageShell from '../components/PageShell.tsx'
|
||||||
import PageState from '../components/PageState.tsx'
|
import PageState from '../components/PageState.tsx'
|
||||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||||
|
|
@ -86,7 +86,7 @@ export default function EventDetail() {
|
||||||
const accent = event.color || TEAL
|
const accent = event.color || TEAL
|
||||||
const groups = peopleGroups(event.people, accent)
|
const groups = peopleGroups(event.people, accent)
|
||||||
|
|
||||||
const sections: ShellSection[] = [
|
const sections = [
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
title: event.theme || 'About',
|
title: event.theme || 'About',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import PageShell from "../components/PageShell.tsx";
|
import PageShell from "../components/PageShell.tsx";
|
||||||
import { defineSection, useSectionManifest } from "../lib/sections.tsx";
|
import { useSectionManifest } from "../lib/sections.tsx";
|
||||||
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
|
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
|
@ -35,10 +35,10 @@ const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
||||||
|
|
||||||
/* Pinned on every band. Named rather than repeated so turning this
|
/* Pinned on every band. Named rather than repeated so turning this
|
||||||
page into "everything, filtered" later is one deletion. */
|
page into "everything, filtered" later is one deletion. */
|
||||||
const RETREATS = { type: "retreat" } as const;
|
const RETREATS = { type: "retreat" };
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
defineSection({
|
{
|
||||||
id: "national",
|
id: "national",
|
||||||
title: "National Retreats",
|
title: "National Retreats",
|
||||||
blurb: "Our flagship gatherings, open to young adults across the country.",
|
blurb: "Our flagship gatherings, open to young adults across the country.",
|
||||||
|
|
@ -47,8 +47,8 @@ const SECTIONS = [
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "national", ...RETREATS },
|
props: { section: "national", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "carousel" },
|
views: { ...CARD_VIEWS, default: "carousel" },
|
||||||
}),
|
},
|
||||||
defineSection({
|
{
|
||||||
id: "regional",
|
id: "regional",
|
||||||
title: "Regional Retreats",
|
title: "Regional Retreats",
|
||||||
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
blurb: "Smaller gatherings hosted by regions throughout the year.",
|
||||||
|
|
@ -57,8 +57,8 @@ const SECTIONS = [
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "regional", ...RETREATS },
|
props: { section: "regional", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
}),
|
},
|
||||||
defineSection({
|
{
|
||||||
id: "partner",
|
id: "partner",
|
||||||
title: "Partner Events",
|
title: "Partner Events",
|
||||||
blurb: "Retreats hosted by organizations we collaborate with.",
|
blurb: "Retreats hosted by organizations we collaborate with.",
|
||||||
|
|
@ -67,13 +67,13 @@ const SECTIONS = [
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "partner", ...RETREATS },
|
props: { section: "partner", ...RETREATS },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
}),
|
},
|
||||||
|
|
||||||
/* The other three scopes, ready to uncomment. Each needs an accent
|
/* The other three scopes, ready to uncomment. Each needs an accent
|
||||||
and a background of its own — those are presentation and live
|
and a background of its own — those are presentation and live
|
||||||
here, not in event_sections.
|
here, not in event_sections.
|
||||||
|
|
||||||
defineSection({
|
{
|
||||||
id: "local",
|
id: "local",
|
||||||
title: "Local Events",
|
title: "Local Events",
|
||||||
blurb: "Hosted by individual chapters.",
|
blurb: "Hosted by individual chapters.",
|
||||||
|
|
@ -82,8 +82,8 @@ const SECTIONS = [
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "local", ...RETREATS_ONLY },
|
props: { section: "local", ...RETREATS_ONLY },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
}),
|
},
|
||||||
defineSection({
|
{
|
||||||
id: "international",
|
id: "international",
|
||||||
title: "International Events",
|
title: "International Events",
|
||||||
blurb: "Gatherings beyond the US.",
|
blurb: "Gatherings beyond the US.",
|
||||||
|
|
@ -92,8 +92,8 @@ const SECTIONS = [
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "international", ...RETREATS_ONLY },
|
props: { section: "international", ...RETREATS_ONLY },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
}),
|
},
|
||||||
defineSection({
|
{
|
||||||
id: "other",
|
id: "other",
|
||||||
title: "Other Events",
|
title: "Other Events",
|
||||||
blurb: "Everything that doesn't fit the categories above.",
|
blurb: "Everything that doesn't fit the categories above.",
|
||||||
|
|
@ -102,7 +102,7 @@ const SECTIONS = [
|
||||||
Component: EventListCards,
|
Component: EventListCards,
|
||||||
props: { section: "other", ...RETREATS_ONLY },
|
props: { section: "other", ...RETREATS_ONLY },
|
||||||
views: { ...CARD_VIEWS, default: "grid" },
|
views: { ...CARD_VIEWS, default: "grid" },
|
||||||
}),
|
},
|
||||||
|
|
||||||
*/
|
*/
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -25,37 +25,9 @@ import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||||
|
|
||||||
/* feedback.status's CHECK values, in triage order. */
|
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"] as const;
|
|
||||||
|
|
||||||
type FeedbackStatus = (typeof STATUSES)[number];
|
const STATUS_STYLE = {
|
||||||
|
|
||||||
/* The columns GET /api/admin/feedback selects. */
|
|
||||||
type FeedbackRow = {
|
|
||||||
id: number;
|
|
||||||
created_at: string;
|
|
||||||
feedback_type: string;
|
|
||||||
message: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
page_path: string | null;
|
|
||||||
section_id: string | null;
|
|
||||||
status: FeedbackStatus;
|
|
||||||
admin_note: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FeedbackPage = {
|
|
||||||
feedback: FeedbackRow[];
|
|
||||||
/** Unfiltered, one per status. */
|
|
||||||
counts: Record<FeedbackStatus, number>;
|
|
||||||
nextCursor: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FeedbackChanges = { status?: FeedbackStatus; admin_note?: string };
|
|
||||||
|
|
||||||
type StatusFilter = FeedbackStatus | "all";
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<FeedbackStatus, string> = {
|
|
||||||
new: "bg-[#138ba0] text-white",
|
new: "bg-[#138ba0] text-white",
|
||||||
read: "bg-[#eef9fb] text-[#138ba0]",
|
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||||
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||||
|
|
@ -65,7 +37,7 @@ const STATUS_STYLE: Record<FeedbackStatus, string> = {
|
||||||
|
|
||||||
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
||||||
// won't parse without the T and the Z.
|
// won't parse without the T and the Z.
|
||||||
function formatDate(value: string) {
|
function formatDate(value) {
|
||||||
const date = new Date(`${value.replace(" ", "T")}Z`);
|
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||||
return date.toLocaleString(undefined, {
|
return date.toLocaleString(undefined, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
|
|
@ -73,34 +45,26 @@ function formatDate(value: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function locationOf(row: FeedbackRow) {
|
function locationOf(row) {
|
||||||
if (!row.page_path) return "Not page-specific";
|
if (!row.page_path) return "Not page-specific";
|
||||||
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── One submission ──────────────────────────────────────────── */
|
/* ── One submission ──────────────────────────────────────────── */
|
||||||
|
|
||||||
type FeedbackCardProps = {
|
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
||||||
row: FeedbackRow;
|
|
||||||
onChange: (row: FeedbackRow) => void;
|
|
||||||
onRemove: (row: FeedbackRow) => void;
|
|
||||||
canWrite: boolean;
|
|
||||||
canRemove: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: FeedbackCardProps) {
|
|
||||||
const [note, setNote] = useState(row.admin_note ?? "");
|
const [note, setNote] = useState(row.admin_note ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
const noteDirty = note !== (row.admin_note ?? "");
|
const noteDirty = note !== (row.admin_note ?? "");
|
||||||
|
|
||||||
async function save(changes: FeedbackChanges) {
|
async function save(changes) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await patch<{ feedback: FeedbackRow }>(`/admin/feedback/${row.id}`, changes);
|
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||||
onChange(data.feedback);
|
onChange(data.feedback);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||||
|
|
@ -177,8 +141,7 @@ function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }: Feedback
|
||||||
id={`status-${row.id}`}
|
id={`status-${row.id}`}
|
||||||
value={row.status}
|
value={row.status}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
// The options are STATUSES, so the value is always one of them.
|
onChange={(e) => save({ status: e.target.value })}
|
||||||
onChange={(e) => save({ status: e.target.value as FeedbackStatus })}
|
|
||||||
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||||
>
|
>
|
||||||
{STATUSES.map((s) => (
|
{STATUSES.map((s) => (
|
||||||
|
|
@ -265,22 +228,22 @@ export default function AdminFeedback() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [status, setStatus] = useState<StatusFilter>("new");
|
const [status, setStatus] = useState("new");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [search, setSearch] = useState(""); // applied, not typed
|
const [search, setSearch] = useState(""); // applied, not typed
|
||||||
|
|
||||||
const [rows, setRows] = useState<FeedbackRow[]>([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [counts, setCounts] = useState<Partial<Record<FeedbackStatus, number>>>({});
|
const [counts, setCounts] = useState({});
|
||||||
const [cursor, setCursor] = useState<number | null>(null);
|
const [cursor, setCursor] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Minimums, not equality — see lib/roles.ts.
|
// Minimums, not equality — see lib/roles.ts.
|
||||||
const canWrite = roleCanWrite(user);
|
const canWrite = roleCanWrite(user);
|
||||||
const canRemove = canDelete(user);
|
const canRemove = canDelete(user);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (before: number | null = null) => {
|
async (before = null) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -290,7 +253,7 @@ export default function AdminFeedback() {
|
||||||
if (before) params.set("before", String(before));
|
if (before) params.set("before", String(before));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await get<FeedbackPage>(`/admin/feedback?${params}`, { ttl: 0 });
|
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||||
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||||
setCounts(data.counts);
|
setCounts(data.counts);
|
||||||
setCursor(data.nextCursor);
|
setCursor(data.nextCursor);
|
||||||
|
|
@ -314,7 +277,7 @@ export default function AdminFeedback() {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
function replaceRow(updated: FeedbackRow) {
|
function replaceRow(updated) {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev
|
prev
|
||||||
.map((row) => (row.id === updated.id ? updated : row))
|
.map((row) => (row.id === updated.id ? updated : row))
|
||||||
|
|
@ -327,7 +290,7 @@ export default function AdminFeedback() {
|
||||||
|
|
||||||
// The deleted row is passed whole rather than by id: its status
|
// The deleted row is passed whole rather than by id: its status
|
||||||
// is what says which tab count to drop.
|
// is what says which tab count to drop.
|
||||||
function removeRow(removed: FeedbackRow) {
|
function removeRow(removed) {
|
||||||
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||||
setCounts((prev) => ({
|
setCounts((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -335,7 +298,7 @@ export default function AdminFeedback() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: Array<{ id: StatusFilter; label: string; count?: number }> = [
|
const tabs = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,17 @@
|
||||||
record) wants to be a separate component that fails on its own.
|
record) wants to be a separate component that fails on its own.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
import { SITE_VERSION } from "../../lib/version.ts";
|
import { SITE_VERSION } from "../../lib/version.ts";
|
||||||
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target, type AdminNavItem } from "./adminNav.js";
|
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
|
||||||
|
|
||||||
/* One card. The title link is stretched over the whole card with
|
/* One card. The title link is stretched over the whole card with
|
||||||
`after:absolute`, which makes the card clickable without nesting
|
`after:absolute`, which makes the card clickable without nesting
|
||||||
an anchor inside an anchor; the sub-links sit above it on z-10 so
|
an anchor inside an anchor; the sub-links sit above it on z-10 so
|
||||||
they stay separately clickable. */
|
they stay separately clickable. */
|
||||||
function NavCard({ item }: { item: AdminNavItem }) {
|
function NavCard({ item }) {
|
||||||
const to = target(item);
|
const to = target(item);
|
||||||
|
|
||||||
// Drop the child that just repeats the card's own destination —
|
// Drop the child that just repeats the card's own destination —
|
||||||
|
|
@ -76,15 +75,7 @@ function NavCard({ item }: { item: AdminNavItem }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardBlock({
|
function CardBlock({ title, blurb, children }) {
|
||||||
title,
|
|
||||||
blurb,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
blurb?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-3">
|
||||||
|
|
@ -96,7 +87,7 @@ function CardBlock({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelSection({ title, children }: { title: string; children: ReactNode }) {
|
function PanelSection({ title, children }) {
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
|
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
|
@ -116,7 +107,7 @@ const QUICK_ADD = [
|
||||||
|
|
||||||
export default function AdminHome() {
|
export default function AdminHome() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const role = user ? (ROLE_LABELS[user.role] ?? user.role) : undefined;
|
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ export default function AdminLayout() {
|
||||||
// What the page below has published about itself — a record
|
// What the page below has published about itself — a record
|
||||||
// name, or null on a list. setDetail is stable so publishing
|
// name, or null on a list. setDetail is stable so publishing
|
||||||
// can't loop.
|
// can't loop.
|
||||||
const [detail, setDetail] = useState<string | null>(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const stableSet = useCallback((value: string | null) => setDetail(value), []);
|
const stableSet = useCallback((value) => setDetail(value), []);
|
||||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
starts working.
|
starts working.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
|
|
@ -29,12 +29,12 @@ export default function AdminLogin() {
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const destination = location.state?.from?.pathname ?? "/admin/home";
|
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,54 +24,23 @@
|
||||||
the reason shows up before the click rather than after it.
|
the reason shows up before the click rather than after it.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { del, get, patch } from "../../lib/api.js";
|
import { del, get, patch } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ROLES, ROLE_LABELS, ROLE_NOTES, type Role } from "../../lib/roles.ts";
|
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||||
|
|
||||||
/* An admin_users row as panel.js selects it, with its live session count. */
|
|
||||||
type PanelUser = {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
role: Role;
|
|
||||||
is_active: number;
|
|
||||||
created_at: string;
|
|
||||||
last_login_at: string | null;
|
|
||||||
sessions: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* GET /api/admin/panel/overview. A count is null when its table
|
|
||||||
doesn't exist yet. */
|
|
||||||
type Overview = {
|
|
||||||
system: {
|
|
||||||
schemaVersion: number;
|
|
||||||
dbPath: string | null;
|
|
||||||
nodeVersion: string;
|
|
||||||
platform: string;
|
|
||||||
uptimeSeconds: number;
|
|
||||||
startedAt: string;
|
|
||||||
sessions: number | null;
|
|
||||||
roles: Role[];
|
|
||||||
};
|
|
||||||
content: Array<{ label: string; count: number | null }>;
|
|
||||||
users: PanelUser[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserResponse = { user: PanelUser };
|
|
||||||
|
|
||||||
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
||||||
marker that says so. Left alone, browsers read it as local time
|
marker that says so. Left alone, browsers read it as local time
|
||||||
and last-login drifts by the timezone offset. */
|
and last-login drifts by the timezone offset. */
|
||||||
function when(value: string | null | undefined) {
|
function when(value) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function uptime(seconds: number | null | undefined) {
|
function uptime(seconds) {
|
||||||
if (seconds == null) return "—";
|
if (seconds == null) return "—";
|
||||||
const d = Math.floor(seconds / 86400);
|
const d = Math.floor(seconds / 86400);
|
||||||
const h = Math.floor((seconds % 86400) / 3600);
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
|
@ -81,15 +50,7 @@ function uptime(seconds: number | null | undefined) {
|
||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Block({
|
function Block({ title, note, children }) {
|
||||||
title,
|
|
||||||
note,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
note?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-8 first:mt-0">
|
<section className="mt-8 first:mt-0">
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-3">
|
||||||
|
|
@ -101,7 +62,7 @@ function Block({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
function Stat({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
|
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
|
||||||
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
|
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
|
||||||
|
|
@ -116,23 +77,23 @@ export default function AdminPanel() {
|
||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [data, setData] = useState<Overview | null>(null);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Which row is mid-request, and what went wrong on it. Scoped to
|
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||||
// the row so a failure on one account doesn't blank the table.
|
// the row so a failure on one account doesn't blank the table.
|
||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
const [busyId, setBusyId] = useState(null);
|
||||||
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
|
const [rowError, setRowError] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setData(await get<Overview>("/admin/panel/overview", { ttl: 0 }));
|
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
setError((err instanceof Error && err.message) || "Couldn't load the panel.");
|
setError(err.message || "Couldn't load the panel.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +105,7 @@ export default function AdminPanel() {
|
||||||
|
|
||||||
/* Replace the one row the server returns rather than refetching
|
/* Replace the one row the server returns rather than refetching
|
||||||
the whole overview — the counts didn't change. */
|
the whole overview — the counts didn't change. */
|
||||||
function mergeUser(updated: PanelUser) {
|
function mergeUser(updated) {
|
||||||
setData((current) =>
|
setData((current) =>
|
||||||
current
|
current
|
||||||
? {
|
? {
|
||||||
|
|
@ -155,37 +116,30 @@ export default function AdminPanel() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(id: number, work: () => Promise<PanelUser>) {
|
async function run(id, work) {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setRowError(null);
|
setRowError(null);
|
||||||
try {
|
try {
|
||||||
mergeUser(await work());
|
mergeUser(await work());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
setRowError({ id, message: (err instanceof Error && err.message) || "That didn't work." });
|
setRowError({ id, message: err.message || "That didn't work." });
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeRole = (row: PanelUser, role: Role) =>
|
const changeRole = (row, role) =>
|
||||||
|
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||||
|
|
||||||
|
const setActive = (row, is_active) =>
|
||||||
run(
|
run(
|
||||||
row.id,
|
row.id,
|
||||||
async () => (await patch<UserResponse>(`/admin/panel/users/${row.id}`, { role })).user,
|
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||||
);
|
);
|
||||||
|
|
||||||
const setActive = (row: PanelUser, is_active: number) =>
|
const revoke = (row) =>
|
||||||
run(
|
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
|
||||||
row.id,
|
|
||||||
async () =>
|
|
||||||
(await patch<UserResponse>(`/admin/panel/users/${row.id}`, { is_active })).user,
|
|
||||||
);
|
|
||||||
|
|
||||||
const revoke = (row: PanelUser) =>
|
|
||||||
run(
|
|
||||||
row.id,
|
|
||||||
async () => (await del<UserResponse>(`/admin/panel/users/${row.id}/sessions`)).user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -210,9 +164,6 @@ export default function AdminPanel() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not loading and no error means the overview arrived.
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const { system, content, users } = data;
|
const { system, content, users } = data;
|
||||||
const activeSupers = users.filter(
|
const activeSupers = users.filter(
|
||||||
(u) => u.role === "superadmin" && u.is_active === 1,
|
(u) => u.role === "superadmin" && u.is_active === 1,
|
||||||
|
|
@ -303,7 +254,7 @@ export default function AdminPanel() {
|
||||||
<select
|
<select
|
||||||
value={row.role}
|
value={row.role}
|
||||||
disabled={locked || busy}
|
disabled={locked || busy}
|
||||||
onChange={(e) => changeRole(row, e.target.value as Role)}
|
onChange={(e) => changeRole(row, e.target.value)}
|
||||||
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
||||||
title={
|
title={
|
||||||
isMe
|
isMe
|
||||||
|
|
|
||||||
|
|
@ -43,36 +43,26 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { get, post, patch, del, ApiError, type FieldErrors } from "../../lib/api.js";
|
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||||
import {
|
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||||
ADMIN_ENTITIES,
|
|
||||||
slugify,
|
|
||||||
type AdminOptions,
|
|
||||||
type AdminRow,
|
|
||||||
type FieldCondition,
|
|
||||||
} from "../../lib/adminSchema.js";
|
|
||||||
import { atLeast } from "../../lib/roles.ts";
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||||
|
|
||||||
/* A foreign key refusing to budge is the most common way a save or
|
/* A foreign key refusing to budge is the most common way a save or
|
||||||
delete fails here, and SQLite's own wording explains nothing to
|
delete fails here, and SQLite's own wording explains nothing to
|
||||||
whoever is filling in the form. */
|
whoever is filling in the form. */
|
||||||
function friendly(message: string, singular: string): string {
|
function friendly(message, singular) {
|
||||||
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
|
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
|
||||||
return `Something still points at this ${singular}. Reassign or remove those first.`;
|
return `Something still points at this ${singular}. Reassign or remove those first.`;
|
||||||
}
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RowResponse = { row: AdminRow };
|
|
||||||
|
|
||||||
type Notice = { tone: "ok" | "error"; text: string; recover?: "reload" };
|
|
||||||
|
|
||||||
export default function EntityEdit() {
|
export default function EntityEdit() {
|
||||||
const { entity: entityKey, id } = useParams();
|
const { entity: entityKey, id } = useParams();
|
||||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
const manifest = ADMIN_ENTITIES[entityKey];
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
|
@ -95,9 +85,9 @@ export default function EntityEdit() {
|
||||||
// Hoisted above the loading guards: the title hook below is a
|
// Hoisted above the loading guards: the title hook below is a
|
||||||
// hook, so it can't sit after an early return, and it needs the
|
// hook, so it can't sit after an early return, and it needs the
|
||||||
// same paths the heading uses.
|
// same paths the heading uses.
|
||||||
const slugPaths: string[] = Array.isArray(manifest?.slugFrom)
|
const slugPaths = Array.isArray(manifest?.slugFrom)
|
||||||
? manifest.slugFrom
|
? manifest.slugFrom
|
||||||
: [manifest?.slugFrom].filter((path): path is string => Boolean(path));
|
: [manifest?.slugFrom].filter(Boolean);
|
||||||
|
|
||||||
// Everything before the last path is a qualifier: a fact about
|
// Everything before the last path is a qualifier: a fact about
|
||||||
// another field rather than something to type. It renders as
|
// another field rather than something to type. It renders as
|
||||||
|
|
@ -107,7 +97,7 @@ export default function EntityEdit() {
|
||||||
|
|
||||||
// Empty until every qualifier is chosen, because half a prefix
|
// Empty until every qualifier is chosen, because half a prefix
|
||||||
// would be saved into an id that then never matches.
|
// would be saved into an id that then never matches.
|
||||||
const prefixOf = (source: AdminRow | null) => {
|
const prefixOf = (source) => {
|
||||||
if (qualifierPaths.length === 0) return "";
|
if (qualifierPaths.length === 0) return "";
|
||||||
const parts = qualifierPaths.map((path) => getPath(source, path));
|
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||||
if (parts.some((part) => !part)) return "";
|
if (parts.some((part) => !part)) return "";
|
||||||
|
|
@ -119,9 +109,9 @@ export default function EntityEdit() {
|
||||||
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const tailOf = (source: AdminRow | null) => {
|
const tailOf = (source) => {
|
||||||
const prefix = prefixOf(source);
|
const prefix = prefixOf(source);
|
||||||
const value = String(source?.id ?? "");
|
const value = source?.id ?? "";
|
||||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -130,27 +120,24 @@ export default function EntityEdit() {
|
||||||
// names the field to read instead.
|
// names the field to read instead.
|
||||||
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||||
|
|
||||||
// Blank when neither is set yet, which reads as "nothing to name".
|
const [form, setForm] = useState(null);
|
||||||
const headingOf = (row: AdminRow) => String(getPath(row, headingPath) || row.id || "");
|
const [options, setOptions] = useState({});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
const [form, setForm] = useState<AdminRow | null>(null);
|
const [message, setMessage] = useState(null);
|
||||||
const [options, setOptions] = useState<AdminOptions>({});
|
|
||||||
const [errors, setErrors] = useState<Partial<FieldErrors>>({});
|
|
||||||
const [message, setMessage] = useState<Notice | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [slugTouched, setSlugTouched] = useState(false);
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
|
||||||
// The last state the server confirmed. Everything else compares
|
// The last state the server confirmed. Everything else compares
|
||||||
// against this to decide whether there's anything to lose.
|
// against this to decide whether there's anything to lose.
|
||||||
const baseline = useRef<string | null>(null);
|
const baseline = useRef(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
try {
|
try {
|
||||||
const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 });
|
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||||
setOptions(opts.options);
|
setOptions(opts.options);
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
|
|
@ -160,12 +147,12 @@ export default function EntityEdit() {
|
||||||
// server's column defaults apply to whatever isn't filled in.
|
// server's column defaults apply to whatever isn't filled in.
|
||||||
// No id key for an auto entity: the table assigns it, and
|
// No id key for an auto entity: the table assigns it, and
|
||||||
// sending "" would be an explicit value rather than an absence.
|
// sending "" would be an explicit value rather than an absence.
|
||||||
const blank: AdminRow = autoId ? {} : { id: "" };
|
const blank = autoId ? {} : { id: "" };
|
||||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||||
setForm(blank);
|
setForm(blank);
|
||||||
baseline.current = JSON.stringify(blank);
|
baseline.current = JSON.stringify(blank);
|
||||||
} else {
|
} else {
|
||||||
const data = await get<RowResponse>(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||||
setForm(data.row);
|
setForm(data.row);
|
||||||
baseline.current = JSON.stringify(data.row);
|
baseline.current = JSON.stringify(data.row);
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +187,7 @@ export default function EntityEdit() {
|
||||||
: isNew
|
: isNew
|
||||||
? `New ${manifest.singular}`
|
? `New ${manifest.singular}`
|
||||||
: form
|
: form
|
||||||
? headingOf(form)
|
? getPath(form, headingPath) || form.id
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -208,7 +195,7 @@ export default function EntityEdit() {
|
||||||
// Router entirely, so the only hook available is this one.
|
// Router entirely, so the only hook available is this one.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dirty) return undefined;
|
if (!dirty) return undefined;
|
||||||
const warn = (event: BeforeUnloadEvent) => {
|
const warn = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = "";
|
event.returnValue = "";
|
||||||
};
|
};
|
||||||
|
|
@ -221,19 +208,19 @@ export default function EntityEdit() {
|
||||||
|
|
||||||
/* ── Heading ───────────────────────────────────────────────── */
|
/* ── Heading ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
const heading = singleton ? manifest.label : headingOf(form);
|
const heading = singleton ? manifest.label : getPath(form, headingPath) || form.id;
|
||||||
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
const updatedAt = form.updated_at;
|
||||||
|
|
||||||
const children = manifest.children ?? [];
|
const children = manifest.children ?? [];
|
||||||
|
|
||||||
/* ── Actions ───────────────────────────────────────────────── */
|
/* ── Actions ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
const leave = (to: string) => {
|
const leave = (to) => {
|
||||||
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||||
navigate(to);
|
navigate(to);
|
||||||
};
|
};
|
||||||
|
|
||||||
const change = (path: string, value: unknown) => {
|
const change = (path, value) => {
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
let next = setPath(prev, path, value);
|
let next = setPath(prev, path, value);
|
||||||
// Recompose the id whenever one of its sources moves. The
|
// Recompose the id whenever one of its sources moves. The
|
||||||
|
|
@ -255,20 +242,20 @@ export default function EntityEdit() {
|
||||||
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
async function save() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const data = isNew
|
const data = isNew
|
||||||
? await post<RowResponse>(`/admin/${manifest.key}`, form)
|
? await post(`/admin/${manifest.key}`, form)
|
||||||
: await patch<RowResponse>(`/admin/${manifest.key}/${id}`, form);
|
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||||
|
|
||||||
setForm(data.row);
|
setForm(data.row);
|
||||||
baseline.current = JSON.stringify(data.row);
|
baseline.current = JSON.stringify(data.row);
|
||||||
setMessage({ tone: "ok", text: "Saved." });
|
setMessage({ tone: "ok", text: "Saved." });
|
||||||
|
|
||||||
if (isNew) navigate(`/admin/${manifest.key}/${String(data.row.id)}`, { replace: true });
|
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
|
|
||||||
|
|
@ -293,9 +280,9 @@ export default function EntityEdit() {
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const remove = async () => {
|
async function remove() {
|
||||||
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -311,9 +298,9 @@ export default function EntityEdit() {
|
||||||
: "Couldn't delete that.",
|
: "Couldn't delete that.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value;
|
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-24">
|
<div className="pb-24">
|
||||||
|
|
@ -344,8 +331,8 @@ export default function EntityEdit() {
|
||||||
!isNew && (
|
!isNew && (
|
||||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
<p className="text-sm text-[#4a6b72]">
|
<p className="text-sm text-[#4a6b72]">
|
||||||
{manifest.idLabel} #{String(form.id)}
|
{manifest.idLabel} #{form.id}
|
||||||
{updatedAt && <> · last saved {updatedAt}</>}
|
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -372,9 +359,9 @@ export default function EntityEdit() {
|
||||||
change("id", `${prefixOf(form)}${slugify(value)}`);
|
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!isNew && updatedAt && (
|
{!isNew && form.updated_at && (
|
||||||
<p className="mt-2 text-xs text-[#4a6b72]">
|
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||||
Last saved {updatedAt}
|
Last saved {form.updated_at}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -416,7 +403,7 @@ export default function EntityEdit() {
|
||||||
<Repeater
|
<Repeater
|
||||||
key={child.key}
|
key={child.key}
|
||||||
spec={child}
|
spec={child}
|
||||||
rows={form[child.key] as AdminRow[] | undefined}
|
rows={form[child.key]}
|
||||||
options={options}
|
options={options}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
errorPrefix={`${child.key}.`}
|
errorPrefix={`${child.key}.`}
|
||||||
|
|
|
||||||
|
|
@ -17,25 +17,20 @@ import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-r
|
||||||
|
|
||||||
import { get, ApiError } from "../../lib/api.js";
|
import { get, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import {
|
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
||||||
ADMIN_ENTITIES,
|
|
||||||
type AdminOptions,
|
|
||||||
type AdminRow,
|
|
||||||
type ListFilter,
|
|
||||||
} from "../../lib/adminSchema.js";
|
|
||||||
import { atLeast } from "../../lib/roles.ts";
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
|
|
||||||
export default function EntityList() {
|
export default function EntityList() {
|
||||||
const { entity: entityKey } = useParams();
|
const { entity: entityKey } = useParams();
|
||||||
const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined;
|
const manifest = ADMIN_ENTITIES[entityKey];
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const [rows, setRows] = useState<AdminRow[]>([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [options, setOptions] = useState<AdminOptions>({});
|
const [options, setOptions] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState(null);
|
||||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||||
|
|
||||||
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||||
|
|
@ -51,8 +46,8 @@ export default function EntityList() {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [list, opts] = await Promise.all([
|
const [list, opts] = await Promise.all([
|
||||||
get<{ rows: AdminRow[]; total: number }>(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||||
get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }),
|
get("/admin/options", { ttl: 60_000 }),
|
||||||
]);
|
]);
|
||||||
setRows(list.rows);
|
setRows(list.rows);
|
||||||
setOptions(opts.options);
|
setOptions(opts.options);
|
||||||
|
|
@ -77,18 +72,18 @@ export default function EntityList() {
|
||||||
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
|
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setParam(key: string, value: string) {
|
function setParam(key, value) {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
if (value) next.set(key, value);
|
if (value) next.set(key, value);
|
||||||
else next.delete(key);
|
else next.delete(key);
|
||||||
setParams(next, { replace: true });
|
setParams(next, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(filter: ListFilter): Array<readonly [string, string]> {
|
function labelFor(filter, value) {
|
||||||
if (filter.optionsFrom) {
|
if (filter.optionsFrom) {
|
||||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label] as const);
|
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||||
}
|
}
|
||||||
return (filter.options ?? []).map((o) => (typeof o === "string" ? [o, o] : o));
|
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -169,7 +164,7 @@ export default function EntityList() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={String(row.id)}
|
key={row.id}
|
||||||
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
||||||
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
||||||
>
|
>
|
||||||
|
|
@ -184,12 +179,10 @@ export default function EntityList() {
|
||||||
? row[column.key]
|
? row[column.key]
|
||||||
? "Yes"
|
? "Yes"
|
||||||
: "—"
|
: "—"
|
||||||
: row[column.key]
|
: row[column.key] || "—"}
|
||||||
? String(row[column.key])
|
|
||||||
: "—"}
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{String(row.id)}</td>
|
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@
|
||||||
|
|
||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
import { atLeast, type Role } from "../../lib/roles.ts";
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
import { ADMIN_HOME } from "./adminNav.js";
|
import { ADMIN_HOME } from "./adminNav.js";
|
||||||
|
|
||||||
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
|
export default function RequireRole({ role = "superadmin" }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
// RequireAuth is already showing its own placeholder above this.
|
// RequireAuth is already showing its own placeholder above this.
|
||||||
|
|
|
||||||
45
src/pages/admin/adminNav.d.ts
vendored
45
src/pages/admin/adminNav.d.ts
vendored
|
|
@ -1,45 +0,0 @@
|
||||||
/* Types for adminNav.js. */
|
|
||||||
|
|
||||||
import type { AdminUser } from "../../lib/auth.tsx";
|
|
||||||
|
|
||||||
export declare const ADMIN_HOME: string;
|
|
||||||
export declare const ADMIN_PANEL: string;
|
|
||||||
|
|
||||||
export type AdminArea = "home" | "cms" | "panel";
|
|
||||||
|
|
||||||
export declare const AREA_TITLES: Record<AdminArea, string>;
|
|
||||||
|
|
||||||
export type AdminNavLink = {
|
|
||||||
to: string;
|
|
||||||
label: string;
|
|
||||||
/** Only read by the home cards. */
|
|
||||||
blurb?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminNavBase = {
|
|
||||||
label: string;
|
|
||||||
blurb?: string;
|
|
||||||
separated?: boolean;
|
|
||||||
superOnly?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A tab. One with no `to` of its own opens its first child, so it
|
|
||||||
* must have one. */
|
|
||||||
export type AdminNavItem = AdminNavBase &
|
|
||||||
(
|
|
||||||
| { to: string; children?: AdminNavLink[] }
|
|
||||||
| { to?: undefined; children: [AdminNavLink, ...AdminNavLink[]] }
|
|
||||||
);
|
|
||||||
|
|
||||||
export declare const CMS_NAV: AdminNavItem[];
|
|
||||||
export declare const FORMS_NAV: AdminNavItem & { children: [AdminNavLink, ...AdminNavLink[]] };
|
|
||||||
export declare const PANEL_NAV: AdminNavItem;
|
|
||||||
export declare const NAV: AdminNavItem[];
|
|
||||||
|
|
||||||
export declare function navFor(user: AdminUser | null | undefined): AdminNavItem[];
|
|
||||||
|
|
||||||
export declare const matches: (pathname: string, to: string | null | undefined) => boolean;
|
|
||||||
|
|
||||||
export declare const target: (item: AdminNavItem) => string;
|
|
||||||
|
|
||||||
export declare function areaFor(pathname: string): AdminArea;
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
||||||
splitByStatus,
|
|
||||||
typesPresent,
|
|
||||||
useEvents,
|
|
||||||
type EventFilter,
|
|
||||||
} from "../../data/eventData.js";
|
|
||||||
import { eventHref } from "../../lib/hrefs.ts";
|
import { eventHref } from "../../lib/hrefs.ts";
|
||||||
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
|
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||||
import { seriesLabel } from "../../lib/eventSeries.ts";
|
import { seriesLabel } from "../../lib/eventSeries.ts";
|
||||||
import type { EventListItem } from "../../lib/useContent.ts";
|
|
||||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
EVENT LIST — CARDS
|
EVENT LIST — CARDS
|
||||||
|
|
@ -32,7 +25,7 @@ import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||||||
nothing but retreats shows no control at all.
|
nothing but retreats shows no control at all.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
const LOGO_FILES = import.meta.glob<string>("../../assets/event-logos/*.svg", {
|
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||||
eager: true,
|
eager: true,
|
||||||
import: "default",
|
import: "default",
|
||||||
});
|
});
|
||||||
|
|
@ -41,7 +34,7 @@ const LOGOS = Object.fromEntries(
|
||||||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || null;
|
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||||
|
|
||||||
/* Last resort only. The API already falls back to the host
|
/* Last resort only. The API already falls back to the host
|
||||||
organization's logo when an event doesn't name its own, so this
|
organization's logo when an event doesn't name its own, so this
|
||||||
|
|
@ -49,15 +42,7 @@ const logoSrc = (file: string | null | undefined) => (file && LOGOS[file]) || nu
|
||||||
const DEFAULT_ORG_LOGO = null;
|
const DEFAULT_ORG_LOGO = null;
|
||||||
|
|
||||||
/* An <img> that removes itself if the file 404s. */
|
/* An <img> that removes itself if the file 404s. */
|
||||||
function Logo({
|
function Logo({ file, alt = "", className }) {
|
||||||
file,
|
|
||||||
alt = "",
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
file: string | null | undefined;
|
|
||||||
alt?: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const src = logoSrc(file);
|
const src = logoSrc(file);
|
||||||
if (!src || failed) return null;
|
if (!src || failed) return null;
|
||||||
|
|
@ -138,15 +123,7 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
carousel has the same constraint — it needs the card's click to
|
carousel has the same constraint — it needs the card's click to
|
||||||
mean "bring this one to the front" on anything that isn't the
|
mean "bring this one to the front" on anything that isn't the
|
||||||
active slide. */
|
active slide. */
|
||||||
function TitleLink({
|
function TitleLink({ ev, linked, children }) {
|
||||||
ev,
|
|
||||||
linked,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
ev: Pick<EventListItem, "id">;
|
|
||||||
linked: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
if (!linked) return <>{children}</>;
|
if (!linked) return <>{children}</>;
|
||||||
return (
|
return (
|
||||||
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||||
|
|
@ -173,16 +150,6 @@ function TitleLink({
|
||||||
`linked` is the one thing a caller turns off: a card on the
|
`linked` is the one thing a caller turns off: a card on the
|
||||||
event's own page shouldn't link to the page it's already on.
|
event's own page shouldn't link to the page it's already on.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
type CardProps = {
|
|
||||||
ev: EventListItem;
|
|
||||||
defaultColor?: string;
|
|
||||||
accent?: string;
|
|
||||||
compact?: boolean;
|
|
||||||
interactive?: boolean;
|
|
||||||
linked?: boolean;
|
|
||||||
showType?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Card({
|
export function Card({
|
||||||
ev,
|
ev,
|
||||||
defaultColor = TEAL,
|
defaultColor = TEAL,
|
||||||
|
|
@ -191,7 +158,7 @@ export function Card({
|
||||||
interactive = true,
|
interactive = true,
|
||||||
linked = true,
|
linked = true,
|
||||||
showType = false,
|
showType = false,
|
||||||
}: CardProps) {
|
}) {
|
||||||
const past = ev.status === "past";
|
const past = ev.status === "past";
|
||||||
const color = ev.color || defaultColor;
|
const color = ev.color || defaultColor;
|
||||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||||
|
|
@ -390,23 +357,14 @@ export function Card({
|
||||||
them visible is one tap, and the row reads as what the section
|
them visible is one tap, and the row reads as what the section
|
||||||
contains rather than as a form control.
|
contains rather than as a form control.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
type TypeFilterValue = EventType | "all";
|
export function TypeFilter({ types, active, setActive, accent }) {
|
||||||
|
const chip = on => ({
|
||||||
type TypeFilterProps = {
|
|
||||||
types: typeof EVENT_TYPES;
|
|
||||||
active: TypeFilterValue;
|
|
||||||
setActive: (id: TypeFilterValue) => void;
|
|
||||||
accent: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps) {
|
|
||||||
const chip = (on: boolean) => ({
|
|
||||||
border: `1px solid ${accent}`,
|
border: `1px solid ${accent}`,
|
||||||
background: on ? accent : "transparent",
|
background: on ? accent : "transparent",
|
||||||
color: on ? "#ffffff" : accent,
|
color: on ? "#ffffff" : accent,
|
||||||
});
|
});
|
||||||
|
|
||||||
const button = (id: TypeFilterValue, label: string) => (
|
const button = (id, label) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => setActive(id)}
|
onClick={() => setActive(id)}
|
||||||
|
|
@ -434,12 +392,8 @@ export function TypeFilter({ types, active, setActive, accent }: TypeFilterProps
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
TOGGLE — the control for the section heading's action bar
|
TOGGLE — the control for the section heading's action bar
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
export function EventCardsToggle({
|
export function EventCardsToggle({ view, setView, accent }) {
|
||||||
view,
|
const btn = active => ({
|
||||||
setView,
|
|
||||||
accent,
|
|
||||||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
|
||||||
const btn = (active: boolean) => ({
|
|
||||||
background: active ? accent : "transparent",
|
background: active ? accent : "transparent",
|
||||||
color: active ? "#ffffff" : accent,
|
color: active ? "#ffffff" : accent,
|
||||||
});
|
});
|
||||||
|
|
@ -491,14 +445,6 @@ export function EventCardsToggle({
|
||||||
differently to someone waiting, so they're distinguished rather
|
differently to someone waiting, so they're distinguished rather
|
||||||
than all falling through to "coming soon".
|
than all falling through to "coming soon".
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
type EventListCardsProps = EventFilter & {
|
|
||||||
/** "carousel" or "grid". */
|
|
||||||
view?: string;
|
|
||||||
accent?: string;
|
|
||||||
defaultColor?: string;
|
|
||||||
empty?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EventListCards({
|
export default function EventListCards({
|
||||||
section,
|
section,
|
||||||
host,
|
host,
|
||||||
|
|
@ -508,7 +454,7 @@ export default function EventListCards({
|
||||||
accent = TEAL,
|
accent = TEAL,
|
||||||
defaultColor,
|
defaultColor,
|
||||||
empty = "· Events coming soon, stay connected for announcements ·",
|
empty = "· Events coming soon, stay connected for announcements ·",
|
||||||
}: EventListCardsProps) {
|
}) {
|
||||||
const { events: fetched, loading, error } = useEvents({
|
const { events: fetched, loading, error } = useEvents({
|
||||||
section,
|
section,
|
||||||
host,
|
host,
|
||||||
|
|
@ -519,7 +465,7 @@ export default function EventListCards({
|
||||||
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [showPast, setShowPast] = useState(false);
|
const [showPast, setShowPast] = useState(false);
|
||||||
const [activeType, setActiveType] = useState<TypeFilterValue>("all");
|
const [activeType, setActiveType] = useState("all");
|
||||||
|
|
||||||
/* What this band holds, which is what the chips offer — not the
|
/* What this band holds, which is what the chips offer — not the
|
||||||
full list of declared types, three quarters of which would be
|
full list of declared types, three quarters of which would be
|
||||||
|
|
@ -573,7 +519,7 @@ export default function EventListCards({
|
||||||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||||||
|
|
||||||
// Arrows and dots follow the section accent, not the active card.
|
// Arrows and dots follow the section accent, not the active card.
|
||||||
const arrowStyle = (enabled: boolean) => ({
|
const arrowStyle = enabled => ({
|
||||||
border: `1px solid ${accent}`,
|
border: `1px solid ${accent}`,
|
||||||
background: "rgba(255,255,255,0.85)",
|
background: "rgba(255,255,255,0.85)",
|
||||||
color: enabled ? accent : "#b8c6c9",
|
color: enabled ? accent : "#b8c6c9",
|
||||||
|
|
@ -581,7 +527,7 @@ export default function EventListCards({
|
||||||
opacity: enabled ? 1 : 0.4,
|
opacity: enabled ? 1 : 0.4,
|
||||||
});
|
});
|
||||||
|
|
||||||
const notice = (text: string) => (
|
const notice = text => (
|
||||||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
page to the nav adds it to this form too.
|
page to the nav adds it to this form too.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { post, ApiError } from "../../lib/api.js";
|
import { post, ApiError } from "../../lib/api.js";
|
||||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||||
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
||||||
|
|
@ -46,7 +46,7 @@ function OptionalTag() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldError({ id, children }: { id: string; children?: ReactNode }) {
|
function FieldError({ id, children }) {
|
||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
return (
|
return (
|
||||||
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
<p id={id} className="mt-2 text-sm text-[#b3261e]">
|
||||||
|
|
@ -56,15 +56,7 @@ function FieldError({ id, children }: { id: string; children?: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native select plus a chevron, since appearance-none strips the default one.
|
// Native select plus a chevron, since appearance-none strips the default one.
|
||||||
type SelectProps = {
|
function Select({ id, label, value, onChange, children }) {
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Select({ id, label, value, onChange, children }: SelectProps) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||||
|
|
@ -101,13 +93,7 @@ function Select({ id, label, value, onChange, children }: SelectProps) {
|
||||||
|
|
||||||
/* ── Type picker ─────────────────────────────────────────────── */
|
/* ── Type picker ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
function TypePicker({
|
function TypePicker({ value, onChange }) {
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string | null;
|
|
||||||
onChange: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="text-base font-semibold text-[#26454c]">
|
<legend className="text-base font-semibold text-[#26454c]">
|
||||||
|
|
@ -165,16 +151,7 @@ function TypePicker({
|
||||||
|
|
||||||
/* ── Where on the site ───────────────────────────────────────── */
|
/* ── Where on the site ───────────────────────────────────────── */
|
||||||
|
|
||||||
type LocationPickerProps = {
|
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
|
||||||
/** A nav path, or SITE_WIDE. */
|
|
||||||
page: string;
|
|
||||||
/** A nav hash, or WHOLE_PAGE. */
|
|
||||||
section: string;
|
|
||||||
onPageChange: (page: string) => void;
|
|
||||||
onSectionChange: (section: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function LocationPicker({ page, section, onPageChange, onSectionChange }: LocationPickerProps) {
|
|
||||||
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -228,7 +205,7 @@ function LocationPicker({ page, section, onPageChange, onSectionChange }: Locati
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human-readable version of the picked location, for the thank-you panel.
|
// Human-readable version of the picked location, for the thank-you panel.
|
||||||
function describeLocation(page: string, section: string) {
|
function describeLocation(page, section) {
|
||||||
if (page === SITE_WIDE) return null;
|
if (page === SITE_WIDE) return null;
|
||||||
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
|
||||||
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
|
||||||
|
|
@ -239,11 +216,8 @@ function describeLocation(page: string, section: string) {
|
||||||
|
|
||||||
/* ── The form ────────────────────────────────────────────────── */
|
/* ── The form ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* The fields server/src/routes/feedback.js can reject by name. */
|
|
||||||
type FeedbackFieldErrors = { message?: string; email?: string };
|
|
||||||
|
|
||||||
export default function FeedbackForm() {
|
export default function FeedbackForm() {
|
||||||
const [type, setType] = useState<string | null>(null);
|
const [type, setType] = useState(null);
|
||||||
const [page, setPage] = useState(SITE_WIDE);
|
const [page, setPage] = useState(SITE_WIDE);
|
||||||
const [section, setSection] = useState(WHOLE_PAGE);
|
const [section, setSection] = useState(WHOLE_PAGE);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
@ -254,14 +228,14 @@ export default function FeedbackForm() {
|
||||||
const [website, setWebsite] = useState("");
|
const [website, setWebsite] = useState("");
|
||||||
|
|
||||||
// idle → sending → sent, or back to idle with an error to show.
|
// idle → sending → sent, or back to idle with an error to show.
|
||||||
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<FeedbackFieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState({});
|
||||||
|
|
||||||
const sending = status === "sending";
|
const sending = status === "sending";
|
||||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!ready || sending) return;
|
if (!ready || sending) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||||
import type { OrgKind } from "../../lib/hrefs.ts";
|
|
||||||
import type { OrganizationListItem } from "../../lib/useContent.ts";
|
|
||||||
import {
|
import {
|
||||||
initialsFor,
|
initialsFor,
|
||||||
orgPath,
|
orgPath,
|
||||||
|
|
@ -35,13 +33,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
||||||
const CARD_MIN = "20rem";
|
const CARD_MIN = "20rem";
|
||||||
const GRID_MAX = "88rem";
|
const GRID_MAX = "88rem";
|
||||||
|
|
||||||
function OrgMark({
|
function OrgMark({ org, color }) {
|
||||||
org,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
org: Pick<OrganizationListItem, "logo" | "name">;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
if (org.logo && !failed) {
|
if (org.logo && !failed) {
|
||||||
|
|
@ -66,14 +58,7 @@ function OrgMark({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardLabels = { accent: string; pageLabel: string; siteLabel: string };
|
function OrgCard({ org, accent, pageLabel, siteLabel }) {
|
||||||
|
|
||||||
function OrgCard({
|
|
||||||
org,
|
|
||||||
accent,
|
|
||||||
pageLabel,
|
|
||||||
siteLabel,
|
|
||||||
}: CardLabels & { org: OrganizationListItem }) {
|
|
||||||
const color = org.color || accent;
|
const color = org.color || accent;
|
||||||
const path = orgPath(org);
|
const path = orgPath(org);
|
||||||
|
|
||||||
|
|
@ -151,13 +136,7 @@ function OrgCard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Block({
|
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
|
||||||
title,
|
|
||||||
orgs,
|
|
||||||
accent,
|
|
||||||
pageLabel,
|
|
||||||
siteLabel,
|
|
||||||
}: CardLabels & { title?: string; orgs: OrganizationListItem[] }) {
|
|
||||||
if (orgs.length === 0) return null;
|
if (orgs.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -188,30 +167,7 @@ function Block({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
a.name.localeCompare(b.name);
|
|
||||||
|
|
||||||
/* One heading's worth of the grid. Labels override the grid's own. */
|
|
||||||
type OrgGroup = {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
pageLabel?: string;
|
|
||||||
siteLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrgListCardsProps = {
|
|
||||||
kind?: OrgKind;
|
|
||||||
title?: string;
|
|
||||||
groups?: OrgGroup[];
|
|
||||||
/** Which group key an organization files under. */
|
|
||||||
groupBy?: (org: OrganizationListItem) => string | null | undefined;
|
|
||||||
accent?: string;
|
|
||||||
pageLabel?: string;
|
|
||||||
siteLabel?: string;
|
|
||||||
/** Null keeps the API's order. */
|
|
||||||
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
|
|
||||||
empty?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrgListCards({
|
export default function OrgListCards({
|
||||||
kind,
|
kind,
|
||||||
|
|
@ -223,10 +179,10 @@ export default function OrgListCards({
|
||||||
siteLabel = "Visit site",
|
siteLabel = "Visit site",
|
||||||
sort = byName,
|
sort = byName,
|
||||||
empty = "· Nothing to show here just yet ·",
|
empty = "· Nothing to show here just yet ·",
|
||||||
}: OrgListCardsProps) {
|
}) {
|
||||||
const { organizations, loading, error } = useOrganizations(kind);
|
const { organizations, loading, error } = useOrganizations(kind);
|
||||||
|
|
||||||
const shell = (children: ReactNode) => (
|
const shell = children => (
|
||||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { useCommunity } from "../../data/chapters.js";
|
||||||
useCommunity,
|
|
||||||
type Chapter,
|
|
||||||
type Community,
|
|
||||||
type Region,
|
|
||||||
} from "../../data/chapters.js";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
import { initialsFor, orgPath } from "../../data/organizations.js";
|
||||||
import { AREAS, AREA_NAMES, type AreaSlice } from "../../data/mapGrid.js";
|
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
|
||||||
import type { ContentBlock, OrganizationListItem } from "../../lib/useContent.ts";
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — MAP
|
ORGANIZATION LIST — MAP
|
||||||
|
|
@ -55,16 +48,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
||||||
|
|
||||||
/* organizations.color is nullable; an SVG fill left undefined paints
|
/* organizations.color is nullable; an SVG fill left undefined paints
|
||||||
black, so an uncoloured region takes the same fallback as a card. */
|
black, so an uncoloured region takes the same fallback as a card. */
|
||||||
const colorOf = (item: { color?: string | null }) => item.color || FALLBACK_COLOR;
|
const colorOf = (item) => item.color || FALLBACK_COLOR;
|
||||||
|
|
||||||
/* Which region is picked and which is under the pointer, shared by
|
|
||||||
the map, the legend and the list. */
|
|
||||||
type Highlight = {
|
|
||||||
selected: string | null;
|
|
||||||
hovered: string | null;
|
|
||||||
setSelected: (id: string | null) => void;
|
|
||||||
setHovered: (id: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const US_TITLE = "US Unity Regions";
|
const US_TITLE = "US Unity Regions";
|
||||||
const INTL_TITLE = "International Unity Regions";
|
const INTL_TITLE = "International Unity Regions";
|
||||||
|
|
@ -75,7 +59,7 @@ const INTL_TITLE = "International Unity Regions";
|
||||||
vanishing. When organization and person pages arrive this should
|
vanishing. When organization and person pages arrive this should
|
||||||
move to a shared component; small enough to live here until then.
|
move to a shared component; small enough to live here until then.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string }) {
|
function Blocks({ blocks = [], color }) {
|
||||||
if (blocks.length === 0) return null;
|
if (blocks.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -108,7 +92,7 @@ function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string
|
||||||
case "links":
|
case "links":
|
||||||
return (
|
return (
|
||||||
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
|
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
|
||||||
{(block.items ?? []).map((item, j) => (
|
{block.items.map((item, j) => (
|
||||||
<li key={j}>
|
<li key={j}>
|
||||||
{item.url ? (
|
{item.url ? (
|
||||||
<a
|
<a
|
||||||
|
|
@ -154,19 +138,6 @@ function Blocks({ blocks = [], color }: { blocks?: ContentBlock[]; color: string
|
||||||
subtracts, and the order the slices arrive in doesn't change
|
subtracts, and the order the slices arrive in doesn't change
|
||||||
what's drawn.
|
what's drawn.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
type TileProps = Highlight & {
|
|
||||||
code: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
size: number;
|
|
||||||
width?: number;
|
|
||||||
label?: string;
|
|
||||||
slices?: AreaSlice[];
|
|
||||||
count?: number;
|
|
||||||
onPick?: (code: string) => void;
|
|
||||||
fontSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Tile({
|
function Tile({
|
||||||
code,
|
code,
|
||||||
x,
|
x,
|
||||||
|
|
@ -182,12 +153,11 @@ function Tile({
|
||||||
setHovered,
|
setHovered,
|
||||||
onPick,
|
onPick,
|
||||||
fontSize = 34,
|
fontSize = 34,
|
||||||
}: TileProps) {
|
}) {
|
||||||
if (slices.length === 0) return null;
|
if (slices.length === 0) return null;
|
||||||
|
|
||||||
const primary = slices[0];
|
const primary = slices[0];
|
||||||
// Nullable so indexOf and includes take `selected` as it is.
|
const ids = slices.map(s => s.regionId);
|
||||||
const ids: Array<string | null> = slices.map(s => s.regionId);
|
|
||||||
const active = ids.includes(selected) || ids.includes(hovered);
|
const active = ids.includes(selected) || ids.includes(hovered);
|
||||||
const dimmed = selected && !ids.includes(selected);
|
const dimmed = selected && !ids.includes(selected);
|
||||||
const clipId = `clip-${code}`;
|
const clipId = `clip-${code}`;
|
||||||
|
|
@ -278,13 +248,7 @@ function Tile({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegionMapProps = Highlight & {
|
function RegionMap({ slices, chapterCounts, ...props }) {
|
||||||
slices: Record<string, AreaSlice[]>;
|
|
||||||
chapterCounts: Record<string, number>;
|
|
||||||
onPick?: (code: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
|
|
||||||
const size = TILE - PAD * 2;
|
const size = TILE - PAD * 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -315,15 +279,8 @@ function RegionMap({ slices, chapterCounts, ...props }: RegionMapProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegendButton({
|
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||||
region,
|
|
||||||
selected,
|
|
||||||
setSelected,
|
|
||||||
hovered,
|
|
||||||
setHovered,
|
|
||||||
}: Highlight & { region: Region }) {
|
|
||||||
const on = selected === region.id || hovered === region.id;
|
const on = selected === region.id || hovered === region.id;
|
||||||
const color = colorOf(region);
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
onClick={() => setSelected(selected === region.id ? null : region.id)}
|
||||||
|
|
@ -334,36 +291,29 @@ function LegendButton({
|
||||||
aria-pressed={selected === region.id}
|
aria-pressed={selected === region.id}
|
||||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${color}`,
|
border: `1px solid ${colorOf(region)}`,
|
||||||
background: on ? color : "transparent",
|
background: on ? colorOf(region) : "transparent",
|
||||||
color: on ? "#ffffff" : color,
|
color: on ? "#ffffff" : colorOf(region),
|
||||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
style={{ background: on ? "#ffffff" : color }}
|
style={{ background: on ? "#ffffff" : colorOf(region) }}
|
||||||
/>
|
/>
|
||||||
{region.name}
|
{region.name}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegendProps = Highlight & {
|
function Legend({ domestic, international, onMapIds, ...props }) {
|
||||||
domestic: Region[];
|
|
||||||
international: Region[];
|
|
||||||
/** Regions that paint at least one tile. */
|
|
||||||
onMapIds: Set<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
|
|
||||||
const { selected, setSelected } = props;
|
const { selected, setSelected } = props;
|
||||||
|
|
||||||
// A region is on the map if it paints a tile. West Central used
|
// A region is on the map if it paints a tile. West Central used
|
||||||
// to need a hardcoded exception here because its states arrived
|
// to need a hardcoded exception here because its states arrived
|
||||||
// only through SPLITS; it has ordinary rows now, so the exception
|
// only through SPLITS; it has ordinary rows now, so the exception
|
||||||
// is gone.
|
// is gone.
|
||||||
const onMap = (region: Region) => onMapIds.has(region.id);
|
const onMap = region => onMapIds.has(region.id);
|
||||||
const us = domestic.filter(onMap);
|
const us = domestic.filter(onMap);
|
||||||
const intl = international.filter(onMap);
|
const intl = international.filter(onMap);
|
||||||
|
|
||||||
|
|
@ -401,15 +351,6 @@ function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegionBlockProps = Highlight & {
|
|
||||||
region: Region;
|
|
||||||
chapters: Chapter[];
|
|
||||||
subtext: string;
|
|
||||||
indent: boolean;
|
|
||||||
regionRefs?: RefObject<Record<string, HTMLDivElement | null>>;
|
|
||||||
chapterRefs?: RefObject<Record<string, HTMLLIElement | null>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function RegionBlock({
|
function RegionBlock({
|
||||||
region,
|
region,
|
||||||
chapters,
|
chapters,
|
||||||
|
|
@ -420,15 +361,12 @@ function RegionBlock({
|
||||||
indent,
|
indent,
|
||||||
regionRefs,
|
regionRefs,
|
||||||
chapterRefs,
|
chapterRefs,
|
||||||
}: RegionBlockProps) {
|
}) {
|
||||||
const on = selected === region.id;
|
const on = selected === region.id;
|
||||||
const color = colorOf(region);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={el => {
|
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
|
||||||
if (regionRefs) regionRefs.current[region.id] = el;
|
|
||||||
}}
|
|
||||||
className="transition-opacity duration-200"
|
className="transition-opacity duration-200"
|
||||||
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
|
||||||
>
|
>
|
||||||
|
|
@ -440,11 +378,11 @@ function RegionBlock({
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-3 w-3 rounded-full shrink-0"
|
className="h-3 w-3 rounded-full shrink-0"
|
||||||
style={{ background: color }}
|
style={{ background: colorOf(region) }}
|
||||||
/>
|
/>
|
||||||
<h4
|
<h4
|
||||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||||
style={{ color: color }}
|
style={{ color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
{region.name}
|
{region.name}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
@ -465,60 +403,55 @@ function RegionBlock({
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
<ul className="ml-5 mb-4 flex flex-col gap-3">
|
||||||
{chapters.map(c => {
|
{chapters.map(c => (
|
||||||
const path = orgPath(c);
|
<li
|
||||||
return (
|
key={c.id}
|
||||||
<li
|
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||||
key={c.id}
|
className="pl-3 flex items-start gap-3"
|
||||||
ref={el => {
|
style={{ borderLeft: `2px solid ${colorOf(region)}` }}
|
||||||
if (chapterRefs) chapterRefs.current[c.id] = el;
|
>
|
||||||
}}
|
<div className="min-w-0 flex-1">
|
||||||
className="pl-3 flex items-start gap-3"
|
<p className="font-700">{c.name}</p>
|
||||||
style={{ borderLeft: `2px solid ${color}` }}
|
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
||||||
>
|
{c.location_label}
|
||||||
<div className="min-w-0 flex-1">
|
{c.meets ? ` · ${c.meets}` : ""}
|
||||||
<p className="font-700">{c.name}</p>
|
</p>
|
||||||
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
|
{(c.website || c.email) && (
|
||||||
{c.location_label}
|
<p className="text-sm mt-1 flex gap-4">
|
||||||
{c.meets ? ` · ${c.meets}` : ""}
|
{c.website && (
|
||||||
|
<a
|
||||||
|
href={c.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-700 underline"
|
||||||
|
style={{ color: colorOf(region) }}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{c.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${c.email}`}
|
||||||
|
className="font-700 underline"
|
||||||
|
style={{ color: colorOf(region) }}
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{(c.website || c.email) && (
|
|
||||||
<p className="text-sm mt-1 flex gap-4">
|
|
||||||
{c.website && (
|
|
||||||
<a
|
|
||||||
href={c.website}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-700 underline"
|
|
||||||
style={{ color: color }}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{c.email && (
|
|
||||||
<a
|
|
||||||
href={`mailto:${c.email}`}
|
|
||||||
className="font-700 underline"
|
|
||||||
style={{ color: color }}
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{path && (
|
|
||||||
<ArrowLink
|
|
||||||
to={path}
|
|
||||||
label={`${c.name} — chapter page`}
|
|
||||||
color={color}
|
|
||||||
size="h-8 w-8"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{orgPath(c) && (
|
||||||
|
<ArrowLink
|
||||||
|
to={orgPath(c)}
|
||||||
|
label={`${c.name} — chapter page`}
|
||||||
|
color={colorOf(region)}
|
||||||
|
size="h-8 w-8"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -533,15 +466,7 @@ function RegionBlock({
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* Logo, or the organization's initials when there's no file. */
|
/* Logo, or the organization's initials when there's no file. */
|
||||||
function OrgLogo({
|
function OrgLogo({ org, color, size = "h-14 w-14" }) {
|
||||||
org,
|
|
||||||
color,
|
|
||||||
size = "h-14 w-14",
|
|
||||||
}: {
|
|
||||||
org: Pick<OrganizationListItem, "logo" | "name">;
|
|
||||||
color: string;
|
|
||||||
size?: string;
|
|
||||||
}) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
if (org.logo && !failed) {
|
if (org.logo && !failed) {
|
||||||
|
|
@ -566,16 +491,7 @@ function OrgLogo({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChapterCardProps = {
|
function ChapterCard({ chapter, color, open, onOpen }) {
|
||||||
chapter: Chapter;
|
|
||||||
color: string;
|
|
||||||
open: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
|
||||||
const path = orgPath(chapter);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
|
||||||
|
|
@ -627,9 +543,9 @@ function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{path && (
|
{orgPath(chapter) && (
|
||||||
<ArrowLink
|
<ArrowLink
|
||||||
to={path}
|
to={orgPath(chapter)}
|
||||||
label={`${chapter.name} — chapter page`}
|
label={`${chapter.name} — chapter page`}
|
||||||
color={color}
|
color={color}
|
||||||
/>
|
/>
|
||||||
|
|
@ -639,18 +555,7 @@ function ChapterCard({ chapter, color, open, onOpen }: ChapterCardProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChapterDetail({
|
function ChapterDetail({ chapter, region, onClose }) {
|
||||||
chapter,
|
|
||||||
region,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
chapter: Chapter;
|
|
||||||
region: Region;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const path = orgPath(chapter);
|
|
||||||
const color = colorOf(region);
|
|
||||||
|
|
||||||
/* "Led by" comes from affiliations rather than a text field, so
|
/* "Led by" comes from affiliations rather than a text field, so
|
||||||
it lists real people and stays empty until they exist. */
|
it lists real people and stays empty until they exist. */
|
||||||
const leads = (chapter.leadership ?? [])
|
const leads = (chapter.leadership ?? [])
|
||||||
|
|
@ -659,23 +564,21 @@ function ChapterDetail({
|
||||||
)
|
)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const rows = (
|
const rows = [
|
||||||
[
|
["Region", region.name],
|
||||||
["Region", region.name],
|
["Where", chapter.venue],
|
||||||
["Where", chapter.venue],
|
["Meets", chapter.meets],
|
||||||
["Meets", chapter.meets],
|
["Led by", leads],
|
||||||
["Led by", leads],
|
["Since", chapter.started],
|
||||||
["Since", chapter.started],
|
].filter(([, v]) => v);
|
||||||
] satisfies Array<[label: string, value: string | null | undefined]>
|
|
||||||
).filter(([, v]) => v);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl p-6 mb-6"
|
className="rounded-2xl p-6 mb-6"
|
||||||
style={{ border: `2px solid ${color}`, background: `${color}0f` }}
|
style={{ border: `2px solid ${colorOf(region)}`, background: `${colorOf(region)}0f` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<OrgLogo org={chapter} color={color} size="h-20 w-20" />
|
<OrgLogo org={chapter} color={colorOf(region)} size="h-20 w-20" />
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||||
|
|
@ -686,13 +589,13 @@ function ChapterDetail({
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close details"
|
aria-label="Close details"
|
||||||
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
|
||||||
style={{ border: `1px solid ${color}`, color: color }}
|
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Blocks blocks={chapter.blocks} color={color} />
|
<Blocks blocks={chapter.blocks} color={colorOf(region)} />
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||||
|
|
@ -710,11 +613,11 @@ function ChapterDetail({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
{path && (
|
{orgPath(chapter) && (
|
||||||
<Link
|
<Link
|
||||||
to={path}
|
to={orgPath(chapter)}
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ background: color, color: "#ffffff" }}
|
style={{ background: colorOf(region), color: "#ffffff" }}
|
||||||
>
|
>
|
||||||
Chapter page
|
Chapter page
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -725,7 +628,7 @@ function ChapterDetail({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ border: `1px solid ${color}`, color: color }}
|
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
Visit site
|
Visit site
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -735,7 +638,7 @@ function ChapterDetail({
|
||||||
<a
|
<a
|
||||||
href={`mailto:${chapter.email}`}
|
href={`mailto:${chapter.email}`}
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ border: `1px solid ${color}`, color: color }}
|
style={{ border: `1px solid ${colorOf(region)}`, color: colorOf(region) }}
|
||||||
>
|
>
|
||||||
Get in touch
|
Get in touch
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -745,19 +648,7 @@ function ChapterDetail({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChapterGridProps = Pick<Community, "regions" | "chapters" | "chaptersIn" | "subtextFor"> & {
|
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
|
||||||
openId: string | null;
|
|
||||||
setOpenId: (id: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ChapterGrid({
|
|
||||||
regions,
|
|
||||||
chapters,
|
|
||||||
chaptersIn,
|
|
||||||
subtextFor,
|
|
||||||
openId,
|
|
||||||
setOpenId,
|
|
||||||
}: ChapterGridProps) {
|
|
||||||
// Only regions that actually have chapters get a grid.
|
// Only regions that actually have chapters get a grid.
|
||||||
const populated = regions
|
const populated = regions
|
||||||
.map(region => ({ region, list: chaptersIn(region.id) }))
|
.map(region => ({ region, list: chaptersIn(region.id) }))
|
||||||
|
|
@ -769,15 +660,14 @@ function ChapterGrid({
|
||||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||||
{populated.map(({ region, list }) => {
|
{populated.map(({ region, list }) => {
|
||||||
const subtext = subtextFor(region);
|
const subtext = subtextFor(region);
|
||||||
const color = colorOf(region);
|
|
||||||
return (
|
return (
|
||||||
<section key={region.id} className="mb-12">
|
<section key={region.id} className="mb-12">
|
||||||
<div className="flex items-baseline gap-3 mb-1">
|
<div className="flex items-baseline gap-3 mb-1">
|
||||||
<span
|
<span
|
||||||
className="h-3 w-3 rounded-full shrink-0"
|
className="h-3 w-3 rounded-full shrink-0"
|
||||||
style={{ background: color }}
|
style={{ background: colorOf(region) }}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-2xl font-800" style={{ color: color }}>
|
<h3 className="text-2xl font-800" style={{ color: colorOf(region) }}>
|
||||||
{region.name}
|
{region.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-sm" style={{ color: MUTED }}>
|
<span className="text-sm" style={{ color: MUTED }}>
|
||||||
|
|
@ -810,7 +700,7 @@ function ChapterGrid({
|
||||||
<ChapterCard
|
<ChapterCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
chapter={c}
|
chapter={c}
|
||||||
color={color}
|
color={colorOf(region)}
|
||||||
open={openId === c.id}
|
open={openId === c.id}
|
||||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -831,12 +721,8 @@ function ChapterGrid({
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The control for the section heading's action bar. */
|
/* The control for the section heading's action bar. */
|
||||||
export function OrgMapToggle({
|
export function OrgMapToggle({ view, setView, accent }) {
|
||||||
view,
|
const btn = active => ({
|
||||||
setView,
|
|
||||||
accent,
|
|
||||||
}: Pick<SectionToggleProps, "view" | "setView" | "accent">) {
|
|
||||||
const btn = (active: boolean) => ({
|
|
||||||
background: active ? accent : "transparent",
|
background: active ? accent : "transparent",
|
||||||
color: active ? "#ffffff" : accent,
|
color: active ? "#ffffff" : accent,
|
||||||
});
|
});
|
||||||
|
|
@ -866,14 +752,7 @@ export function OrgMapToggle({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrgListMap({
|
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||||
view = "map",
|
|
||||||
accent = FALLBACK_COLOR,
|
|
||||||
}: {
|
|
||||||
/** "map" or "grid". */
|
|
||||||
view?: string;
|
|
||||||
accent?: string;
|
|
||||||
}) {
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|
@ -890,16 +769,16 @@ export default function OrgListMap({
|
||||||
subtextFor,
|
subtextFor,
|
||||||
} = useCommunity();
|
} = useCommunity();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const [hovered, setHovered] = useState(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null); // no card open on arrival
|
const [openId, setOpenId] = useState(null); // no card open on arrival
|
||||||
|
|
||||||
// The list scrolls itself to whatever the map or legend points at.
|
// The list scrolls itself to whatever the map or legend points at.
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef(null);
|
||||||
const regionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const regionRefs = useRef({});
|
||||||
const chapterRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
const chapterRefs = useRef({});
|
||||||
|
|
||||||
const scrollListTo = (el: HTMLElement | null | undefined) => {
|
const scrollListTo = el => {
|
||||||
const box = listRef.current;
|
const box = listRef.current;
|
||||||
if (!box || !el) return;
|
if (!box || !el) return;
|
||||||
// Only when the list is its own scroll area (lg and up). Below
|
// Only when the list is its own scroll area (lg and up). Below
|
||||||
|
|
@ -917,7 +796,7 @@ export default function OrgListMap({
|
||||||
|
|
||||||
// Clicking a tile jumps to its first chapter when it has one,
|
// Clicking a tile jumps to its first chapter when it has one,
|
||||||
// otherwise to the region it belongs to.
|
// otherwise to the region it belongs to.
|
||||||
const pickArea = (code: string) => {
|
const pickArea = code => {
|
||||||
const chapter = chapters.find(c => c.area_code === code);
|
const chapter = chapters.find(c => c.area_code === code);
|
||||||
if (chapter && chapterRefs.current[chapter.id]) {
|
if (chapter && chapterRefs.current[chapter.id]) {
|
||||||
return scrollListTo(chapterRefs.current[chapter.id]);
|
return scrollListTo(chapterRefs.current[chapter.id]);
|
||||||
|
|
@ -926,7 +805,7 @@ export default function OrgListMap({
|
||||||
if (region) scrollListTo(regionRefs.current[region.id]);
|
if (region) scrollListTo(regionRefs.current[region.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shell = (children: ReactNode) => (
|
const shell = children => (
|
||||||
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -962,7 +841,7 @@ export default function OrgListMap({
|
||||||
const shared = { selected, setSelected, hovered, setHovered };
|
const shared = { selected, setSelected, hovered, setHovered };
|
||||||
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
const onMapIds = new Set(regionAreas.map(a => a.region_id));
|
||||||
|
|
||||||
const block = (region: Region, indent: boolean) => (
|
const block = (region, indent) => (
|
||||||
<RegionBlock
|
<RegionBlock
|
||||||
key={region.id}
|
key={region.id}
|
||||||
region={region}
|
region={region}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import {
|
import { useId, useState } from "react";
|
||||||
useId,
|
|
||||||
useState,
|
|
||||||
type AnchorHTMLAttributes,
|
|
||||||
type ButtonHTMLAttributes,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||||
import type { OrgKind } from "../../lib/hrefs.ts";
|
|
||||||
import type { OrganizationListItem } from "../../lib/useContent.ts";
|
|
||||||
import {
|
import {
|
||||||
areasSentence,
|
areasSentence,
|
||||||
initialsFor,
|
initialsFor,
|
||||||
|
|
@ -55,14 +47,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
||||||
it — a link nested in a button is invalid, and a screen reader
|
it — a link nested in a button is invalid, and a screen reader
|
||||||
announces the whole row as one confused control.
|
announces the whole row as one confused control.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
type RowButtonProps = {
|
function RowButton({ as: As = "button", color, children, ...rest }) {
|
||||||
as?: "a" | "button";
|
|
||||||
color: string;
|
|
||||||
children: ReactNode;
|
|
||||||
} & AnchorHTMLAttributes<HTMLAnchorElement> &
|
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
|
|
||||||
return (
|
return (
|
||||||
<As
|
<As
|
||||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
|
|
@ -74,13 +59,7 @@ function RowButton({ as: As = "button", color, children, ...rest }: RowButtonPro
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrgMark({
|
function OrgMark({ org, color }) {
|
||||||
org,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
if (org.logo && !failed) {
|
if (org.logo && !failed) {
|
||||||
|
|
@ -115,9 +94,7 @@ function OrgMark({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RowLabels = { pageLabel: string; siteLabel: string };
|
function OrgRow({ org, pageLabel, siteLabel }) {
|
||||||
|
|
||||||
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const panelId = useId();
|
const panelId = useId();
|
||||||
|
|
||||||
|
|
@ -268,12 +245,7 @@ function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationLi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Block({
|
function Block({ title, orgs, pageLabel, siteLabel }) {
|
||||||
title,
|
|
||||||
orgs,
|
|
||||||
pageLabel,
|
|
||||||
siteLabel,
|
|
||||||
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
|
|
||||||
if (orgs.length === 0) return null;
|
if (orgs.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -290,30 +262,7 @@ function Block({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const byName = (a: OrganizationListItem, b: OrganizationListItem) =>
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
a.name.localeCompare(b.name);
|
|
||||||
|
|
||||||
/* One heading's worth of the list. Labels override the list's own. */
|
|
||||||
type OrgGroup = {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
pageLabel?: string;
|
|
||||||
siteLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrgListVerticalProps = {
|
|
||||||
kind?: OrgKind;
|
|
||||||
title?: string;
|
|
||||||
groups?: OrgGroup[];
|
|
||||||
/** Which group key an organization files under. */
|
|
||||||
groupBy?: (org: OrganizationListItem) => string | null | undefined;
|
|
||||||
accent?: string;
|
|
||||||
pageLabel?: string;
|
|
||||||
siteLabel?: string;
|
|
||||||
/** Null keeps the API's order. */
|
|
||||||
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
|
|
||||||
empty?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrgListVertical({
|
export default function OrgListVertical({
|
||||||
kind,
|
kind,
|
||||||
|
|
@ -325,10 +274,10 @@ export default function OrgListVertical({
|
||||||
siteLabel = "Visit site",
|
siteLabel = "Visit site",
|
||||||
sort = byName,
|
sort = byName,
|
||||||
empty = "· Nothing to show here just yet ·",
|
empty = "· Nothing to show here just yet ·",
|
||||||
}: OrgListVerticalProps) {
|
}) {
|
||||||
const { organizations, loading, error } = useOrganizations(kind);
|
const { organizations, loading, error } = useOrganizations(kind);
|
||||||
|
|
||||||
const shell = (children: ReactNode) => (
|
const shell = children => (
|
||||||
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue