v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
29
src/App.tsx
29
src/App.tsx
|
|
@ -1,17 +1,31 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom";
|
||||
/*Components*/
|
||||
import Layout from "./components/Layout.tsx";
|
||||
|
||||
/*Libraries*/
|
||||
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
|
||||
|
||||
/*Primary Pages*/
|
||||
import Home from "./pages/Home.tsx";
|
||||
import Retreats from "./pages/Retreats.tsx";
|
||||
import Community from "./pages/Community.tsx";
|
||||
import Leadership from "./pages/Leadership.tsx";
|
||||
import Resources from "./pages/Resources.tsx";
|
||||
|
||||
/*Secondary Pages*/
|
||||
import Feedback from "./pages/Feedback.tsx";
|
||||
import Giving from "./pages/Giving.tsx";
|
||||
|
||||
/*Admin Pages*/
|
||||
import AdminLayout from "./pages/admin/AdminLayout.tsx";
|
||||
import AdminLogin from "./pages/admin/AdminLogin.tsx";
|
||||
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
|
||||
import EntityList from "./pages/admin/EntityList.tsx";
|
||||
import EntityEdit from "./pages/admin/EntityEdit.tsx";
|
||||
|
||||
/*Ternary Pages*/
|
||||
import Privacy from "./pages/Privacy.tsx";
|
||||
import Terms from "./pages/Terms.tsx";
|
||||
|
||||
import NotFound from "./pages/NotFound.tsx";
|
||||
|
||||
export default function App() {
|
||||
|
|
@ -31,6 +45,17 @@ export default function App() {
|
|||
<Route path="terms" element={<Terms />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
<Route element={<AuthProvider><Outlet /></AuthProvider>}>
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/feedback" replace />} />
|
||||
<Route path="feedback" element={<AdminFeedback />} />
|
||||
<Route path=":entity" element={<EntityList />} />
|
||||
<Route path=":entity/:id" element={<EntityEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,48 +1,370 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
import { get } from "../lib/api.js";
|
||||
import "./PeopleTiles.css";
|
||||
|
||||
/**
|
||||
* PeopleTiles — a horizontal, polaroid-style people list.
|
||||
*
|
||||
* Drop into any section:
|
||||
* <PeopleTiles size="md" groups={staffGroups} />
|
||||
* <PeopleTiles size="sm" people={volunteers} />
|
||||
* Supply data any of four ways:
|
||||
*
|
||||
* <PeopleTiles size="lg" teams="ngu-board" />
|
||||
* <PeopleTiles size="md" teams={["ngu-board", "nw-leadership"]} />
|
||||
* <PeopleTiles size="sm" people={[
|
||||
* { peopleslug: "john-doe", title: "Hospitality" },
|
||||
* { peopleslug: "jane-doe" },
|
||||
* ]} />
|
||||
* <PeopleTiles size="sm" people={volunteers} overflow="scroll" />
|
||||
*
|
||||
* With `teams`, each team is fetched in the order given and split
|
||||
* into a lead group and a members group by the affiliation's
|
||||
* is_owner flag.
|
||||
*
|
||||
* With `peopleslug`, the person is fetched by id and any other key
|
||||
* on the entry overrides what came back — so a title can be given
|
||||
* per placement, and is blank when it isn't. A slug entry can sit
|
||||
* beside a fully-written person in the same array.
|
||||
*
|
||||
* Sizes
|
||||
* sm photo + name
|
||||
* md photo + name + title
|
||||
* lg photo + name + title + expandable bio (pronouns, age, primary org above the bio)
|
||||
* lg photo + name + title, and a chevron on anyone who has
|
||||
* something to expand: pronouns, home organization, location,
|
||||
* public email or a bio. Not bio alone — the details panel is
|
||||
* worth opening before anyone has written prose.
|
||||
*
|
||||
* Group shape
|
||||
* { id, label?, note?, accent?, people: [person] }
|
||||
*
|
||||
* Person shape
|
||||
* {
|
||||
* id, name,
|
||||
* title?, // "Regional Director"
|
||||
* photo?, // "/people/jane-doe.jpg" — falls back to initials
|
||||
* pronouns?, // "she/her"
|
||||
* age?, // number, or use birthdate
|
||||
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
|
||||
* org?, // "Grace Chapel" or { name, href }
|
||||
* bio?, // string or string[] (paragraphs)
|
||||
* accent?, // per-person override
|
||||
* }
|
||||
* Field names follow the API (is_owner, location_label), so a row
|
||||
* from /api/teams/:id/people drops in unchanged.
|
||||
*/
|
||||
|
||||
const SIZE_FEATURES = {
|
||||
sm: { title: false, bio: false },
|
||||
md: { title: true, bio: false },
|
||||
lg: { title: true, bio: true },
|
||||
export interface Person {
|
||||
id?: string | number;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
tagline?: string | null;
|
||||
photo?: string | null;
|
||||
pronouns?: string | null;
|
||||
location_label?: string | null;
|
||||
public_email?: string | null;
|
||||
org?: string | { id?: string; name: string; href?: string } | null;
|
||||
bio?: string | string[] | null;
|
||||
accent?: string;
|
||||
role?: string | null;
|
||||
is_owner?: boolean;
|
||||
/** Accepted as an alias so hand-written entries can use either. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
/** A person named by slug. Any other field overrides the record. */
|
||||
export interface PersonRef extends Partial<Omit<Person, "name">> {
|
||||
peopleslug: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type PersonInput = Person | PersonRef;
|
||||
|
||||
export interface PeopleGroup {
|
||||
id?: string;
|
||||
label?: string;
|
||||
note?: string;
|
||||
accent?: string;
|
||||
people: Person[];
|
||||
}
|
||||
|
||||
export interface PeopleGroupInput extends Omit<PeopleGroup, "people"> {
|
||||
people: PersonInput[];
|
||||
}
|
||||
|
||||
export interface TeamSpec {
|
||||
id: string;
|
||||
/** Heading for the members group. Defaults to the team's name. */
|
||||
label?: string;
|
||||
/** Heading for the lead group. Defaults to the lead's own title. */
|
||||
leadLabel?: string;
|
||||
/** Set false to keep owners inline with everyone else. */
|
||||
splitOwners?: boolean;
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
export type TeamSource = string | TeamSpec;
|
||||
|
||||
export type PeopleTilesSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface PeopleTilesProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
||||
people?: PersonInput[];
|
||||
groups?: PeopleGroupInput[];
|
||||
teams?: TeamSource | TeamSource[];
|
||||
size?: PeopleTilesSize;
|
||||
scale?: number;
|
||||
overflow?: "wrap" | "scroll";
|
||||
align?: "start" | "center";
|
||||
accent?: string;
|
||||
tilt?: boolean;
|
||||
/** How long a fetched team or person is reused, in ms. */
|
||||
ttl?: number;
|
||||
emptyMessage?: string;
|
||||
loadingMessage?: string;
|
||||
errorMessage?: string;
|
||||
onExpand?: (person: Person | null, group: PeopleGroup | null) => void;
|
||||
}
|
||||
|
||||
const SIZE_FEATURES: Record<PeopleTilesSize, { title: boolean; details: boolean }> = {
|
||||
sm: { title: false, details: false },
|
||||
md: { title: true, details: false },
|
||||
lg: { title: true, details: true },
|
||||
};
|
||||
|
||||
/* ── Data ──────────────────────────────────────────────────────
|
||||
Fetching lives here rather than in every section, but the view
|
||||
below stays pure — it only ever sees resolved groups, whatever
|
||||
produced them.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function PeopleTiles({
|
||||
teams,
|
||||
groups,
|
||||
people,
|
||||
ttl = 5 * 60_000,
|
||||
loadingMessage = "Loading…",
|
||||
errorMessage = "Couldn't load this list right now.",
|
||||
...view
|
||||
}: PeopleTilesProps) {
|
||||
const specs = useMemo(() => normalizeTeams(teams), [teams]);
|
||||
const teamKey = specs.map((spec) => spec.id).join(",");
|
||||
|
||||
// Sorted so two sections naming the same people in a different
|
||||
// order still hit the same cached request.
|
||||
const slugs = useMemo(
|
||||
() => (specs.length ? [] : collectSlugs(groups, people)),
|
||||
[specs.length, groups, people],
|
||||
);
|
||||
const slugKey = slugs.join(",");
|
||||
|
||||
const [teamGroups, setTeamGroups] = useState<PeopleGroup[] | null>(null);
|
||||
const [directory, setDirectory] = useState<Record<string, Person> | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!specs.length) {
|
||||
setTeamGroups(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let live = true;
|
||||
setFailed(false);
|
||||
|
||||
Promise.all(
|
||||
specs.map((spec) =>
|
||||
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
|
||||
spec,
|
||||
data,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.then((results) => {
|
||||
if (!live) return;
|
||||
// Order follows the order the teams were supplied in, not
|
||||
// whichever request came back first.
|
||||
setTeamGroups(
|
||||
results.flatMap(({ spec, data }) => buildTeamGroups(spec, data, specs.length)),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!live) return;
|
||||
console.error("PeopleTiles: couldn't load teams", teamKey, err);
|
||||
setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [teamKey, ttl, specs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugKey) {
|
||||
setDirectory(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let live = true;
|
||||
setFailed(false);
|
||||
|
||||
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
|
||||
.then((data: { people: Person[] }) => {
|
||||
if (!live) return;
|
||||
const byId: Record<string, Person> = {};
|
||||
for (const person of data.people) byId[String(person.id)] = person;
|
||||
setDirectory(byId);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!live) return;
|
||||
console.error("PeopleTiles: couldn't load people", slugKey, err);
|
||||
setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [slugKey, ttl]);
|
||||
|
||||
if (specs.length) {
|
||||
if (failed) return <p className="pl__empty">{errorMessage}</p>;
|
||||
if (!teamGroups) return <p className="pl__empty">{loadingMessage}</p>;
|
||||
return <PeopleTilesView {...view} groups={teamGroups} />;
|
||||
}
|
||||
|
||||
if (slugKey) {
|
||||
if (failed) return <p className="pl__empty">{errorMessage}</p>;
|
||||
if (!directory) return <p className="pl__empty">{loadingMessage}</p>;
|
||||
return (
|
||||
<PeopleTilesView
|
||||
{...view}
|
||||
groups={groups?.map((group) => ({
|
||||
...group,
|
||||
people: resolveAll(group.people, directory),
|
||||
}))}
|
||||
people={people ? resolveAll(people, directory) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PeopleTilesView
|
||||
{...view}
|
||||
groups={groups as PeopleGroup[] | undefined}
|
||||
people={people as Person[] | undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamResponse {
|
||||
team: { id: string; name: string; color?: string | null };
|
||||
people: Person[];
|
||||
}
|
||||
|
||||
function normalizeTeams(teams: PeopleTilesProps["teams"]): TeamSpec[] {
|
||||
if (!teams) return [];
|
||||
const list = Array.isArray(teams) ? teams : [teams];
|
||||
return list
|
||||
.map((entry) => (typeof entry === "string" ? { id: entry } : entry))
|
||||
.filter((spec): spec is TeamSpec => Boolean(spec?.id));
|
||||
}
|
||||
|
||||
function isRef(entry: PersonInput): entry is PersonRef {
|
||||
return typeof (entry as PersonRef).peopleslug === "string";
|
||||
}
|
||||
|
||||
function collectSlugs(
|
||||
groups?: PeopleGroupInput[],
|
||||
people?: PersonInput[],
|
||||
): string[] {
|
||||
const found = new Set<string>();
|
||||
const scan = (list?: PersonInput[]) => {
|
||||
for (const entry of list ?? []) {
|
||||
if (entry && isRef(entry)) found.add(entry.peopleslug);
|
||||
}
|
||||
};
|
||||
scan(people);
|
||||
for (const group of groups ?? []) scan(group.people);
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/* The fetched record is the base; anything else on the entry wins,
|
||||
including an explicit null — that's how a title is deliberately
|
||||
left blank rather than inherited. */
|
||||
function resolveAll(
|
||||
list: PersonInput[],
|
||||
directory: Record<string, Person>,
|
||||
): Person[] {
|
||||
const resolved: Person[] = [];
|
||||
|
||||
for (const entry of list) {
|
||||
if (!entry) continue;
|
||||
|
||||
if (!isRef(entry)) {
|
||||
resolved.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const base = directory[entry.peopleslug];
|
||||
if (!base) {
|
||||
// Unpublished, deleted, or a typo in the slug. Leaving the
|
||||
// tile out beats rendering a nameless placeholder.
|
||||
console.warn(`PeopleTiles: no published person "${entry.peopleslug}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { peopleslug, ...overrides } = entry;
|
||||
const merged: Person = { ...base };
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
resolved.push(merged);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildTeamGroups(
|
||||
spec: TeamSpec,
|
||||
data: TeamResponse,
|
||||
teamCount: number,
|
||||
): PeopleGroup[] {
|
||||
const accent = spec.accent ?? data.team.color ?? undefined;
|
||||
const name = spec.label ?? data.team.name;
|
||||
const split = spec.splitOwners !== false;
|
||||
|
||||
const owners = split ? data.people.filter(owns) : [];
|
||||
const rest = split ? data.people.filter((person) => !owns(person)) : data.people;
|
||||
|
||||
// A lead only reads as a lead when there's a body of people to
|
||||
// stand apart from. All owners, or none, is just a list.
|
||||
if (!owners.length || !rest.length) {
|
||||
return [
|
||||
{
|
||||
id: data.team.id,
|
||||
// One unlabelled team needs no heading; several always do.
|
||||
label: teamCount > 1 || spec.label ? name : undefined,
|
||||
accent,
|
||||
people: data.people,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${data.team.id}-lead`,
|
||||
label: spec.leadLabel ?? leadLabel(owners, name),
|
||||
accent,
|
||||
people: owners,
|
||||
},
|
||||
{ id: data.team.id, label: name, accent, people: rest },
|
||||
];
|
||||
}
|
||||
|
||||
function leadLabel(owners: Person[], teamName: string): string {
|
||||
if (owners.length === 1 && titleOf(owners[0])) return titleOf(owners[0]) as string;
|
||||
return `${teamName} lead${owners.length > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
/* ── View ────────────────────────────────────────────────────── */
|
||||
|
||||
export function PeopleTilesView({
|
||||
people,
|
||||
groups,
|
||||
size = "md",
|
||||
scale = 1,
|
||||
overflow = "wrap", // "wrap" | "scroll"
|
||||
align = "start", // "start" | "center"
|
||||
overflow = "wrap",
|
||||
align = "start",
|
||||
accent,
|
||||
tilt = false,
|
||||
emptyMessage = "No one listed yet.",
|
||||
|
|
@ -50,18 +372,24 @@ export default function PeopleTiles({
|
|||
className = "",
|
||||
style,
|
||||
...rest
|
||||
}: Omit<
|
||||
PeopleTilesProps,
|
||||
"teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups"
|
||||
> & {
|
||||
people?: Person[];
|
||||
groups?: PeopleGroup[];
|
||||
}) {
|
||||
const baseId = useId().replace(/:/g, "");
|
||||
const [openKey, setOpenKey] = useState(null);
|
||||
const rootRef = useRef(null);
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const resolvedSize = SIZE_FEATURES[size] ? size : "md";
|
||||
const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
|
||||
const features = SIZE_FEATURES[resolvedSize];
|
||||
|
||||
const resolvedGroups = useMemo(() => {
|
||||
const source = Array.isArray(groups) && groups.length
|
||||
const resolvedGroups = useMemo<PeopleGroup[]>(() => {
|
||||
const source = groups?.length
|
||||
? groups
|
||||
: Array.isArray(people) && people.length
|
||||
: people?.length
|
||||
? [{ id: "all", people }]
|
||||
: [];
|
||||
|
||||
|
|
@ -74,7 +402,7 @@ export default function PeopleTiles({
|
|||
.filter((group) => group.people.length > 0);
|
||||
}, [groups, people]);
|
||||
|
||||
// Close the bio if the person it belongs to disappears from the data.
|
||||
// Close the panel if the person it belongs to disappears.
|
||||
useEffect(() => {
|
||||
if (!openKey) return;
|
||||
const stillThere = resolvedGroups.some((group) =>
|
||||
|
|
@ -87,21 +415,23 @@ export default function PeopleTiles({
|
|||
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
|
||||
}
|
||||
|
||||
const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
|
||||
const open = features.details ? findByKey(resolvedGroups, openKey) : null;
|
||||
|
||||
function toggle(group, person, index) {
|
||||
function toggle(group: PeopleGroup, person: Person, index: number) {
|
||||
const key = keyFor(group, person, index);
|
||||
const next = openKey === key ? null : key;
|
||||
setOpenKey(next);
|
||||
if (onExpand) onExpand(next ? person : null, next ? group : null);
|
||||
onExpand?.(next ? person : null, next ? group : null);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape" && openKey) {
|
||||
event.stopPropagation();
|
||||
setOpenKey(null);
|
||||
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
|
||||
if (button) button.focus();
|
||||
const button = rootRef.current?.querySelector<HTMLButtonElement>(
|
||||
'.pl__tile[aria-expanded="true"]',
|
||||
);
|
||||
button?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,11 +443,13 @@ export default function PeopleTiles({
|
|||
data-overflow={overflow}
|
||||
data-align={align}
|
||||
data-tilt={tilt ? "on" : "off"}
|
||||
style={{
|
||||
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
||||
...(accent ? { "--pl-accent": accent } : null),
|
||||
...style,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
...(scale !== 1 ? { "--pl-scale": scale } : null),
|
||||
...(accent ? { "--pl-accent": accent } : null),
|
||||
...style,
|
||||
} as CSSProperties
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
>
|
||||
|
|
@ -126,7 +458,9 @@ export default function PeopleTiles({
|
|||
<section
|
||||
key={group.id}
|
||||
className="pl__group"
|
||||
style={group.accent ? { "--pl-accent": group.accent } : undefined}
|
||||
style={
|
||||
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
|
||||
}
|
||||
aria-label={group.label || undefined}
|
||||
>
|
||||
{(group.label || group.note) && (
|
||||
|
|
@ -139,14 +473,18 @@ export default function PeopleTiles({
|
|||
<ul className="pl__row">
|
||||
{group.people.map((person, index) => {
|
||||
const key = keyFor(group, person, index);
|
||||
const expandable = features.bio && hasBio(person);
|
||||
const expandable = features.details && hasDetails(person);
|
||||
const isOpen = expandable && openKey === key;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="pl__item"
|
||||
style={person.accent ? { "--pl-accent": person.accent } : undefined}
|
||||
style={
|
||||
person.accent
|
||||
? ({ "--pl-accent": person.accent } as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tile
|
||||
person={person}
|
||||
|
|
@ -165,13 +503,13 @@ export default function PeopleTiles({
|
|||
</div>
|
||||
|
||||
{open && (
|
||||
<BioPanel
|
||||
<DetailPanel
|
||||
id={`${baseId}-bio`}
|
||||
person={open.person}
|
||||
group={open.group}
|
||||
onClose={() => {
|
||||
setOpenKey(null);
|
||||
if (onExpand) onExpand(null, null);
|
||||
onExpand?.(null, null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -179,24 +517,38 @@ export default function PeopleTiles({
|
|||
);
|
||||
}
|
||||
|
||||
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
||||
function Tile({
|
||||
person,
|
||||
showTitle,
|
||||
expandable,
|
||||
isOpen,
|
||||
panelId,
|
||||
onToggle,
|
||||
}: {
|
||||
person: Person;
|
||||
showTitle: boolean;
|
||||
expandable: boolean;
|
||||
isOpen: boolean;
|
||||
panelId: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const title = titleOf(person);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="pl__frame">
|
||||
<span className="pl__photo">
|
||||
<Photo src={person.photo} name={person.name} />
|
||||
</span>
|
||||
<span className="pl__caption">
|
||||
<span className="pl__name">{person.name}</span>
|
||||
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
|
||||
</span>
|
||||
{expandable && (
|
||||
<span className="pl__badge" aria-hidden="true">
|
||||
<Chevron />
|
||||
</span>
|
||||
)}
|
||||
<span className="pl__frame">
|
||||
<span className="pl__photo">
|
||||
<Photo src={photoSrc(person.photo)} name={person.name} />
|
||||
</span>
|
||||
</>
|
||||
<span className="pl__caption">
|
||||
<span className="pl__name">{person.name}</span>
|
||||
{showTitle && title && <span className="pl__title">{title}</span>}
|
||||
</span>
|
||||
{expandable && (
|
||||
<span className="pl__badge" aria-hidden="true">
|
||||
<Chevron />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!expandable) {
|
||||
|
|
@ -212,12 +564,12 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
|
|||
onClick={onToggle}
|
||||
>
|
||||
{content}
|
||||
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
|
||||
<span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ src, name }) {
|
||||
function Photo({ src, name }: { src?: string | null; name: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -244,10 +596,21 @@ function Photo({ src, name }) {
|
|||
);
|
||||
}
|
||||
|
||||
function BioPanel({ id, person, group, onClose }) {
|
||||
const age = resolveAge(person);
|
||||
function DetailPanel({
|
||||
id,
|
||||
person,
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
id: string;
|
||||
person: Person;
|
||||
group: PeopleGroup;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const org = resolveOrg(person.org);
|
||||
const title = titleOf(person);
|
||||
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
|
||||
const tint = person.accent || group?.accent;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -255,49 +618,55 @@ function BioPanel({ id, person, group, onClose }) {
|
|||
className="pl__bio"
|
||||
role="region"
|
||||
aria-label={`About ${person.name}`}
|
||||
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
|
||||
style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
|
||||
>
|
||||
<div className="pl__bio-head">
|
||||
<div>
|
||||
<p className="pl__bio-name">{person.name}</p>
|
||||
{person.title && <p className="pl__bio-title">{person.title}</p>}
|
||||
{title && <p className="pl__bio-title">{title}</p>}
|
||||
</div>
|
||||
<button type="button" className="pl__close" onClick={onClose}>
|
||||
<span className="pl__sr">Close bio</span>
|
||||
<span className="pl__sr">Close details</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(person.pronouns || age != null || org) && (
|
||||
<dl className="pl__facts">
|
||||
{person.pronouns && (
|
||||
<div className="pl__fact">
|
||||
<dt>Pronouns</dt>
|
||||
<dd>{person.pronouns}</dd>
|
||||
</div>
|
||||
)}
|
||||
{age != null && (
|
||||
<div className="pl__fact">
|
||||
<dt>Age</dt>
|
||||
<dd>{age}</dd>
|
||||
</div>
|
||||
)}
|
||||
{org && (
|
||||
<div className="pl__fact">
|
||||
<dt>Home organization</dt>
|
||||
<dd>
|
||||
{org.href ? (
|
||||
<a href={org.href} target="_blank" rel="noreferrer">
|
||||
{org.name}
|
||||
</a>
|
||||
) : (
|
||||
org.name
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
<dl className="pl__facts">
|
||||
{person.pronouns && (
|
||||
<div className="pl__fact">
|
||||
<dt>Pronouns</dt>
|
||||
<dd>{person.pronouns}</dd>
|
||||
</div>
|
||||
)}
|
||||
{org && (
|
||||
<div className="pl__fact">
|
||||
<dt>Home organization</dt>
|
||||
<dd>
|
||||
{org.href ? (
|
||||
<a href={org.href} target="_blank" rel="noreferrer">
|
||||
{org.name}
|
||||
</a>
|
||||
) : (
|
||||
org.name
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{person.location_label && (
|
||||
<div className="pl__fact">
|
||||
<dt>Based in</dt>
|
||||
<dd>{person.location_label}</dd>
|
||||
</div>
|
||||
)}
|
||||
{person.public_email && (
|
||||
<div className="pl__fact">
|
||||
<dt>Email</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${person.public_email}`}>{person.public_email}</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{paragraphs.filter(Boolean).map((paragraph, index) => (
|
||||
<p key={index} className="pl__bio-text">
|
||||
|
|
@ -323,13 +692,13 @@ function Chevron() {
|
|||
);
|
||||
}
|
||||
|
||||
/* helpers ------------------------------------------------------------- */
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function keyFor(group, person, index) {
|
||||
function keyFor(group: PeopleGroup, person: Person, index: number): string {
|
||||
return `${group.id}:${person.id ?? person.name ?? index}`;
|
||||
}
|
||||
|
||||
function findByKey(groups, key) {
|
||||
function findByKey(groups: PeopleGroup[], key: string | null) {
|
||||
if (!key) return null;
|
||||
for (const group of groups) {
|
||||
for (let index = 0; index < group.people.length; index += 1) {
|
||||
|
|
@ -340,12 +709,36 @@ function findByKey(groups, key) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function hasBio(person) {
|
||||
if (Array.isArray(person.bio)) return person.bio.some(Boolean);
|
||||
return Boolean(person.bio);
|
||||
/* The seat they hold here, or what they're called when there is no
|
||||
seat — the same fallback content.js applies to leadership rows. */
|
||||
function titleOf(person: Person): string | null {
|
||||
return person.title || person.tagline || null;
|
||||
}
|
||||
|
||||
function initials(name = "") {
|
||||
function owns(person: Person): boolean {
|
||||
return Boolean(person.is_owner ?? person.isOwner);
|
||||
}
|
||||
|
||||
/* Anything the panel would have to show. Gating on bio alone left
|
||||
every tile flat until someone wrote prose. */
|
||||
function hasDetails(person: Person): boolean {
|
||||
if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email,
|
||||
);
|
||||
}
|
||||
|
||||
/* Database rows carry a bare filename; a hand-written entry may
|
||||
give a path or a full URL. Both should work. */
|
||||
function photoSrc(photo?: string | null): string | null {
|
||||
if (!photo) return null;
|
||||
if (/^(https?:|\/|data:)/.test(photo)) return photo;
|
||||
return `/people/${photo}`;
|
||||
}
|
||||
|
||||
function initials(name = ""): string {
|
||||
return name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
|
|
@ -355,21 +748,9 @@ function initials(name = "") {
|
|||
.toUpperCase();
|
||||
}
|
||||
|
||||
function resolveAge(person) {
|
||||
if (typeof person.age === "number") return person.age;
|
||||
if (!person.birthdate) return null;
|
||||
const born = new Date(person.birthdate);
|
||||
if (Number.isNaN(born.getTime())) return null;
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - born.getFullYear();
|
||||
const monthDelta = now.getMonth() - born.getMonth();
|
||||
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
function resolveOrg(org) {
|
||||
function resolveOrg(org: Person["org"]) {
|
||||
if (!org) return null;
|
||||
if (typeof org === "string") return { name: org };
|
||||
if (typeof org === "string") return { name: org, href: undefined };
|
||||
if (!org.name) return null;
|
||||
return org;
|
||||
}
|
||||
|
|
|
|||
418
src/components/admin/fields.tsx
Normal file
418
src/components/admin/fields.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN FORM PRIMITIVES
|
||||
|
||||
Field renders one input from a manifest entry. Repeater renders
|
||||
an ordered collection of them, and nests one level for content
|
||||
blocks and their items.
|
||||
|
||||
Ordering is array position — the server writes sort_order from
|
||||
the index — so moving a row is a splice, not a number to
|
||||
hand-edit. Rows can be dragged by the handle or moved with the
|
||||
arrow buttons; the arrows are the keyboard path and stay whether
|
||||
or not a pointer is in use.
|
||||
|
||||
A row added and never filled in is dropped by the server rather
|
||||
than rejected, which depends on spec.blank seeding nothing the
|
||||
server doesn't also declare as a column default. If you give a
|
||||
blank row a starting value here, add the matching default: to
|
||||
that column in the server's admin-schema.js or the row will be
|
||||
saved as real input.
|
||||
|
||||
Two options change the box itself rather than what goes in it:
|
||||
|
||||
prefix fixed text inside the box, left of the cursor. The
|
||||
value it decorates is only the part after it, so
|
||||
the caller joins the two. Used by composed slugs,
|
||||
where the prefix is a fact about another field
|
||||
rather than something to retype.
|
||||
readOnly shown, selectable, not editable. Deliberately not
|
||||
`disabled`: a disabled control reads as switched
|
||||
off and drops out of the tab order, whereas an
|
||||
immutable id is settled fact you still want to be
|
||||
able to read and copy.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
const input =
|
||||
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
||||
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
/* Same box, but lit by the real input nested inside it. */
|
||||
const inputShell =
|
||||
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
|
||||
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
|
||||
"focus-within:ring-[#138ba0]/25";
|
||||
|
||||
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
||||
const shellError =
|
||||
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
|
||||
|
||||
/* Reads as settled fact rather than as an empty box someone forgot
|
||||
to fill in. */
|
||||
const inputLocked =
|
||||
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
|
||||
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
|
||||
|
||||
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||
|
||||
export function getPath(object, path) {
|
||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
export function setPath(object, path, value) {
|
||||
const [head, ...rest] = path.split(".");
|
||||
if (rest.length === 0) return { ...object, [head]: value };
|
||||
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
|
||||
}
|
||||
|
||||
/* ── Field ───────────────────────────────────────────────────── */
|
||||
|
||||
export function Field({ field, value, row, options, error, onChange }) {
|
||||
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||
const widget = field.widget ?? "text";
|
||||
const locked = Boolean(field.readOnly);
|
||||
|
||||
let list = null;
|
||||
let orphaned = false;
|
||||
|
||||
if (widget === "select") {
|
||||
list = field.optionsFrom
|
||||
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
|
||||
: (field.options ?? []).map((o) =>
|
||||
Array.isArray(o) ? [o[0], o[1]] : [o, o],
|
||||
);
|
||||
if (field.filterBy && row) {
|
||||
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
|
||||
}
|
||||
|
||||
// A stored value with no matching option renders as the blank
|
||||
// choice, which reads as "nobody set this" and saves as a
|
||||
// deliberate clear. It usually means the row it pointed at was
|
||||
// deleted, so keep it on screen and say so.
|
||||
orphaned =
|
||||
value != null &&
|
||||
value !== "" &&
|
||||
!list.some(([optionId]) => String(optionId) === String(value));
|
||||
}
|
||||
|
||||
const common = {
|
||||
id,
|
||||
className: `${input} ${error ? inputError : ""}`,
|
||||
value: value ?? "",
|
||||
onChange: (e) => onChange(e.target.value),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={field.full ? "sm:col-span-2" : ""}>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
||||
{field.label}
|
||||
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
|
||||
</label>
|
||||
|
||||
<div className="mt-1.5">
|
||||
{widget === "checkbox" ? (
|
||||
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={value === 1 || value === true}
|
||||
disabled={locked}
|
||||
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
|
||||
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
|
||||
/>
|
||||
{field.help ?? "Yes"}
|
||||
</label>
|
||||
) : widget === "textarea" ? (
|
||||
<textarea
|
||||
{...common}
|
||||
rows={4}
|
||||
readOnly={locked}
|
||||
className={`${locked ? inputLocked : common.className} resize-y`}
|
||||
/>
|
||||
) : widget === "select" ? (
|
||||
// A select has no readOnly, so this one really does have
|
||||
// to be disabled — there's no way to keep it focusable
|
||||
// and still refuse a new choice.
|
||||
<select
|
||||
{...common}
|
||||
disabled={locked}
|
||||
className={`${locked ? inputLocked : common.className} ${
|
||||
orphaned && !locked ? inputError : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
||||
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
||||
{list.map(([id2, label]) => (
|
||||
<option key={id2} value={id2}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : widget === "color" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
||||
disabled={locked}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
||||
/>
|
||||
<input
|
||||
{...common}
|
||||
readOnly={locked}
|
||||
placeholder="#138ba0"
|
||||
className={locked ? inputLocked : common.className}
|
||||
/>
|
||||
</div>
|
||||
) : field.prefix && !locked ? (
|
||||
// The span is not a form control, so it can't be typed
|
||||
// into, tabbed to, or selected by dragging through the
|
||||
// field. Clicking it focuses the input, which is what
|
||||
// makes the two read as one box.
|
||||
<div
|
||||
className={`${inputShell} ${error ? shellError : ""}`}
|
||||
onClick={() => document.getElementById(id)?.focus()}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 select-none ${
|
||||
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
|
||||
}`}
|
||||
>
|
||||
{field.prefix}
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
|
||||
step={widget === "number" ? "any" : undefined}
|
||||
{...common}
|
||||
readOnly={locked}
|
||||
aria-readonly={locked || undefined}
|
||||
className={locked ? inputLocked : common.className}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
|
||||
) : orphaned && !locked ? (
|
||||
<p className="mt-1 text-xs text-[#b3261e]">
|
||||
This points at something that has been deleted. Pick a replacement before saving.
|
||||
</p>
|
||||
) : (
|
||||
field.help &&
|
||||
widget !== "checkbox" && (
|
||||
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldGrid({ children }) {
|
||||
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
/* ── Repeater ────────────────────────────────────────────────── */
|
||||
|
||||
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
||||
const list = rows ?? [];
|
||||
|
||||
// Which row is in flight, and which one it's currently over.
|
||||
// Both are per-Repeater, which is what keeps a drag inside a
|
||||
// nested collection from being accepted by the outer one.
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
const [overIndex, setOverIndex] = useState(null);
|
||||
const rowRefs = useRef([]);
|
||||
|
||||
const update = (index, next) =>
|
||||
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||
|
||||
const move = (index, delta) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= list.length) return;
|
||||
const next = [...list];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const relocate = (from, to) => {
|
||||
if (from === to || from == null || to == null) return;
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...list, { ...spec.blank }])}
|
||||
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
{spec.addLabel ?? "Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
|
||||
|
||||
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{list.map((row, index) => {
|
||||
const dragging = dragIndex === index;
|
||||
const over = overIndex === index && dragIndex !== null && !dragging;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// A null dragIndex means the drag started in some
|
||||
// other collection — leave it to whoever owns it.
|
||||
if (dragIndex === null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (overIndex !== index) setOverIndex(index);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (dragIndex === null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
relocate(dragIndex, index);
|
||||
endDrag();
|
||||
}}
|
||||
className={
|
||||
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
|
||||
(over
|
||||
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
|
||||
: "border-[#4a6b72]/20 ") +
|
||||
(dragging ? "opacity-50" : "")
|
||||
}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Firefox won't start a drag without payload.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
// Drag the whole row, not just the handle.
|
||||
const el = rowRefs.current[index];
|
||||
if (el) e.dataTransfer.setDragImage(el, 16, 16);
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
title="Drag to reorder"
|
||||
aria-hidden="true"
|
||||
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
|
||||
>
|
||||
⠿
|
||||
</span>
|
||||
|
||||
<span className="text-sm font-medium text-[#26454c]">
|
||||
{spec.title ? spec.title(row, options) : `#${index + 1}`}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex gap-1">
|
||||
<IconButton
|
||||
label="Move up"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Move down"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === list.length - 1}
|
||||
>
|
||||
↓
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Remove"
|
||||
danger
|
||||
onClick={() => onChange(list.filter((_, i) => i !== index))}
|
||||
>
|
||||
✕
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGrid>
|
||||
{spec.fields.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
row={row}
|
||||
value={row[field.path]}
|
||||
options={options}
|
||||
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
|
||||
onChange={(value) => update(index, { ...row, [field.path]: value })}
|
||||
/>
|
||||
))}
|
||||
</FieldGrid>
|
||||
|
||||
{(spec.children ?? []).map((nested) => (
|
||||
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
||||
<Repeater
|
||||
spec={nested}
|
||||
rows={row[nested.key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
||||
onChange={(value) => update(index, { ...row, [nested.key]: value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, onClick, danger, disabled, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
|
||||
(disabled
|
||||
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
|
||||
: danger
|
||||
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
|
||||
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
51
src/data/feedbackTypes.js
Normal file
51
src/data/feedbackTypes.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
FEEDBACK TYPES
|
||||
|
||||
Used by the public form (to render the tiles) and the admin
|
||||
triage view (to label them). The ids must match TYPES in
|
||||
server/src/routes/feedback.js — that list stays separate because
|
||||
the server isn't in this workspace, and it's the thing that
|
||||
decides what's storable.
|
||||
|
||||
'general' isn't offered anywhere; it's the server's fallback for
|
||||
a type it doesn't recognise, so old rows still read sensibly.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const FEEDBACK_TYPES = [
|
||||
{
|
||||
id: "broken",
|
||||
label: "Something's broken",
|
||||
hint: "A link, image, or button that doesn't work",
|
||||
},
|
||||
{
|
||||
id: "confusing",
|
||||
label: "Hard to use",
|
||||
hint: "Something you couldn't find or follow",
|
||||
},
|
||||
{
|
||||
id: "outdated",
|
||||
label: "Wrong or missing info",
|
||||
hint: "Old dates, typos, an event that isn't listed",
|
||||
},
|
||||
{
|
||||
id: "request",
|
||||
label: "Feature request",
|
||||
hint: "Something you'd like the site to do",
|
||||
},
|
||||
{
|
||||
id: "praise",
|
||||
label: "Kind words",
|
||||
hint: "Tell us what's working well",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
hint: "Anything that doesn't fit the boxes above",
|
||||
},
|
||||
];
|
||||
|
||||
const BY_ID = new Map(FEEDBACK_TYPES.map((t) => [t.id, t.label]));
|
||||
|
||||
export function feedbackTypeLabel(id) {
|
||||
return BY_ID.get(id) ?? (id === "general" ? "Unsorted" : id);
|
||||
}
|
||||
614
src/lib/adminSchema.js
Normal file
614
src/lib/adminSchema.js
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN FORM MANIFESTS
|
||||
|
||||
The presentation half of server/src/admin-schema.js: labels,
|
||||
widgets, grouping, and which select pulls from which option
|
||||
list. The server decides what's storable; this decides what the
|
||||
form looks like. Paths here must match column names there, and
|
||||
nested paths ('region.scope') match the side-table keys.
|
||||
|
||||
Adding a field is one entry. Adding an entity is one object here
|
||||
plus one descriptor on the server — no new page component.
|
||||
|
||||
A value seeded in a repeater's `blank` must also be declared as
|
||||
a default: on the matching server column, or a row the user adds
|
||||
and never fills in reads as real input instead of being skipped.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const PLACE_FIELDS = [
|
||||
{ path: "venue", label: "Venue" },
|
||||
{ path: "address", label: "Address", full: true },
|
||||
{ path: "locality", label: "City" },
|
||||
{ path: "state_code", label: "State", help: "Two letters, US only" },
|
||||
{ path: "country", label: "Country", help: "Defaults to US" },
|
||||
{ path: "location_label", label: "Display location", help: "Overrides the line built from city and state" },
|
||||
{ path: "latitude", label: "Latitude", widget: "number" },
|
||||
{ path: "longitude", label: "Longitude", widget: "number" },
|
||||
{ path: "is_online", label: "Online", widget: "checkbox" },
|
||||
];
|
||||
|
||||
const PUBLISH_FIELDS = [
|
||||
{ path: "is_published", label: "Published", widget: "checkbox" },
|
||||
{ path: "sort_order", label: "Sort order", widget: "number" },
|
||||
];
|
||||
|
||||
/* The parts of an affiliation that read the same from either end —
|
||||
the person's list of roles, and the team's list of members. */
|
||||
const AFFILIATION_ROLE_FIELDS = [
|
||||
{ path: "title", label: "Title", help: "Board Chair, Chapter Lead" },
|
||||
{
|
||||
path: "role",
|
||||
label: "Role",
|
||||
widget: "select",
|
||||
options: ["lead", "board", "staff", "volunteer", "member"],
|
||||
},
|
||||
{ path: "is_owner", label: "Owner", widget: "checkbox", help: "Listed first" },
|
||||
{ path: "started_on", label: "Started", widget: "date" },
|
||||
{ path: "ended_on", label: "Ended", widget: "date", help: "Blank means current" },
|
||||
{ path: "is_public", label: "Public", widget: "checkbox" },
|
||||
];
|
||||
|
||||
/* The two collections every entity carries. */
|
||||
const linksChild = {
|
||||
key: "links",
|
||||
label: "Links and socials",
|
||||
addLabel: "Add link",
|
||||
title: (row) => row.label || row.url || "New link",
|
||||
blank: { kind: "action", label: "", url: "", is_primary: 0 },
|
||||
fields: [
|
||||
{
|
||||
path: "kind",
|
||||
label: "Kind",
|
||||
widget: "select",
|
||||
options: ["action", "social", "website", "email"],
|
||||
},
|
||||
{ path: "platform", label: "Platform", help: "instagram, discord…" },
|
||||
{ path: "label", label: "Label", required: true },
|
||||
{ path: "url", label: "URL", required: true, full: true },
|
||||
{ path: "is_primary", label: "Primary", widget: "checkbox" },
|
||||
],
|
||||
};
|
||||
|
||||
const blocksChild = {
|
||||
key: "content_blocks",
|
||||
label: "Content blocks",
|
||||
addLabel: "Add block",
|
||||
title: (row) => `${row.type ?? "block"} — ${(row.text ?? "").slice(0, 40) || "empty"}`,
|
||||
blank: { slot: "body", type: "paragraph", text: "" },
|
||||
fields: [
|
||||
{ path: "slot", label: "Slot", widget: "select", options: ["card", "body"] },
|
||||
{
|
||||
path: "type",
|
||||
label: "Type",
|
||||
widget: "select",
|
||||
options: [
|
||||
"heading",
|
||||
"subheading",
|
||||
"paragraph",
|
||||
"list",
|
||||
"links",
|
||||
"quote",
|
||||
"image",
|
||||
"divider",
|
||||
],
|
||||
},
|
||||
{ path: "text", label: "Text", widget: "textarea", full: true },
|
||||
{ path: "media", label: "Media", help: "Filename or URL" },
|
||||
{ path: "href", label: "Link" },
|
||||
],
|
||||
children: [
|
||||
{
|
||||
key: "items",
|
||||
label: "Items",
|
||||
addLabel: "Add item",
|
||||
title: (row) => row.text || "New item",
|
||||
blank: { text: "" },
|
||||
fields: [
|
||||
{ path: "text", label: "Text", required: true },
|
||||
{ path: "detail", label: "Detail" },
|
||||
{ path: "url", label: "URL", help: "Blank for a plain list item" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Organizations ───────────────────────────────────────────── */
|
||||
|
||||
const organizations = {
|
||||
key: "organizations",
|
||||
label: "Organizations",
|
||||
singular: "organization",
|
||||
idLabel: "Slug",
|
||||
slugFrom: "name",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
{ key: "name", label: "Name", primary: true },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "locality", label: "City" },
|
||||
{ key: "state_code", label: "State" },
|
||||
{ key: "is_published", label: "Live", widget: "bool" },
|
||||
],
|
||||
filters: [
|
||||
{ key: "kind", label: "Kind", options: ["national", "region", "chapter", "partner"] },
|
||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||
],
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "Identity",
|
||||
fields: [
|
||||
{
|
||||
path: "kind",
|
||||
label: "Kind",
|
||||
widget: "select",
|
||||
options: ["national", "region", "chapter", "partner"],
|
||||
required: true,
|
||||
help: "Changing this swaps which extra fields apply",
|
||||
},
|
||||
{ path: "name", label: "Name", required: true },
|
||||
{ path: "short_name", label: "Short name" },
|
||||
{ path: "tagline", label: "Tagline", full: true },
|
||||
{ path: "color", label: "Colour", widget: "color" },
|
||||
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Region",
|
||||
when: { path: "kind", value: "region" },
|
||||
fields: [
|
||||
{
|
||||
path: "region.scope",
|
||||
label: "Scope",
|
||||
widget: "select",
|
||||
options: ["domestic", "international", "virtual"],
|
||||
required: true,
|
||||
},
|
||||
{ path: "region.map_note", label: "Map note", full: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Chapter",
|
||||
when: { path: "kind", value: "chapter" },
|
||||
fields: [
|
||||
{
|
||||
path: "chapter.region_id",
|
||||
label: "Region",
|
||||
widget: "select",
|
||||
optionsFrom: "regions",
|
||||
blankLabel: "— none —",
|
||||
},
|
||||
{ path: "chapter.meets", label: "Meets", help: "2nd Sundays, 6:00pm" },
|
||||
{ path: "chapter.started", label: "Started", help: "Since 2021" },
|
||||
],
|
||||
},
|
||||
{ legend: "Place", fields: PLACE_FIELDS },
|
||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||
],
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "region_areas",
|
||||
label: "Map areas",
|
||||
when: { path: "kind", value: "region" },
|
||||
addLabel: "Add area",
|
||||
title: (row) => row.area_code || "New area",
|
||||
blank: { area_code: "", share: 1 },
|
||||
fields: [
|
||||
{ path: "area_code", label: "Area code", required: true, help: "WA, CA, CANADA" },
|
||||
{ path: "share", label: "Share", widget: "number", help: "1 for the whole tile, 0.5 for half" },
|
||||
{
|
||||
path: "edge",
|
||||
label: "Edge",
|
||||
widget: "select",
|
||||
options: ["top", "bottom"],
|
||||
blankLabel: "— whole tile —",
|
||||
},
|
||||
{ path: "note", label: "Note", help: "north, Salt Lake City area" },
|
||||
],
|
||||
},
|
||||
linksChild,
|
||||
blocksChild,
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Events ──────────────────────────────────────────────────── */
|
||||
|
||||
const events = {
|
||||
key: "events",
|
||||
label: "Events",
|
||||
singular: "event",
|
||||
idLabel: "Slug",
|
||||
slugFrom: "title",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
{ key: "title", label: "Title", primary: true },
|
||||
{ key: "section_id", label: "Section" },
|
||||
{ key: "date_label", label: "Dates" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "is_published", label: "Live", widget: "bool" },
|
||||
],
|
||||
filters: [
|
||||
{ key: "section_id", label: "Section", optionsFrom: "event_sections" },
|
||||
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
|
||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||
],
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "Identity",
|
||||
fields: [
|
||||
{
|
||||
path: "section_id",
|
||||
label: "Section",
|
||||
widget: "select",
|
||||
optionsFrom: "event_sections",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
path: "host_org_id",
|
||||
label: "Host",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
blankLabel: "— none —",
|
||||
help: "Supplies the logo and colour when this event sets neither",
|
||||
},
|
||||
{ path: "title", label: "Title", required: true },
|
||||
{ path: "theme", label: "Theme" },
|
||||
{ path: "tagline", label: "Tagline", full: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "When",
|
||||
fields: [
|
||||
{ path: "starts_on", label: "Starts", widget: "date" },
|
||||
{ path: "ends_on", label: "Ends", widget: "date" },
|
||||
{
|
||||
path: "date_label",
|
||||
label: "Date label",
|
||||
help: "What the card shows — 'March/April 2026' is fine here",
|
||||
},
|
||||
{
|
||||
path: "status",
|
||||
label: "Status",
|
||||
widget: "select",
|
||||
options: ["upcoming", "past", "cancelled"],
|
||||
blankLabel: "— derive from end date —",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ legend: "Where", fields: PLACE_FIELDS },
|
||||
{
|
||||
legend: "Appearance",
|
||||
fields: [
|
||||
{ path: "event_logo", label: "Event logo", help: "Filename in public/event-logos/" },
|
||||
{ path: "org_logo", label: "Org logo override" },
|
||||
{ path: "color", label: "Colour", widget: "color" },
|
||||
{ path: "gradient", label: "Gradient", full: true },
|
||||
],
|
||||
},
|
||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||
],
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "event_people",
|
||||
label: "People at this event",
|
||||
addLabel: "Add person",
|
||||
title: (row, options) =>
|
||||
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
|
||||
blank: { person_id: "", role: "speaker", is_public: 1 },
|
||||
fields: [
|
||||
{
|
||||
path: "person_id",
|
||||
label: "Person",
|
||||
widget: "select",
|
||||
optionsFrom: "people",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
path: "role",
|
||||
label: "Role",
|
||||
widget: "select",
|
||||
options: [
|
||||
"speaker",
|
||||
"leader",
|
||||
"facilitator",
|
||||
"host",
|
||||
"musician",
|
||||
"volunteer",
|
||||
"attendee",
|
||||
],
|
||||
},
|
||||
{ path: "title", label: "Title", help: "Keynote Speaker" },
|
||||
{ path: "is_public", label: "Show on the site", widget: "checkbox" },
|
||||
],
|
||||
},
|
||||
linksChild,
|
||||
blocksChild,
|
||||
],
|
||||
};
|
||||
|
||||
/* ── People ──────────────────────────────────────────────────── */
|
||||
|
||||
const people = {
|
||||
key: "people",
|
||||
label: "People",
|
||||
singular: "person",
|
||||
idLabel: "Slug",
|
||||
slugFrom: "display_name",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
{ key: "display_name", label: "Name", primary: true },
|
||||
{ key: "tagline", label: "Tagline" },
|
||||
{ key: "locality", label: "City" },
|
||||
{ key: "is_published", label: "Live", widget: "bool" },
|
||||
],
|
||||
filters: [
|
||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||
],
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "Identity",
|
||||
fields: [
|
||||
{ path: "display_name", label: "Display name", required: true },
|
||||
{ path: "sort_name", label: "Sort name", help: "Doe, Jane" },
|
||||
{ path: "pronouns", label: "Pronouns" },
|
||||
{ path: "tagline", label: "Tagline", full: true },
|
||||
{ path: "photo", label: "Photo", help: "Filename in public/people/" },
|
||||
{
|
||||
path: "primary_org_id",
|
||||
label: "Home organization",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
blankLabel: "— none —",
|
||||
help: "Optional. Shown above their bio",
|
||||
},
|
||||
{
|
||||
path: "bio",
|
||||
label: "Bio",
|
||||
widget: "textarea",
|
||||
full: true,
|
||||
help: "Leave a blank line between paragraphs",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Public contact",
|
||||
fields: [
|
||||
{ path: "public_email", label: "Email", help: "Printed on the site" },
|
||||
{ path: "public_phone", label: "Phone" },
|
||||
{ path: "locality", label: "City" },
|
||||
{ path: "state_code", label: "State" },
|
||||
{ path: "country", label: "Country" },
|
||||
{ path: "location_label", label: "Display location" },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Private",
|
||||
note: "Never sent to the public site. Only admins see this.",
|
||||
fields: [
|
||||
{ path: "private.birth_date", label: "Birth date", widget: "date" },
|
||||
{ path: "private.private_email", label: "Email" },
|
||||
{ path: "private.private_phone", label: "Phone" },
|
||||
{ path: "private.address", label: "Address", full: true },
|
||||
{ path: "private.notes", label: "Notes", widget: "textarea", full: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Publishing",
|
||||
note: "Unpublished people are invisible everywhere, including as chapter leads.",
|
||||
fields: PUBLISH_FIELDS,
|
||||
},
|
||||
],
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "affiliations",
|
||||
label: "Roles and organizations",
|
||||
addLabel: "Add role",
|
||||
note:
|
||||
"Where someone appears within a team is set on that team's page, " +
|
||||
"not by the order of this list.",
|
||||
title: (row, options) =>
|
||||
[row.title, options?.organizations?.find((o) => o.id === row.org_id)?.label]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "New role",
|
||||
blank: { org_id: "", role: "member", is_public: 1, is_owner: 0 },
|
||||
fields: [
|
||||
{
|
||||
path: "org_id",
|
||||
label: "Organization",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
path: "team_id",
|
||||
label: "Team",
|
||||
widget: "select",
|
||||
optionsFrom: "teams",
|
||||
blankLabel: "— none —",
|
||||
// A team belongs to an org; offering one from another
|
||||
// org would fail the composite foreign key on save.
|
||||
filterBy: (option, row) => option.org_id === row.org_id,
|
||||
help: "Only teams of the chosen organization",
|
||||
},
|
||||
...AFFILIATION_ROLE_FIELDS,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "person_awards",
|
||||
label: "Awards",
|
||||
addLabel: "Add award",
|
||||
title: (row, options) =>
|
||||
options?.awards?.find((a) => a.id === row.award_id)?.label ?? "New award",
|
||||
blank: { award_id: "", is_public: 1 },
|
||||
fields: [
|
||||
{
|
||||
path: "award_id",
|
||||
label: "Award",
|
||||
widget: "select",
|
||||
optionsFrom: "awards",
|
||||
required: true,
|
||||
help: "Labelled with the organization that gives it",
|
||||
},
|
||||
{
|
||||
path: "event_id",
|
||||
label: "Presented at",
|
||||
widget: "select",
|
||||
optionsFrom: "events",
|
||||
blankLabel: "— none —",
|
||||
},
|
||||
{ path: "awarded_on", label: "Date", widget: "date" },
|
||||
{ path: "citation", label: "Citation", widget: "textarea", full: true },
|
||||
{ path: "is_public", label: "Public", widget: "checkbox" },
|
||||
],
|
||||
},
|
||||
linksChild,
|
||||
blocksChild,
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Teams ───────────────────────────────────────────────────── */
|
||||
|
||||
const teams = {
|
||||
key: "teams",
|
||||
label: "Teams",
|
||||
singular: "team",
|
||||
idLabel: "Slug",
|
||||
// teams.id is a global primary key, not scoped to the org, so
|
||||
// 'board' can only exist once across the whole site. Composing
|
||||
// the slug from both fields is what keeps NGU's board and the
|
||||
// Northwest's board from colliding.
|
||||
slugFrom: ["org_id", "name"],
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
{ key: "name", label: "Name", primary: true },
|
||||
{ key: "org_id", label: "Organization" },
|
||||
{ key: "tagline", label: "Tagline" },
|
||||
{ key: "is_published", label: "Live", widget: "bool" },
|
||||
],
|
||||
filters: [
|
||||
{ key: "org_id", label: "Organization", optionsFrom: "organizations" },
|
||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||
],
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "Identity",
|
||||
note:
|
||||
"Pick the organization first: the slug is built from it, " +
|
||||
"and it can't be changed once anyone is filed under this team.",
|
||||
fields: [
|
||||
{
|
||||
path: "org_id",
|
||||
label: "Organization",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
required: true,
|
||||
help: "Who this team belongs to",
|
||||
},
|
||||
{ path: "name", label: "Name", required: true, help: "Board, Leadership Team" },
|
||||
{ path: "tagline", label: "Tagline", full: true },
|
||||
{ path: "color", label: "Colour", widget: "color" },
|
||||
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
|
||||
],
|
||||
},
|
||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||
],
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
addLabel: "Add member",
|
||||
note:
|
||||
"This order is the order they appear on the site. Owners are " +
|
||||
"still listed first, in this order among themselves.",
|
||||
title: (row, options) =>
|
||||
[options?.people?.find((p) => p.id === row.person_id)?.label, row.title]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "New member",
|
||||
blank: { person_id: "", role: "member", is_public: 1, is_owner: 0 },
|
||||
fields: [
|
||||
{
|
||||
path: "person_id",
|
||||
label: "Person",
|
||||
widget: "select",
|
||||
optionsFrom: "people",
|
||||
required: true,
|
||||
},
|
||||
...AFFILIATION_ROLE_FIELDS,
|
||||
],
|
||||
},
|
||||
linksChild,
|
||||
blocksChild,
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Awards ──────────────────────────────────────────────────── */
|
||||
|
||||
const awards = {
|
||||
key: "awards",
|
||||
label: "Awards",
|
||||
singular: "award",
|
||||
idLabel: "Slug",
|
||||
slugFrom: "name",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
{ key: "name", label: "Name", primary: true },
|
||||
{ key: "org_id", label: "Awarded by" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "sort_order", label: "Order" },
|
||||
],
|
||||
filters: [
|
||||
{ key: "org_id", label: "Awarded by", optionsFrom: "organizations" },
|
||||
],
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "Identity",
|
||||
note:
|
||||
"An award exists whether or not anyone has received it. " +
|
||||
"Who received it is edited on the person.",
|
||||
fields: [
|
||||
{
|
||||
path: "org_id",
|
||||
label: "Awarded by",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
blankLabel: "— unattributed —",
|
||||
help: "The organization that gives this award",
|
||||
},
|
||||
{ path: "name", label: "Name", required: true },
|
||||
{ path: "description", label: "Description", widget: "textarea", full: true },
|
||||
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
|
||||
{ path: "sort_order", label: "Sort order", widget: "number" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
|
||||
|
||||
export function slugify(value) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 64);
|
||||
}
|
||||
30
src/lib/adminTitle.tsx
Normal file
30
src/lib/adminTitle.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN TITLE
|
||||
|
||||
The layout knows which section you're in; only the page knows
|
||||
which record is on screen. Rather than have both write
|
||||
document.title and race — child effects run before parent
|
||||
effects, so the layout would win and the record name would
|
||||
never survive — the page publishes a string and the layout is
|
||||
the single writer.
|
||||
|
||||
Null is the normal state. A page that publishes nothing, or one
|
||||
still loading, simply leaves the section title showing.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
|
||||
export const AdminTitleContext = createContext(null);
|
||||
|
||||
/* Publish the name of whatever this page is showing. Clears on
|
||||
unmount, so navigating away can't leave a stale record name in
|
||||
the tab. */
|
||||
export function useAdminDetail(name) {
|
||||
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
||||
|
||||
useEffect(() => {
|
||||
if (!setDetail) return undefined;
|
||||
setDetail(name || null);
|
||||
return () => setDetail(null);
|
||||
}, [setDetail, name]);
|
||||
}
|
||||
|
|
@ -100,3 +100,15 @@ export function post(path, data) {
|
|||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function patch(path, data) {
|
||||
return request(path, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function del(path) {
|
||||
return request(path, { method: "DELETE" });
|
||||
}
|
||||
|
|
|
|||
98
src/lib/auth.tsx
Normal file
98
src/lib/auth.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
AUTH CONTEXT
|
||||
|
||||
One fetch of /api/auth/me at mount decides whether anyone is
|
||||
signed in. There's no token to store: the session cookie is
|
||||
HttpOnly, so this code can't read it and neither can anything
|
||||
injected into the page. "Am I signed in" is always a question
|
||||
for the server.
|
||||
|
||||
RequireAuth guards routes. Worth being clear about what that
|
||||
does and doesn't do: it hides the interface, not the data. The
|
||||
protection is the 401 from /api/admin — this only stops someone
|
||||
staring at an empty table.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { get, post, ApiError } from "./api.js";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
get("/auth/me", { ttl: 0 })
|
||||
.then((data) => {
|
||||
if (!ignore) setUser(data.user);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!ignore) setUser(null); // 401 is the normal case
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const data = await post("/auth/login", { email, password });
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await post("/auth/logout", {});
|
||||
} finally {
|
||||
// Whatever the server said, this browser is done.
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) throw new Error("useAuth used outside AuthProvider");
|
||||
return value;
|
||||
}
|
||||
|
||||
/* Signals a session that ended while the page was open — the
|
||||
admin pages call this when a request comes back 401. */
|
||||
export function isUnauthorized(error) {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
export function RequireAuth() {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-[#4a6b72]">
|
||||
Checking your session…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// `from` lets the login page send them back where they aimed.
|
||||
return <Navigate to="/admin/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -1,63 +1,6 @@
|
|||
import PageShell from "../components/PageShell.tsx";
|
||||
import PeopleTiles from "../components/PeopleTiles.tsx";
|
||||
|
||||
// Shape reference for <PeopleTiles />. Swap for an API/SQLite fetch when ready —
|
||||
// the component only cares about the shape, not where it came from.
|
||||
|
||||
export const leadershipGroups = [
|
||||
{
|
||||
id: "director",
|
||||
label: "Retreat Director",
|
||||
accent: "#138ba0",
|
||||
people: [
|
||||
{
|
||||
id: 1,
|
||||
name: "John Doe",
|
||||
title: "Retreat Director",
|
||||
photo: "/people/jordan-ellis.jpg",
|
||||
pronouns: "he/him",
|
||||
birthdate: "1994-03-08",
|
||||
org: { name: "Unity of Des Moines", href: "https://example.org" },
|
||||
bio: [
|
||||
"John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
|
||||
"He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
label: "Retreat Team",
|
||||
people: [
|
||||
{
|
||||
id: 2,
|
||||
name: "Priya Raman",
|
||||
title: "Events Lead",
|
||||
photo: "/people/priya-raman.jpg",
|
||||
pronouns: "she/her",
|
||||
age: 27,
|
||||
org: "Unity Chicago",
|
||||
bio: "Priya plans the summer retreat schedule and handles venue contracts.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Sam Okafor",
|
||||
title: "Communications",
|
||||
pronouns: "they/them",
|
||||
age: 24,
|
||||
org: "Unity of Milwaukee",
|
||||
bio: "Sam writes the regional newsletter and keeps chapter pages current.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const board = [
|
||||
{ id: 10, name: "Jack Doe", title: "NGU Board President" },
|
||||
{ id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
|
||||
{ id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "board-staff",
|
||||
|
|
@ -67,7 +10,7 @@ const SECTIONS = [
|
|||
background: "#eef9fb",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<PeopleTiles size="sm" people={board} overflow="scroll" />
|
||||
<PeopleTiles size="lg" teams={{ id: "ngu-board",}} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -79,7 +22,7 @@ const SECTIONS = [
|
|||
background: "#ffffff",
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<PeopleTiles size="lg" groups={leadershipGroups} />
|
||||
<PeopleTiles size="lg" teams="ngu-retreat-team" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
315
src/pages/admin/AdminFeedback.tsx
Normal file
315
src/pages/admin/AdminFeedback.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN — FEEDBACK TRIAGE
|
||||
|
||||
Reads /api/admin/feedback, writes status and notes back through
|
||||
PATCH. Deliberately a flat list rather than a table: the message
|
||||
is the content, and messages don't fit in a cell.
|
||||
|
||||
Every read passes ttl: 0. The api cache exists for public
|
||||
content that changes weekly; a triage queue two people are
|
||||
working at the same time is the opposite case.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { get, patch, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
|
||||
const STATUS_STYLE = {
|
||||
new: "bg-[#138ba0] text-white",
|
||||
read: "bg-[#eef9fb] text-[#138ba0]",
|
||||
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
|
||||
archived: "bg-[#4a6b72]/10 text-[#4a6b72]",
|
||||
spam: "bg-[#fdf3f2] text-[#b3261e]",
|
||||
};
|
||||
|
||||
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
|
||||
// won't parse without the T and the Z.
|
||||
function formatDate(value) {
|
||||
const date = new Date(`${value.replace(" ", "T")}Z`);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function locationOf(row) {
|
||||
if (!row.page_path) return "Not page-specific";
|
||||
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
|
||||
}
|
||||
|
||||
/* ── One submission ──────────────────────────────────────────── */
|
||||
|
||||
function FeedbackCard({ row, onChange, canWrite }) {
|
||||
const [note, setNote] = useState(row.admin_note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const noteDirty = note !== (row.admin_note ?? "");
|
||||
|
||||
async function save(changes) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await patch(`/admin/feedback/${row.id}`, changes);
|
||||
onChange(data.feedback);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||
<span className="font-semibold text-[#26454c]">
|
||||
{feedbackTypeLabel(row.feedback_type)}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
STATUS_STYLE[row.status] ?? ""
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
<span className="text-[#4a6b72]">{locationOf(row)}</span>
|
||||
<span className="ml-auto text-xs text-[#4a6b72]">
|
||||
#{row.id} · {formatDate(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 whitespace-pre-wrap text-[#26454c]">{row.message}</p>
|
||||
|
||||
<p className="mt-4 text-sm text-[#4a6b72]">
|
||||
{row.name || row.email ? (
|
||||
<>
|
||||
{row.name && <span>{row.name}</span>}
|
||||
{row.name && row.email && " · "}
|
||||
{row.email && (
|
||||
<a
|
||||
href={`mailto:${row.email}?subject=Your%20NGU%20site%20feedback`}
|
||||
className="text-[#138ba0] underline underline-offset-2"
|
||||
>
|
||||
{row.email}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic">Sent anonymously</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{canWrite && (
|
||||
<div className="mt-5 border-t border-[#4a6b72]/15 pt-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
htmlFor={`status-${row.id}`}
|
||||
className="text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id={`status-${row.id}`}
|
||||
value={row.status}
|
||||
disabled={busy}
|
||||
onChange={(e) => save({ status: e.target.value })}
|
||||
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
disabled={busy}
|
||||
placeholder="Internal note — who's handling it, what was done"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="mt-3 w-full resize-y rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
/>
|
||||
{noteDirty && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => save({ admin_note: note })}
|
||||
className="mt-2 rounded-full bg-[#138ba0] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{busy ? "Saving…" : "Save note"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The page ────────────────────────────────────────────────── */
|
||||
|
||||
export default function AdminFeedback() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState("new");
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState(""); // applied, not typed
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [counts, setCounts] = useState({});
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const canWrite = user?.role === "admin";
|
||||
|
||||
const load = useCallback(
|
||||
async (before = null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (search) params.set("q", search);
|
||||
if (before) params.set("before", String(before));
|
||||
|
||||
try {
|
||||
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
||||
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
||||
setCounts(data.counts);
|
||||
setCursor(data.nextCursor);
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) {
|
||||
// Session expired while the page was open.
|
||||
navigate("/admin/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[status, search, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function replaceRow(updated) {
|
||||
setRows((prev) =>
|
||||
prev
|
||||
.map((row) => (row.id === updated.id ? updated : row))
|
||||
// A row that no longer matches the filter drops out, so
|
||||
// marking something 'read' clears it from the 'new' queue.
|
||||
.filter((row) => status === "all" || row.status === status),
|
||||
);
|
||||
setCounts((prev) => ({ ...prev })); // counts refresh on next load
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "all", label: "All" },
|
||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[#138ba0]">Feedback</h1>
|
||||
<p className="mt-2 text-sm text-[#4a6b72]">
|
||||
{canWrite
|
||||
? "Everything submitted through the site form."
|
||||
: "Read-only: your account can't change statuses or notes."}
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setStatus(tab.id)}
|
||||
className={
|
||||
"rounded-full px-4 py-1.5 text-sm transition-colors " +
|
||||
(status === tab.id
|
||||
? "bg-[#138ba0] font-semibold text-white"
|
||||
: "border border-[#4a6b72]/25 text-[#4a6b72] hover:border-[#138ba0]/50")
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count ? ` (${tab.count})` : ""}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search messages"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSearch(query.trim());
|
||||
}}
|
||||
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch(query.trim())}
|
||||
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#138ba0]/50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && !error && (
|
||||
<p className="mt-10 text-[#4a6b72]">
|
||||
Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{rows.map((row) => (
|
||||
<FeedbackCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
canWrite={canWrite}
|
||||
onChange={replaceRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <p className="mt-6 text-sm text-[#4a6b72]">Loading…</p>}
|
||||
|
||||
{cursor && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(cursor)}
|
||||
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
Load older
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/pages/admin/AdminLayout.tsx
Normal file
160
src/pages/admin/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN LAYOUT
|
||||
|
||||
Bare on purpose. No PageShell, no announcement banner, no
|
||||
footer site map — none of that belongs around a staff tool, and
|
||||
/admin should never appear in navConfig.
|
||||
|
||||
The nav is two tiers, the same shape as the public header: a
|
||||
primary row of the things you'd go looking for, and a subnav of
|
||||
whatever sits under the one you're in. Teams and Awards live
|
||||
under Organizations because that's where they belong
|
||||
conceptually — a team is part of an org, an award is given by
|
||||
one — even though each is its own table and its own page.
|
||||
|
||||
NAV is the single source for the rows, the document title and
|
||||
which tab lights up. Adding an entity is an entry here plus a
|
||||
descriptor; there's no second list to keep in step.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
|
||||
|
||||
const SITE_TITLE = "NGU Admin CMS";
|
||||
|
||||
// A group with no `to` of its own opens its first child, so
|
||||
// clicking the word Forms goes somewhere rather than nowhere.
|
||||
const NAV = [
|
||||
{ to: "/admin/events", label: "Events" },
|
||||
{
|
||||
to: "/admin/organizations",
|
||||
label: "Organizations",
|
||||
children: [
|
||||
{ to: "/admin/organizations", label: "All organizations" },
|
||||
{ to: "/admin/teams", label: "Teams" },
|
||||
{ to: "/admin/awards", label: "Awards" },
|
||||
],
|
||||
},
|
||||
{ to: "/admin/people", label: "People" },
|
||||
{
|
||||
label: "Forms",
|
||||
separated: true,
|
||||
children: [{ to: "/admin/feedback", label: "Website feedback" }],
|
||||
},
|
||||
];
|
||||
|
||||
// A tab owns its own page and everything below it, so editing
|
||||
// /admin/teams/ngu-board keeps Teams lit.
|
||||
const matches = (pathname, to) =>
|
||||
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
||||
|
||||
const target = (item) => item.to ?? item.children?.[0]?.to;
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = NAV.find(
|
||||
(item) =>
|
||||
matches(pathname, item.to) ||
|
||||
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
||||
);
|
||||
|
||||
const activeChild = (active?.children ?? []).find((child) =>
|
||||
matches(pathname, child.to),
|
||||
);
|
||||
|
||||
// What the page below has published about itself — a record
|
||||
// name, or null on a list. setDetail is stable so publishing
|
||||
// can't loop.
|
||||
const [detail, setDetail] = useState(null);
|
||||
const stableSet = useCallback((value) => setDetail(value), []);
|
||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||
|
||||
useEffect(() => {
|
||||
const section = activeChild?.label ?? active?.label;
|
||||
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
|
||||
}, [active, activeChild, detail]);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/admin/login", { replace: true });
|
||||
}
|
||||
|
||||
const subnav = active?.children ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f6fbfc]">
|
||||
<header className="border-b border-[#138ba0]/20 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
|
||||
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span>
|
||||
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
{NAV.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-6">
|
||||
{item.separated && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-px bg-[#4a6b72]/25"
|
||||
/>
|
||||
)}
|
||||
<NavLink
|
||||
to={target(item)}
|
||||
className={
|
||||
item === active
|
||||
? "font-semibold text-[#138ba0]"
|
||||
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
|
||||
<span>{user?.name || user?.email}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="rounded-full border border-[#4a6b72]/30 px-4 py-1.5 font-medium transition-colors hover:bg-[#eef9fb] hover:text-[#138ba0]"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subnav. Only drawn where there is something to draw, so
|
||||
Events and People don't get an empty grey strip. */}
|
||||
{subnav.length > 0 && (
|
||||
<div className="border-t border-[#138ba0]/10 bg-[#f6fbfc]">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap gap-6 px-6 py-2.5 text-sm">
|
||||
{subnav.map((child) => (
|
||||
<NavLink
|
||||
key={child.to}
|
||||
to={child.to}
|
||||
className={
|
||||
child === activeChild
|
||||
? "font-semibold text-[#138ba0]"
|
||||
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
|
||||
}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<AdminTitleContext.Provider value={titleContext}>
|
||||
<Outlet />
|
||||
</AdminTitleContext.Provider>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/pages/admin/AdminLogin.tsx
Normal file
142
src/pages/admin/AdminLogin.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN LOGIN
|
||||
|
||||
Deliberately outside PageShell and the site nav. This isn't a
|
||||
page of the website; it's the door to the back office, and it
|
||||
shouldn't carry a banner, a subnav, or a footer site map.
|
||||
|
||||
The Google button is stubbed and disabled until the OAuth
|
||||
routes exist. It's here so the layout doesn't change when it
|
||||
starts working.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { ApiError } from "../../lib/api.js";
|
||||
|
||||
const GOOGLE_ENABLED = false;
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
|
||||
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destination = location.state?.from?.pathname ?? "/admin/feedback";
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
navigate(destination, { replace: true });
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Couldn't reach the server. Try again in a moment.",
|
||||
);
|
||||
setPassword("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#eef9fb] px-6 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-3xl font-bold text-[#138ba0]">NGU admin</h1>
|
||||
<p className="mt-2 text-sm text-[#4a6b72]">
|
||||
Sign in to read and triage site feedback.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="mt-8 rounded-2xl border border-[#138ba0]/20 bg-white p-6"
|
||||
>
|
||||
<label
|
||||
htmlFor="admin-email"
|
||||
className="block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="admin-password"
|
||||
className="mt-5 block text-sm font-medium text-[#26454c]"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-2 ${fieldClass}`}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-5 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email || !password}
|
||||
className="mt-6 w-full rounded-full bg-[#138ba0] px-6 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
|
||||
{GOOGLE_ENABLED && (
|
||||
<>
|
||||
<div className="my-6 flex items-center gap-3 text-xs text-[#4a6b72]">
|
||||
<span className="h-px flex-1 bg-[#4a6b72]/20" />
|
||||
or
|
||||
<span className="h-px flex-1 bg-[#4a6b72]/20" />
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/google"
|
||||
className="block rounded-full border border-[#4a6b72]/30 px-6 py-3 text-center font-semibold text-[#26454c] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
Continue with Google
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-[#4a6b72]">
|
||||
Accounts are created on the server. Ask whoever runs the box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
src/pages/admin/EntityEdit.tsx
Normal file
422
src/pages/admin/EntityEdit.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN — ENTITY EDIT
|
||||
|
||||
Create and edit for every entity in the manifest. The form holds
|
||||
the whole nested object — parent row, side tables, child
|
||||
collections — and PATCH sends the lot. The server replaces
|
||||
children wholesale, so what you see here is exactly what will
|
||||
exist afterwards.
|
||||
|
||||
Nothing is written until Save, which is what makes removing a
|
||||
repeater row safe: leaving without saving undoes it. That only
|
||||
holds if leaving is hard to do by accident, hence the dirty
|
||||
tracking and the two guards below.
|
||||
|
||||
A field the form never sets is left out of the payload entirely,
|
||||
and the server lets the column's own DEFAULT apply. So a blank
|
||||
new-record form is deliberate, not lazy — writing "" into every
|
||||
field is what used to turn a default into a constraint failure.
|
||||
|
||||
updated_at rides along untouched. If someone else saved while
|
||||
this page was open the server answers 409 rather than letting
|
||||
one of you quietly overwrite the other. Entities with no
|
||||
updated_at column simply never get one, and the 409 path stays
|
||||
dormant for them.
|
||||
|
||||
slugFrom may name one field or several. Most ids are unique
|
||||
because the name is: two organizations aren't both called
|
||||
Northwest. Team ids are the exception — teams.id is a global
|
||||
primary key, so every org's 'Board' would collide — which is
|
||||
why the array form exists and teams uses it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||
|
||||
/* A foreign key refusing to budge is the most common way a save or
|
||||
delete fails here, and SQLite's own wording explains nothing to
|
||||
whoever is filling in the form. */
|
||||
function friendly(message, singular) {
|
||||
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
|
||||
return `Something still points at this ${singular}. Reassign or remove those first.`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export default function EntityEdit() {
|
||||
const { entity: entityKey, id } = useParams();
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const isNew = id === "new";
|
||||
const canWrite = user?.role === "admin";
|
||||
|
||||
// Hoisted above the loading guards: the title hook below is a
|
||||
// hook, so it can't sit after an early return, and it needs the
|
||||
// same paths the heading uses.
|
||||
const slugPaths = Array.isArray(manifest?.slugFrom)
|
||||
? manifest.slugFrom
|
||||
: [manifest?.slugFrom].filter(Boolean);
|
||||
|
||||
// Everything before the last path is a qualifier: a fact about
|
||||
// another field rather than something to type. It renders as
|
||||
// fixed text inside the slug box, and the editable part is only
|
||||
// what follows it.
|
||||
const qualifierPaths = slugPaths.slice(0, -1);
|
||||
|
||||
// Empty until every qualifier is chosen, because half a prefix
|
||||
// would be saved into an id that then never matches.
|
||||
const prefixOf = (source) => {
|
||||
if (qualifierPaths.length === 0) return "";
|
||||
const parts = qualifierPaths.map((path) => getPath(source, path));
|
||||
if (parts.some((part) => !part)) return "";
|
||||
return `${slugify(parts.join(" "))}-`;
|
||||
};
|
||||
|
||||
// What to show greyed out before then: org_id becomes "org-".
|
||||
const prefixHint = qualifierPaths.length
|
||||
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
|
||||
: "";
|
||||
|
||||
const tailOf = (source) => {
|
||||
const prefix = prefixOf(source);
|
||||
const value = source?.id ?? "";
|
||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
};
|
||||
|
||||
// The heading wants the specific half, not the qualifier: a team
|
||||
// page reads "Board", not "northwest Board".
|
||||
const headingPath = slugPaths[slugPaths.length - 1];
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [options, setOptions] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [message, setMessage] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
// The last state the server confirmed. Everything else compares
|
||||
// against this to decide whether there's anything to lose.
|
||||
const baseline = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const opts = await get("/admin/options", { ttl: 60_000 });
|
||||
setOptions(opts.options);
|
||||
|
||||
if (isNew) {
|
||||
// Blank children arrays matter: an absent key means "don't
|
||||
// touch", which is wrong for a row that doesn't exist yet.
|
||||
// The parent's own fields stay absent on purpose so the
|
||||
// server's column defaults apply to whatever isn't filled in.
|
||||
const blank = { id: "" };
|
||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||
setForm(blank);
|
||||
baseline.current = JSON.stringify(blank);
|
||||
} else {
|
||||
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
}
|
||||
setMessage(null);
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setMessage({
|
||||
tone: "error",
|
||||
text: err instanceof ApiError ? err.message : "Couldn't load that.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [manifest, id, isNew, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => Boolean(form) && JSON.stringify(form) !== baseline.current,
|
||||
[form],
|
||||
);
|
||||
|
||||
// The tab says what's on screen; the layout adds the section and
|
||||
// the site name. Null while loading, so it reads "Teams | NGU
|
||||
// Admin CMS" for the half-second before the record arrives
|
||||
// rather than flashing a slug.
|
||||
useAdminDetail(
|
||||
!manifest
|
||||
? null
|
||||
: isNew
|
||||
? `New ${manifest.singular}`
|
||||
: form
|
||||
? getPath(form, headingPath) || form.id
|
||||
: null,
|
||||
);
|
||||
|
||||
// Closing the tab or hitting the browser back button skips React
|
||||
// Router entirely, so the only hook available is this one.
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const warn = (event) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
|
||||
if (!manifest) return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
|
||||
|
||||
/* ── Heading ───────────────────────────────────────────────── */
|
||||
|
||||
const heading = getPath(form, headingPath) || form.id;
|
||||
|
||||
const children = manifest.children ?? [];
|
||||
|
||||
/* ── Actions ───────────────────────────────────────────────── */
|
||||
|
||||
const leave = (to) => {
|
||||
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const change = (path, value) => {
|
||||
setForm((prev) => {
|
||||
let next = setPath(prev, path, value);
|
||||
// Recompose the id whenever one of its sources moves. The
|
||||
// prefix always follows the organization — picking a
|
||||
// different one has to change the slug, or it would claim a
|
||||
// team belongs somewhere it doesn't. The tail only follows
|
||||
// the name until someone types over it.
|
||||
if (isNew && slugPaths.includes(path)) {
|
||||
const tail = slugTouched
|
||||
? tailOf(prev)
|
||||
: slugify(getPath(next, headingPath) ?? "");
|
||||
next = { ...next, id: `${prefixOf(next)}${tail}` };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Clear this field's error as soon as it's touched; leaving a
|
||||
// stale red outline on a field the user just fixed reads as a
|
||||
// save that didn't take.
|
||||
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
setMessage(null);
|
||||
try {
|
||||
const data = isNew
|
||||
? await post(`/admin/${manifest.key}`, form)
|
||||
: await patch(`/admin/${manifest.key}/${id}`, form);
|
||||
|
||||
setForm(data.row);
|
||||
baseline.current = JSON.stringify(data.row);
|
||||
setMessage({ tone: "ok", text: "Saved." });
|
||||
|
||||
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
|
||||
if (err instanceof ApiError) {
|
||||
// 409 means the updated_at we're holding is stale. Every
|
||||
// further save will fail the same way until the page is
|
||||
// reloaded, so offer that rather than just saying no.
|
||||
if (err.status === 409) {
|
||||
setMessage({ tone: "error", text: err.message, recover: "reload" });
|
||||
} else {
|
||||
setErrors(err.fields ?? {});
|
||||
setMessage({
|
||||
tone: "error",
|
||||
text: err.fields
|
||||
? "Some fields need attention."
|
||||
: friendly(err.message, manifest.singular),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setMessage({ tone: "error", text: "Couldn't reach the server." });
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
|
||||
|
||||
try {
|
||||
await del(`/admin/${manifest.key}/${id}`);
|
||||
baseline.current = JSON.stringify(form); // nothing left to warn about
|
||||
navigate(`/admin/${manifest.key}`, { replace: true });
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
tone: "error",
|
||||
text:
|
||||
err instanceof ApiError
|
||||
? friendly(err.message, manifest.singular)
|
||||
: "Couldn't delete that.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visible = (when) => !when || getPath(form, when.path) === when.value;
|
||||
|
||||
return (
|
||||
<div className="pb-24">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => leave(`/admin/${manifest.key}`)}
|
||||
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
|
||||
>
|
||||
← {manifest.label}
|
||||
</button>
|
||||
|
||||
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
|
||||
{isNew ? `New ${manifest.singular}` : heading}
|
||||
</h1>
|
||||
|
||||
{/* Slug */}
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<Field
|
||||
field={{
|
||||
path: "id",
|
||||
label: manifest.idLabel,
|
||||
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
|
||||
prefixPending: isNew && !prefixOf(form),
|
||||
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
|
||||
readOnly: !isNew,
|
||||
help: !isNew
|
||||
? "Fixed once created — links and content blocks reference it."
|
||||
: qualifierPaths.length
|
||||
? "The prefix comes from the organization. Type the rest."
|
||||
: "Lowercase, hyphens, no spaces. Can't be changed later.",
|
||||
}}
|
||||
value={isNew ? tailOf(form) : form.id}
|
||||
error={errors.id}
|
||||
onChange={(value) => {
|
||||
setSlugTouched(true);
|
||||
change("id", `${prefixOf(form)}${slugify(value)}`);
|
||||
}}
|
||||
/>
|
||||
{!isNew && form.updated_at && (
|
||||
<p className="mt-2 text-xs text-[#4a6b72]">
|
||||
Last saved {form.updated_at}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field groups */}
|
||||
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
|
||||
<section
|
||||
key={group.legend}
|
||||
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
|
||||
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
|
||||
<div className="mt-4">
|
||||
<FieldGrid>
|
||||
{group.fields.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
value={getPath(form, field.path)}
|
||||
options={options}
|
||||
error={errors[field.path]}
|
||||
onChange={(value) => change(field.path, value)}
|
||||
/>
|
||||
))}
|
||||
</FieldGrid>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Child collections. Awards own none, so the panel would be
|
||||
an empty white box — skip it rather than render it. */}
|
||||
{children.some((child) => visible(child.when)) && (
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
{children
|
||||
.filter((child) => visible(child.when))
|
||||
.map((child) => (
|
||||
<Repeater
|
||||
key={child.key}
|
||||
spec={child}
|
||||
rows={form[child.key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
errorPrefix={`${child.key}.`}
|
||||
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky action bar */}
|
||||
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
|
||||
{canWrite ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-[#4a6b72]">
|
||||
Read-only: your account can't save changes.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{dirty && !saving && (
|
||||
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<span
|
||||
role="status"
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
{message.recover === "reload" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/pages/admin/EntityList.tsx
Normal file
181
src/pages/admin/EntityList.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN — ENTITY LIST
|
||||
|
||||
One component for organizations, events and people. The :entity
|
||||
route param picks the manifest; nothing here knows what a
|
||||
chapter or a retreat is.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { get, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
||||
|
||||
export default function EntityList() {
|
||||
const { entity: entityKey } = useParams();
|
||||
const manifest = ADMIN_ENTITIES[entityKey];
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [options, setOptions] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
|
||||
const canWrite = user?.role === "admin";
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [list, opts] = await Promise.all([
|
||||
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
|
||||
get("/admin/options", { ttl: 60_000 }),
|
||||
]);
|
||||
setRows(list.rows);
|
||||
setOptions(opts.options);
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't reach the server.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [manifest, params, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (!manifest) {
|
||||
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||
}
|
||||
|
||||
function setParam(key, value) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
function labelFor(filter, value) {
|
||||
if (filter.optionsFrom) {
|
||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||
}
|
||||
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<h1 className="text-3xl font-bold text-[#138ba0]">{manifest.label}</h1>
|
||||
{canWrite && (
|
||||
<Link
|
||||
to={`/admin/${manifest.key}/new`}
|
||||
className="ml-auto rounded-full bg-[#138ba0] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183]"
|
||||
>
|
||||
New {manifest.singular}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-6 flex flex-wrap items-end gap-3">
|
||||
{manifest.list.filters.map((filter) => (
|
||||
<label key={filter.key} className="text-xs text-[#4a6b72]">
|
||||
<span className="block">{filter.label}</span>
|
||||
<select
|
||||
value={params.get(filter.key) ?? ""}
|
||||
onChange={(e) => setParam(filter.key, e.target.value)}
|
||||
className="mt-1 rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{labelFor(filter).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setParam("q", query.trim())}
|
||||
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParam("q", query.trim())}
|
||||
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] hover:border-[#138ba0]/50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Rows */}
|
||||
<div className="mt-6 overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-[#4a6b72]/15 text-xs text-[#4a6b72]">
|
||||
<tr>
|
||||
{manifest.list.columns.map((column) => (
|
||||
<th key={column.key} className="px-4 py-3 font-medium">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 font-medium">Slug</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
|
||||
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
|
||||
>
|
||||
{manifest.list.columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-4 py-3 ${
|
||||
column.primary ? "font-medium text-[#26454c]" : "text-[#4a6b72]"
|
||||
}`}
|
||||
>
|
||||
{column.widget === "bool"
|
||||
? row[column.key]
|
||||
? "Yes"
|
||||
: "—"
|
||||
: row[column.key] || "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-[#4a6b72]">Nothing matches.</p>
|
||||
)}
|
||||
{loading && <p className="px-4 py-8 text-center text-[#4a6b72]">Loading…</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
import { useState } from "react";
|
||||
import { post, ApiError } from "../../lib/api.js";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
||||
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
||||
|
||||
const ACCENT = "#138ba0";
|
||||
const MUTED = "#4a6b72";
|
||||
|
|
@ -25,39 +26,6 @@ const SITE_WIDE = "site";
|
|||
// Sentinel for "this page, but not one section of it".
|
||||
const WHOLE_PAGE = "";
|
||||
|
||||
// Ids must match TYPES in server/src/routes/feedback.js.
|
||||
const FEEDBACK_TYPES = [
|
||||
{
|
||||
id: "broken",
|
||||
label: "Something's broken",
|
||||
hint: "A link, image, or button that doesn't work",
|
||||
},
|
||||
{
|
||||
id: "confusing",
|
||||
label: "Hard to use",
|
||||
hint: "Something you couldn't find or follow",
|
||||
},
|
||||
{
|
||||
id: "outdated",
|
||||
label: "Wrong or missing info",
|
||||
hint: "Old dates, typos, an event that isn't listed",
|
||||
},
|
||||
{
|
||||
id: "request",
|
||||
label: "Feature request",
|
||||
hint: "Something you'd like the site to do",
|
||||
},
|
||||
{
|
||||
id: "praise",
|
||||
label: "Kind words",
|
||||
hint: "Tell us what's working well",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
hint: "Anything that doesn't fit the boxes above",
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Shared field chrome ─────────────────────────────────────── */
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue