Compare commits
No commits in common. "9b91a9aa781853096b06f9c74acd95ec10b52a88" and "5286cae9ca8410b0b1b75702bbc46e6e503df2ce" have entirely different histories.
9b91a9aa78
...
5286cae9ca
39 changed files with 343 additions and 1318 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 */}
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,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,
|
||||||
})),
|
})),
|
||||||
|
|
@ -198,8 +198,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;
|
||||||
|
|
@ -304,10 +304,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"
|
||||||
|
|
@ -251,36 +222,26 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldGrid({ children }: { children: ReactNode }) {
|
export function FieldGrid({ children }) {
|
||||||
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
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];
|
||||||
|
|
@ -288,7 +249,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);
|
||||||
|
|
@ -421,7 +382,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }:
|
||||||
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
<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}.`}
|
||||||
|
|
@ -437,15 +398,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;
|
|
||||||
27
src/data/eventData.d.ts
vendored
27
src/data/eventData.d.ts
vendored
|
|
@ -1,27 +0,0 @@
|
||||||
/* Types for eventData.js. Rows are GET /events as shapeEvent in
|
|
||||||
server/src/routes/content.js sends them. */
|
|
||||||
|
|
||||||
import type { EventType } from "../lib/eventTypes.ts";
|
|
||||||
import type { EventListItem } from "../lib/useContent.ts";
|
|
||||||
|
|
||||||
export type EventFilter = {
|
|
||||||
section?: string;
|
|
||||||
host?: string;
|
|
||||||
status?: EventListItem["status"];
|
|
||||||
type?: EventType | EventType[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function useEvents(filter?: EventFilter): {
|
|
||||||
events: EventListItem[];
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function splitByStatus<T extends { status: string }>(
|
|
||||||
events?: T[],
|
|
||||||
): { upcoming: T[]; past: T[] };
|
|
||||||
|
|
||||||
export declare function typesPresent<D extends { id: string }>(
|
|
||||||
events?: Array<{ event_type: string }>,
|
|
||||||
declared?: readonly D[],
|
|
||||||
): D[];
|
|
||||||
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;
|
|
||||||
118
src/lib/adminSchema.d.ts
vendored
118
src/lib/adminSchema.d.ts
vendored
|
|
@ -1,118 +0,0 @@
|
||||||
/* Types for adminSchema.js: the client half of the descriptor-driven
|
|
||||||
admin CRUD engine. Rows are whatever columns the server-side
|
|
||||||
descriptor in server/src/admin-schema.js declares, so they stay a
|
|
||||||
string-keyed record; the descriptors are what's fixed. */
|
|
||||||
|
|
||||||
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
|
|
||||||
* ('region.scope') are side-table objects under their key, and
|
|
||||||
* child collections are arrays of rows under theirs. */
|
|
||||||
export type AdminRow = Record<string, unknown>;
|
|
||||||
|
|
||||||
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
|
|
||||||
* along on the lists that filterBy narrows. */
|
|
||||||
export type AdminOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
kind?: string;
|
|
||||||
org_id?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
|
|
||||||
export type AdminOptions = Record<string, AdminOption[]>;
|
|
||||||
|
|
||||||
export type FieldWidget =
|
|
||||||
| "text"
|
|
||||||
| "textarea"
|
|
||||||
| "select"
|
|
||||||
| "checkbox"
|
|
||||||
| "color"
|
|
||||||
| "number"
|
|
||||||
| "date";
|
|
||||||
|
|
||||||
/** A bare value, or [value, label]. */
|
|
||||||
export type SelectOption = string | readonly [string, string];
|
|
||||||
|
|
||||||
/** Show a group or collection only when another field has this value. */
|
|
||||||
export type FieldCondition = { path: string; value: unknown };
|
|
||||||
|
|
||||||
export type AdminFieldSpec = {
|
|
||||||
path: string;
|
|
||||||
label: string;
|
|
||||||
widget?: FieldWidget;
|
|
||||||
required?: boolean;
|
|
||||||
full?: boolean;
|
|
||||||
help?: string;
|
|
||||||
options?: readonly SelectOption[];
|
|
||||||
optionsFrom?: string;
|
|
||||||
blankLabel?: string;
|
|
||||||
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
|
|
||||||
/* Set by EntityEdit on the id field rather than in a manifest. */
|
|
||||||
readOnly?: boolean;
|
|
||||||
prefix?: string;
|
|
||||||
prefixPending?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FieldGroupSpec = {
|
|
||||||
legend: string;
|
|
||||||
note?: string;
|
|
||||||
when?: FieldCondition;
|
|
||||||
fields: AdminFieldSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CollectionSpec = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
addLabel?: string;
|
|
||||||
note?: string;
|
|
||||||
when?: FieldCondition;
|
|
||||||
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
|
|
||||||
blank: AdminRow;
|
|
||||||
fields: AdminFieldSpec[];
|
|
||||||
children?: CollectionSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListColumn = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
primary?: boolean;
|
|
||||||
widget?: "bool";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListFilter = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
options?: readonly SelectOption[];
|
|
||||||
optionsFrom?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EntitySpec = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
singular: string;
|
|
||||||
idLabel: string;
|
|
||||||
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
|
||||||
idKind?: "auto";
|
|
||||||
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
|
||||||
slugFrom?: string | string[];
|
|
||||||
titleFrom?: string;
|
|
||||||
list: { columns: ListColumn[]; filters: ListFilter[] };
|
|
||||||
groups: FieldGroupSpec[];
|
|
||||||
children?: CollectionSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminEntityKey =
|
|
||||||
| "organizations"
|
|
||||||
| "events"
|
|
||||||
| "people"
|
|
||||||
| "teams"
|
|
||||||
| "awards"
|
|
||||||
| "timeline";
|
|
||||||
|
|
||||||
/* Indexed by route param as often as by name, so any other string
|
|
||||||
reads as possibly missing. */
|
|
||||||
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
|
|
||||||
readonly [key: string]: EntitySpec | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function slugify(value: unknown): string;
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -103,22 +103,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')
|
||||||
|
|
||||||
|
|
@ -164,17 +154,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'
|
||||||
|
|
@ -194,16 +173,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
|
||||||
|
|
@ -217,14 +194,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')
|
||||||
|
|
||||||
|
|
@ -242,8 +211,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'
|
||||||
|
|
@ -85,7 +85,7 @@ export default function EventDetail() {
|
||||||
const accent = event.color || TEAL
|
const accent = event.color || TEAL
|
||||||
const groups = peopleGroups(event.people, accent)
|
const groups = peopleGroups(event.people, accent)
|
||||||
|
|
||||||
const sections: ShellSection[] = [
|
const sections = [
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
title: event.theme || 'About',
|
title: event.theme || 'About',
|
||||||
|
|
|
||||||
|
|
@ -149,15 +149,7 @@ const EDGE_FADE = `linear-gradient(to right,
|
||||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||||||
|
|
||||||
{/* Functions */}
|
{/* Functions */}
|
||||||
function WaveText({
|
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
|
||||||
text,
|
|
||||||
baseDelay = 0,
|
|
||||||
step = 0.1,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
baseDelay?: number;
|
|
||||||
step?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{text.split("").map((char, i) => (
|
{text.split("").map((char, i) => (
|
||||||
|
|
@ -187,7 +179,7 @@ export default function Home() {
|
||||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
const prev = () => setIndex(i => Math.max(0, i - 1));
|
||||||
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
|
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
|
||||||
|
|
||||||
const arrowStyle = (enabled: boolean) => ({
|
const arrowStyle = enabled => ({
|
||||||
border: `1px solid ${TEAL}`,
|
border: `1px solid ${TEAL}`,
|
||||||
background: "rgba(255,255,255,0.6)",
|
background: "rgba(255,255,255,0.6)",
|
||||||
color: enabled ? TEAL : "#b8c6c9",
|
color: enabled ? TEAL : "#b8c6c9",
|
||||||
|
|
|
||||||
|
|
@ -331,13 +331,7 @@ function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* website and email arrive as bare strings (splitLinks in
|
{[org.website, org.email, ...org.socials]
|
||||||
shape.js); socials are whole link rows. */}
|
|
||||||
{[
|
|
||||||
org.website ? { url: org.website, label: 'Website' } : null,
|
|
||||||
org.email ? { url: `mailto:${org.email}`, label: org.email } : null,
|
|
||||||
...org.socials,
|
|
||||||
]
|
|
||||||
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||||
.map((link) => (
|
.map((link) => (
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
@ -89,9 +79,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
|
||||||
|
|
@ -101,7 +91,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 "";
|
||||||
|
|
@ -113,9 +103,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -124,27 +114,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) {
|
||||||
|
|
@ -154,12 +141,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);
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +181,7 @@ export default function EntityEdit() {
|
||||||
: isNew
|
: isNew
|
||||||
? `New ${manifest.singular}`
|
? `New ${manifest.singular}`
|
||||||
: form
|
: form
|
||||||
? headingOf(form)
|
? getPath(form, headingPath) || form.id
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -202,7 +189,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 = "";
|
||||||
};
|
};
|
||||||
|
|
@ -215,19 +202,18 @@ export default function EntityEdit() {
|
||||||
|
|
||||||
/* ── Heading ───────────────────────────────────────────────── */
|
/* ── Heading ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
const heading = headingOf(form);
|
const heading = getPath(form, headingPath) || form.id;
|
||||||
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -249,20 +235,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 });
|
||||||
|
|
||||||
|
|
@ -287,9 +273,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 {
|
||||||
|
|
@ -305,9 +291,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">
|
||||||
|
|
@ -330,8 +316,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>
|
||||||
)
|
)
|
||||||
|
|
@ -358,9 +344,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>
|
||||||
|
|
@ -402,7 +388,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, useNavigate, useParams, useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
import { get, ApiError } from "../../lib/api.js";
|
import { get, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import {
|
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);
|
||||||
|
|
@ -72,18 +67,18 @@ export default function EntityList() {
|
||||||
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setParam(key: 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 (
|
||||||
|
|
@ -164,7 +159,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}`)}
|
||||||
>
|
>
|
||||||
|
|
@ -179,12 +174,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,15 +1,8 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { 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 type { EventListItem } from "../../lib/useContent.ts";
|
|
||||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
EVENT LIST — CARDS
|
EVENT LIST — CARDS
|
||||||
|
|
@ -31,7 +24,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",
|
||||||
});
|
});
|
||||||
|
|
@ -40,7 +33,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
|
||||||
|
|
@ -48,15 +41,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;
|
||||||
|
|
@ -137,15 +122,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">
|
||||||
|
|
@ -172,16 +149,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,
|
||||||
|
|
@ -190,7 +157,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;
|
||||||
|
|
@ -386,23 +353,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)}
|
||||||
|
|
@ -430,12 +388,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,
|
||||||
});
|
});
|
||||||
|
|
@ -487,14 +441,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,
|
||||||
|
|
@ -504,7 +450,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,
|
||||||
|
|
@ -515,7 +461,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
|
||||||
|
|
@ -569,7 +515,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",
|
||||||
|
|
@ -577,7 +523,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
|
||||||
|
|
@ -53,19 +46,6 @@ const RULE = "#cfe3e7";
|
||||||
const INK = "#2c4a50";
|
const INK = "#2c4a50";
|
||||||
const FALLBACK_COLOR = "#4a6b72";
|
const FALLBACK_COLOR = "#4a6b72";
|
||||||
|
|
||||||
/* organizations.color is nullable; an SVG fill left undefined paints
|
|
||||||
black, so an uncoloured region takes the same fallback as a card. */
|
|
||||||
const colorOf = (item: { color?: string | null }) => item.color || FALLBACK_COLOR;
|
|
||||||
|
|
||||||
/* Which region is picked and which is under the pointer, shared by
|
|
||||||
the map, the legend and the list. */
|
|
||||||
type Highlight = {
|
|
||||||
selected: string | null;
|
|
||||||
hovered: string | null;
|
|
||||||
setSelected: (id: string | null) => void;
|
|
||||||
setHovered: (id: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const US_TITLE = "US Unity Regions";
|
const US_TITLE = "US Unity Regions";
|
||||||
const INTL_TITLE = "International Unity Regions";
|
const INTL_TITLE = "International Unity Regions";
|
||||||
|
|
||||||
|
|
@ -75,7 +55,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 +88,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 +134,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 +149,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}`;
|
||||||
|
|
@ -230,7 +196,7 @@ function Tile({
|
||||||
y={top}
|
y={top}
|
||||||
width={width}
|
width={width}
|
||||||
height={h}
|
height={h}
|
||||||
fill={colorOf(slice)}
|
fill={slice.color}
|
||||||
fillOpacity={opacity}
|
fillOpacity={opacity}
|
||||||
className="transition-all duration-200"
|
className="transition-all duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
@ -245,7 +211,7 @@ function Tile({
|
||||||
height={size}
|
height={size}
|
||||||
rx={14}
|
rx={14}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={active ? colorOf(primary) : "#ffffff"}
|
stroke={active ? primary.color : "#ffffff"}
|
||||||
strokeOpacity={active ? 1 : 0.55}
|
strokeOpacity={active ? 1 : 0.55}
|
||||||
strokeWidth={active ? 4 : 2}
|
strokeWidth={active ? 4 : 2}
|
||||||
className="transition-all duration-200"
|
className="transition-all duration-200"
|
||||||
|
|
@ -258,7 +224,7 @@ function Tile({
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
fontWeight="800"
|
fontWeight="800"
|
||||||
fill={count ? "#ffffff" : colorOf(primary)}
|
fill={count ? "#ffffff" : primary.color}
|
||||||
style={{ pointerEvents: "none" }}
|
style={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
{label || code}
|
{label || code}
|
||||||
|
|
@ -278,13 +244,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 +275,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 +287,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 ${region.color}`,
|
||||||
background: on ? color : "transparent",
|
background: on ? region.color : "transparent",
|
||||||
color: on ? "#ffffff" : color,
|
color: on ? "#ffffff" : region.color,
|
||||||
opacity: selected && selected !== region.id ? 0.45 : 1,
|
opacity: selected && selected !== region.id ? 0.45 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
style={{ background: on ? "#ffffff" : color }}
|
style={{ background: on ? "#ffffff" : region.color }}
|
||||||
/>
|
/>
|
||||||
{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 +347,6 @@ function Legend({ domestic, international, onMapIds, ...props }: LegendProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegionBlockProps = Highlight & {
|
|
||||||
region: Region;
|
|
||||||
chapters: Chapter[];
|
|
||||||
subtext: string;
|
|
||||||
indent: boolean;
|
|
||||||
regionRefs?: RefObject<Record<string, HTMLDivElement | null>>;
|
|
||||||
chapterRefs?: RefObject<Record<string, HTMLLIElement | null>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function RegionBlock({
|
function RegionBlock({
|
||||||
region,
|
region,
|
||||||
chapters,
|
chapters,
|
||||||
|
|
@ -420,15 +357,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 +374,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: region.color }}
|
||||||
/>
|
/>
|
||||||
<h4
|
<h4
|
||||||
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
|
||||||
style={{ color: color }}
|
style={{ color: region.color }}
|
||||||
>
|
>
|
||||||
{region.name}
|
{region.name}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
@ -465,16 +399,12 @@ 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);
|
|
||||||
return (
|
|
||||||
<li
|
<li
|
||||||
key={c.id}
|
key={c.id}
|
||||||
ref={el => {
|
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
|
||||||
if (chapterRefs) chapterRefs.current[c.id] = el;
|
|
||||||
}}
|
|
||||||
className="pl-3 flex items-start gap-3"
|
className="pl-3 flex items-start gap-3"
|
||||||
style={{ borderLeft: `2px solid ${color}` }}
|
style={{ borderLeft: `2px solid ${region.color}` }}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-700">{c.name}</p>
|
<p className="font-700">{c.name}</p>
|
||||||
|
|
@ -490,7 +420,7 @@ function RegionBlock({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-700 underline"
|
className="font-700 underline"
|
||||||
style={{ color: color }}
|
style={{ color: region.color }}
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -499,7 +429,7 @@ function RegionBlock({
|
||||||
<a
|
<a
|
||||||
href={`mailto:${c.email}`}
|
href={`mailto:${c.email}`}
|
||||||
className="font-700 underline"
|
className="font-700 underline"
|
||||||
style={{ color: color }}
|
style={{ color: region.color }}
|
||||||
>
|
>
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -508,17 +438,16 @@ function RegionBlock({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{path && (
|
{orgPath(c) && (
|
||||||
<ArrowLink
|
<ArrowLink
|
||||||
to={path}
|
to={orgPath(c)}
|
||||||
label={`${c.name} — chapter page`}
|
label={`${c.name} — chapter page`}
|
||||||
color={color}
|
color={region.color}
|
||||||
size="h-8 w-8"
|
size="h-8 w-8"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -533,15 +462,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 +487,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 +539,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 +551,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 +560,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],
|
||||||
] satisfies Array<[label: string, value: string | null | undefined]>
|
].filter(([, v]) => v);
|
||||||
).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 ${region.color}`, background: `${region.color}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={region.color} size="h-20 w-20" />
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
|
||||||
|
|
@ -686,13 +585,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 ${region.color}`, color: region.color }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Blocks blocks={chapter.blocks} color={color} />
|
<Blocks blocks={chapter.blocks} color={region.color} />
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||||
|
|
@ -710,11 +609,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: region.color, color: "#ffffff" }}
|
||||||
>
|
>
|
||||||
Chapter page
|
Chapter page
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -725,7 +624,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 ${region.color}`, color: region.color }}
|
||||||
>
|
>
|
||||||
Visit site
|
Visit site
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -735,7 +634,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 ${region.color}`, color: region.color }}
|
||||||
>
|
>
|
||||||
Get in touch
|
Get in touch
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -745,19 +644,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 +656,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: region.color }}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-2xl font-800" style={{ color: color }}>
|
<h3 className="text-2xl font-800" style={{ color: region.color }}>
|
||||||
{region.name}
|
{region.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-sm" style={{ color: MUTED }}>
|
<span className="text-sm" style={{ color: MUTED }}>
|
||||||
|
|
@ -810,7 +696,7 @@ function ChapterGrid({
|
||||||
<ChapterCard
|
<ChapterCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
chapter={c}
|
chapter={c}
|
||||||
color={color}
|
color={region.color}
|
||||||
open={openId === c.id}
|
open={openId === c.id}
|
||||||
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -831,12 +717,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 +748,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 +765,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 +792,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 +801,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 +837,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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue