v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
26
src/App.tsx
26
src/App.tsx
|
|
@ -4,6 +4,7 @@ import Layout from "./components/Layout.tsx";
|
|||
|
||||
/*Libraries*/
|
||||
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
|
||||
import RequireRole from "./pages/admin/RequireRole.tsx";
|
||||
|
||||
/*Primary Pages*/
|
||||
import Home from "./pages/Home.tsx";
|
||||
|
|
@ -11,14 +12,23 @@ import Retreats from "./pages/Retreats.tsx";
|
|||
import Community from "./pages/Community.tsx";
|
||||
import Leadership from "./pages/Leadership.tsx";
|
||||
import Resources from "./pages/Resources.tsx";
|
||||
import History from "./pages/History.tsx";
|
||||
|
||||
/*Secondary Pages*/
|
||||
import Feedback from "./pages/Feedback.tsx";
|
||||
import Giving from "./pages/Giving.tsx";
|
||||
|
||||
/*Entity Pages*/
|
||||
import EventDetail from './pages/EventDetail.tsx'
|
||||
import OrganizationDetail from './pages/OrganizationDetail.tsx'
|
||||
import TeamDetail from './pages/TeamDetail.tsx'
|
||||
import AwardDetail from './pages/AwardDetail.tsx'
|
||||
|
||||
/*Admin Pages*/
|
||||
import AdminLayout from "./pages/admin/AdminLayout.tsx";
|
||||
import AdminLogin from "./pages/admin/AdminLogin.tsx";
|
||||
import AdminPanel from "./pages/admin/AdminPanel.tsx";
|
||||
import AdminHome from "./pages/admin/AdminHome.tsx";
|
||||
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
|
||||
import EntityList from "./pages/admin/EntityList.tsx";
|
||||
import EntityEdit from "./pages/admin/EntityEdit.tsx";
|
||||
|
|
@ -36,10 +46,18 @@ export default function App() {
|
|||
<Route element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="retreats" element={<Retreats />} />
|
||||
<Route path="/events/:id" element={<EventDetail />} />
|
||||
<Route path="community" element={<Community />} />
|
||||
<Route path="/regions/:id" element={<OrganizationDetail />} />
|
||||
<Route path="/chapters/:id" element={<OrganizationDetail />} />
|
||||
<Route path="/partners/:id" element={<OrganizationDetail />} />
|
||||
<Route path="/organizations/:id" element={<OrganizationDetail />} />
|
||||
<Route path="/teams/:id" element={<TeamDetail />} />
|
||||
<Route path="/awards/:id" element={<AwardDetail />} />
|
||||
<Route path="leadership" element={<Leadership />} />
|
||||
<Route path="resources" element={<Resources />} />
|
||||
<Route path="feedback" element={<Feedback />} />
|
||||
<Route path="history" element={<History />} />
|
||||
<Route path="feedback" element={<Feedback />} />
|
||||
<Route path="give" element={<Giving />} />
|
||||
<Route path="privacy" element={<Privacy />} />
|
||||
<Route path="terms" element={<Terms />} />
|
||||
|
|
@ -49,7 +67,11 @@ export default function App() {
|
|||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/feedback" replace />} />
|
||||
<Route index element={<Navigate to="/admin/home" replace />} />
|
||||
<Route path="home" element={<AdminHome />} />
|
||||
<Route element={<RequireRole role="superadmin" />}>
|
||||
<Route path="panel" element={<AdminPanel />} />
|
||||
</Route>
|
||||
<Route path="feedback" element={<AdminFeedback />} />
|
||||
<Route path=":entity" element={<EntityList />} />
|
||||
<Route path=":entity/:id" element={<EntityEdit />} />
|
||||
|
|
|
|||
189
src/components/ContentBlocks.tsx
Normal file
189
src/components/ContentBlocks.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
CONTENT BLOCKS
|
||||
|
||||
Renders the `body` slot of content_blocks. Events, organizations
|
||||
and teams all own blocks and all render them the same way, so
|
||||
this is written once and tinted by the caller's accent.
|
||||
|
||||
⚠ Assumes shape.js's loadBlocks returns rows carrying `type`,
|
||||
`text`, `media`, `href` and an `items` array. If the field names
|
||||
differ, this is the only file to fix — nothing else reads a
|
||||
block.
|
||||
|
||||
An unknown `type` renders its text as a paragraph rather than
|
||||
disappearing. A block somebody typed into the admin should be
|
||||
visible even if the renderer hasn't caught up with it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { blockMedia } from '../lib/media.ts'
|
||||
import type { ContentBlock } from '../lib/useContent.ts'
|
||||
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
/** External if it has a scheme or starts with //; otherwise it's
|
||||
* one of our own routes and should go through the router. */
|
||||
const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)
|
||||
|
||||
function Anchor({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
if (isExternal(href)) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" className={className} style={style}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link to={href} className={className} style={style}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContentBlocks({
|
||||
blocks,
|
||||
accent = '#138ba0',
|
||||
className = '',
|
||||
}: {
|
||||
blocks?: ContentBlock[] | null
|
||||
accent?: string
|
||||
className?: string
|
||||
}) {
|
||||
if (!blocks?.length) return null
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{blocks.map((block, index) => (
|
||||
<Block key={block.id ?? index} block={block} accent={accent} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Block({ block, accent }: { block: ContentBlock; accent: string }) {
|
||||
const text = block.text ?? ''
|
||||
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return (
|
||||
<h3 className="text-2xl font-bold" style={{ color: accent }}>
|
||||
{text}
|
||||
</h3>
|
||||
)
|
||||
|
||||
case 'subheading':
|
||||
return (
|
||||
<h4 className="text-lg font-semibold" style={{ color: accent }}>
|
||||
{text}
|
||||
</h4>
|
||||
)
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{(block.items ?? []).map((item, index) => (
|
||||
<li key={index} className="flex gap-3">
|
||||
<span aria-hidden="true" style={{ color: accent }}>
|
||||
•
|
||||
</span>
|
||||
<span style={{ color: BODY }}>
|
||||
{item.url ? (
|
||||
<Anchor href={item.url} className="underline" style={{ color: accent }}>
|
||||
{item.text}
|
||||
</Anchor>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
{item.detail && (
|
||||
<span className="opacity-70"> — {item.detail}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
|
||||
case 'links':
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(block.items ?? [])
|
||||
.filter((item) => item.url)
|
||||
.map((item, index) => (
|
||||
<Anchor
|
||||
key={index}
|
||||
href={item.url as string}
|
||||
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
{item.text}
|
||||
</Anchor>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'quote':
|
||||
return (
|
||||
<blockquote
|
||||
className="border-l-2 pl-5 text-lg italic"
|
||||
style={{ borderColor: accent, color: BODY }}
|
||||
>
|
||||
{text}
|
||||
</blockquote>
|
||||
)
|
||||
|
||||
case 'image': {
|
||||
const src = blockMedia(block.media)
|
||||
if (!src) return null
|
||||
const img = (
|
||||
<img
|
||||
src={src}
|
||||
// A caption is a caption, not alt text — but an image with
|
||||
// neither is decorative, and alt="" is the correct answer
|
||||
// for that rather than a filename read aloud.
|
||||
alt={text}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<figure>
|
||||
{block.href ? <Anchor href={block.href}>{img}</Anchor> : img}
|
||||
{text && (
|
||||
<figcaption className="mt-2 text-sm opacity-70" style={{ color: BODY }}>
|
||||
{text}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
case 'divider':
|
||||
return <hr className="border-0 h-px" style={{ background: accent, opacity: 0.3 }} />
|
||||
|
||||
case 'paragraph':
|
||||
default:
|
||||
if (!text) return null
|
||||
return (
|
||||
<p className="leading-relaxed" style={{ color: BODY }}>
|
||||
{block.href ? (
|
||||
<Anchor href={block.href} className="underline" style={{ color: accent }}>
|
||||
{text}
|
||||
</Anchor>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
91
src/components/PageState.tsx
Normal file
91
src/components/PageState.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAGE STATE
|
||||
|
||||
The three ways a detail page can fail to be a detail page. All
|
||||
four of them need the same thing, and it should be the same thing
|
||||
— a visitor who hits a dead retreat link and a dead chapter link
|
||||
shouldn't get two different pages.
|
||||
|
||||
Rendered inside PageShell so the chrome doesn't flicker in and
|
||||
out between loading and loaded.
|
||||
|
||||
A 404 gets no retry button: the slug doesn't exist and trying
|
||||
again won't change that. Anything else does, because a dropped
|
||||
connection is the usual cause and one tap fixes it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import PageShell from './PageShell.tsx'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
export default function PageState({
|
||||
loading,
|
||||
error,
|
||||
notFound,
|
||||
onRetry,
|
||||
noun,
|
||||
backTo,
|
||||
backLabel,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
notFound: boolean
|
||||
onRetry: () => void
|
||||
/** Lowercase, as it appears mid-sentence: "retreat", "chapter". */
|
||||
noun: string
|
||||
backTo: string
|
||||
backLabel: string
|
||||
}) {
|
||||
let title: string
|
||||
let content: React.ReactNode
|
||||
|
||||
if (notFound) {
|
||||
title = 'Not found'
|
||||
content = (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<p style={{ color: BODY }}>
|
||||
There’s no {noun} at this address. It may have been renamed, or taken
|
||||
down.
|
||||
</p>
|
||||
<Link
|
||||
to={backTo}
|
||||
className="mt-4 inline-block rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||
style={{ borderColor: TEAL, color: TEAL }}
|
||||
>
|
||||
{backLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
} else if (error) {
|
||||
title = 'Something went wrong'
|
||||
content = (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<p style={{ color: '#b3261e' }}>Couldn’t load this {noun}. {error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||
style={{ borderColor: TEAL, color: TEAL }}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
title = 'Loading…'
|
||||
content = (
|
||||
<p className="max-w-6xl mx-auto px-6" style={{ color: BODY }} role="status">
|
||||
Loading this {noun}…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={title}
|
||||
sections={[{ id: 'status', title: '', accent: TEAL, background: '#ffffff', content }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -57,6 +57,9 @@ const inputLocked =
|
|||
/* ── Dotted paths ────────────────────────────────────────────── */
|
||||
|
||||
export function getPath(object, path) {
|
||||
// An entity with no slug has no heading path either, and a missing
|
||||
// path should read as "no value" rather than throwing on .split.
|
||||
if (!path) return undefined;
|
||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,17 @@
|
|||
narrows the result to what it shows.
|
||||
|
||||
useEvents({ section: "national" }) one band
|
||||
useEvents({ host: "northwest" }) a region's own events
|
||||
useEvents({ host: "northwest" }) one host's events
|
||||
useEvents({ status: "upcoming" }) a home page strip
|
||||
useEvents({ type: "workshop" }) one kind, wherever it is
|
||||
useEvents({ type: ["class", "workshop"] })
|
||||
useEvents() everything
|
||||
|
||||
`section` and `type` are different questions and stack rather
|
||||
than overlap: the section is which band of the page an event
|
||||
belongs to, the type is what kind of gathering it is. A regional
|
||||
class matches both { section: "regional" } and { type: "class" }.
|
||||
|
||||
Filtering here rather than in the query keeps the endpoint to
|
||||
one cached response. At a few dozen events that's the right
|
||||
trade; if the list ever runs to hundreds, move the filters into
|
||||
|
|
@ -23,18 +30,30 @@ import { useResource } from "../lib/useResource.js";
|
|||
|
||||
const EMPTY = { events: [] };
|
||||
|
||||
export function useEvents({ section, host, status } = {}) {
|
||||
export function useEvents({ section, host, status, type } = {}) {
|
||||
const { data, error, loading } = useResource("/events", { fallback: EMPTY });
|
||||
|
||||
const all = data?.events;
|
||||
|
||||
/* An array prop is a new identity on every render, which would
|
||||
restart the memo each time. Joining it gives the dependency
|
||||
list something stable to compare. */
|
||||
const typeKey = Array.isArray(type) ? type.join(",") : (type ?? "");
|
||||
|
||||
const events = useMemo(() => {
|
||||
let list = all ?? [];
|
||||
if (section) list = list.filter(e => e.section_id === section);
|
||||
if (host) list = list.filter(e => e.host?.id === host);
|
||||
// `host` is an organization or a person slug, and an event can
|
||||
// have several of either — co-hosting puts one event on both
|
||||
// hosts' lists, which is the point.
|
||||
if (host) list = list.filter(e => e.hosts?.some(h => h.id === host));
|
||||
if (status) list = list.filter(e => e.status === status);
|
||||
if (typeKey) {
|
||||
const wanted = new Set(typeKey.split(","));
|
||||
list = list.filter(e => wanted.has(e.event_type));
|
||||
}
|
||||
return list;
|
||||
}, [all, section, host, status]);
|
||||
}, [all, section, host, status, typeKey]);
|
||||
|
||||
return { events, loading, error };
|
||||
}
|
||||
|
|
@ -52,3 +71,12 @@ export function splitByStatus(events = []) {
|
|||
|
||||
return { upcoming, past };
|
||||
}
|
||||
|
||||
/* Which of the declared types a list actually contains, in
|
||||
EVENT_TYPES order rather than whatever order the rows arrived
|
||||
in. A section with one type has nothing to filter, which is what
|
||||
lets the chip bar hide itself. */
|
||||
export function typesPresent(events = [], declared = []) {
|
||||
const seen = new Set(events.map(e => e.event_type));
|
||||
return declared.filter(entry => seen.has(entry.id));
|
||||
}
|
||||
|
|
|
|||
63
src/data/historyDecades.ts
Normal file
63
src/data/historyDecades.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Decade headers for the history page.
|
||||
*
|
||||
* Not in the database on purpose. There are four of them, they change
|
||||
* about never, and they're editorial voice rather than record — the same
|
||||
* reasoning that keeps the map grid config in code. A table for four
|
||||
* rows that nobody edits is a migration and an admin page for nothing.
|
||||
*
|
||||
* `preProgram` is what draws the gap: a dashed rail, hollow year dots,
|
||||
* and the "before the program" marker. It lives on the decade rather
|
||||
* than being a hardcoded year check, so the gap moves if the founding
|
||||
* date is ever revised.
|
||||
*/
|
||||
|
||||
import type { DecadeMeta } from '../lib/timeline'
|
||||
|
||||
export const HISTORY_DECADES: DecadeMeta[] = [
|
||||
{
|
||||
decade: 2020,
|
||||
title: 'The Age of Autonomy',
|
||||
tagline: 'Growth, unprecedented support, returning to our roots',
|
||||
blurb:
|
||||
'Covid-19 sends everyone home for 2 years and from the resurgence comes a new iteration of the program',
|
||||
},
|
||||
{
|
||||
decade: 2010,
|
||||
title: 'Millennials run the show',
|
||||
tagline: 'A time of something',
|
||||
blurb:
|
||||
'Need to update what happened here, mostly documented on facebook we asumme',
|
||||
},
|
||||
{
|
||||
decade: 2000,
|
||||
title: 'NGU - The new acronym',
|
||||
tagline: 'Turn of the century forms a national explosion',
|
||||
blurb:
|
||||
'Unity\'s young adult program becomes Next Generation of Unity',
|
||||
},
|
||||
{
|
||||
decade: 1990,
|
||||
title: 'Before NGU there was YAU',
|
||||
tagline: 'Young adult ministry existed',
|
||||
preProgram: true,
|
||||
blurb:
|
||||
'Anything listed here predates our named program. We are looking to document this history more',
|
||||
},
|
||||
{
|
||||
decade: 1980,
|
||||
title: 'YAU?',
|
||||
tagline: 'Young adult ministry existed',
|
||||
preProgram: true,
|
||||
blurb:
|
||||
'Anything listed here predates our named program. We are looking to document this history more',
|
||||
},
|
||||
{
|
||||
decade: 1970,
|
||||
title: 'YAU?',
|
||||
tagline: 'Young adult ministry existed',
|
||||
preProgram: true,
|
||||
blurb:
|
||||
'Anything listed here predates our named program. We are looking to document this history more',
|
||||
},
|
||||
]
|
||||
299
src/data/old-historyMock.ts
Normal file
299
src/data/old-historyMock.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* PLACEHOLDER DATA — delete this file once `GET /api/history` lands.
|
||||
*
|
||||
* Shaped exactly like the aggregation route's payload, so swapping it out
|
||||
* is a two-line change in History.tsx.
|
||||
*
|
||||
* Note what is and isn't here. Entries that point at a record carry a
|
||||
* `ref` and little else: no venue, no host name, no copy that also lives
|
||||
* in `events`. The few that do carry a title are overriding it on
|
||||
* purpose. Titles and blurbs below are invented scaffolding, not real NGU
|
||||
* history — replace them before anyone sees this.
|
||||
*
|
||||
* Decade headers are not here — they live in historyDecades.ts and stay
|
||||
* in code even after the API lands.
|
||||
*
|
||||
* Upcoming items are not flagged. `partitionByDate` decides what's
|
||||
* upcoming by comparing dates to the wall clock, so nothing here needs
|
||||
* editing as dates pass.
|
||||
*/
|
||||
|
||||
import type { TimelineItem } from '../lib/timeline'
|
||||
|
||||
export const MOCK_ITEMS: TimelineItem[] = [
|
||||
// ——— Upcoming (relative to the wall clock, not a flag) ———
|
||||
{
|
||||
id: 'tl-0001',
|
||||
date: '2027-06',
|
||||
precision: 'month',
|
||||
kind: 'event',
|
||||
title: 'Summer Conference 2027',
|
||||
meta: 'Unity Village, MO',
|
||||
ref: { kind: 'event', id: 'summer-2027' },
|
||||
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0002',
|
||||
date: '2026-11-14',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Fall Regional Rally',
|
||||
meta: 'Southeast',
|
||||
ref: { kind: 'event', id: 'fall-rally-2026' },
|
||||
logo: { file: 'regional-rally.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0003',
|
||||
date: '2026-10-03',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Chapter Leads Intensive',
|
||||
meta: 'Online',
|
||||
ref: { kind: 'event', id: 'leads-intensive-2026' },
|
||||
},
|
||||
|
||||
// ——— 2020s ———
|
||||
{
|
||||
id: 'tl-0010',
|
||||
date: '2026-03',
|
||||
precision: 'month',
|
||||
kind: 'organization',
|
||||
title: 'Twentieth chapter chartered',
|
||||
blurb:
|
||||
'The first new charter in the region since 2017, started by three people who met at a rally two summers earlier.',
|
||||
featured: true,
|
||||
ref: { kind: 'organization', id: 'boise', orgKind: 'chapter' },
|
||||
logo: { file: 'boise.svg', kind: 'organization' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0011',
|
||||
date: '2026-04-17',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Spring Regional Rally',
|
||||
ref: { kind: 'event', id: 'spring-rally-2026' },
|
||||
logo: { file: 'regional-rally.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0012',
|
||||
date: '2026-01',
|
||||
precision: 'month',
|
||||
kind: 'people',
|
||||
title: 'New leadership team seated',
|
||||
blurb: 'A four-person exec on a two-year term, the first under the 2024 bylaws.',
|
||||
ref: { kind: 'team', id: 'ngu-leadership' },
|
||||
team: { id: 'ngu-leadership', name: 'Leadership Team', orgId: 'ngu', orgName: 'NGU National' },
|
||||
people: [
|
||||
{ id: 'jane-doe', name: 'Jane Doe', title: 'Chair', photo: 'jane-doe.jpg' },
|
||||
{ id: 'sam-ruiz', name: 'Sam Ruiz', title: 'Vice Chair', photo: 'sam-ruiz.jpg' },
|
||||
{ id: 'ada-mensah', name: 'Ada Mensah', title: 'Secretary' },
|
||||
{ id: 'tom-baird', name: 'Tom Baird', title: 'Treasurer', photo: 'tom-baird.jpg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tl-0013',
|
||||
date: '2025-12-28',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Winter Gathering',
|
||||
ref: { kind: 'event', id: 'winter-gathering-2025' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0014',
|
||||
date: '2025-07-19',
|
||||
precision: 'day',
|
||||
kind: 'award',
|
||||
title: 'Service Award',
|
||||
meta: 'Presented to the Southeast chapter leads',
|
||||
ref: { kind: 'award', id: 'service' },
|
||||
logo: { file: 'service-award.svg', kind: 'award' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0015',
|
||||
date: '2025-07-17',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Summer Conference',
|
||||
meta: 'Three days, 140 attendees',
|
||||
ref: { kind: 'event', id: 'summer-2025' },
|
||||
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0016',
|
||||
date: '2025',
|
||||
precision: 'year',
|
||||
kind: 'milestone',
|
||||
title: 'Photo archive digitized',
|
||||
blurb: 'Roughly 4,000 images from 2003 onward, scanned by volunteers.',
|
||||
href: 'https://archive.nextgenerationofunity.org',
|
||||
},
|
||||
{
|
||||
id: 'tl-0017',
|
||||
date: '2024-09',
|
||||
precision: 'month',
|
||||
kind: 'milestone',
|
||||
title: 'Bylaws rewritten',
|
||||
blurb:
|
||||
'Term limits, a defined handoff window, and the first written process for chartering a chapter.',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'tl-0018',
|
||||
date: '2024-07-11',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Summer Conference',
|
||||
ref: { kind: 'event', id: 'summer-2024' },
|
||||
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0019',
|
||||
date: '2024-07-13',
|
||||
precision: 'day',
|
||||
kind: 'award',
|
||||
title: 'Emerging Leader Award',
|
||||
meta: 'First year the award was given',
|
||||
ref: { kind: 'award', id: 'emerging-leader' },
|
||||
logo: { file: 'emerging-leader.svg', kind: 'award' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0020',
|
||||
date: '2022-06',
|
||||
precision: 'month',
|
||||
kind: 'event',
|
||||
title: 'First in-person rally since 2019',
|
||||
blurb: 'Sixty-one people, most of whom had only ever met on a video call.',
|
||||
featured: true,
|
||||
ref: { kind: 'event', id: 'return-rally-2022' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0021',
|
||||
date: '2021-08',
|
||||
precision: 'month',
|
||||
kind: 'event',
|
||||
title: 'Online Summer Intensive',
|
||||
meta: 'Six sessions across two weeks',
|
||||
ref: { kind: 'event', id: 'online-intensive-2021' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0022',
|
||||
date: '2020-03',
|
||||
precision: 'month',
|
||||
kind: 'milestone',
|
||||
title: 'All gatherings suspended',
|
||||
blurb: 'Weekly online rooms started the following week and ran for 118 weeks.',
|
||||
},
|
||||
|
||||
// ——— 2010s ———
|
||||
{
|
||||
id: 'tl-0030',
|
||||
date: '2018-05',
|
||||
precision: 'month',
|
||||
kind: 'organization',
|
||||
title: 'Eighth region recognized',
|
||||
featured: true,
|
||||
ref: { kind: 'organization', id: 'northwest', orgKind: 'region' },
|
||||
logo: { file: 'northwest.svg', kind: 'organization' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0031',
|
||||
date: '2018-07-20',
|
||||
precision: 'day',
|
||||
kind: 'event',
|
||||
title: 'Summer Conference',
|
||||
ref: { kind: 'event', id: 'summer-2018' },
|
||||
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0032',
|
||||
date: '2016-02',
|
||||
precision: 'month',
|
||||
kind: 'people',
|
||||
title: 'Retreat Team formed',
|
||||
blurb:
|
||||
'Programming had been whoever volunteered. This made it a standing team with a handoff.',
|
||||
ref: { kind: 'team', id: 'ngu-retreat-team' },
|
||||
team: { id: 'ngu-retreat-team', name: 'Retreat Team', orgId: 'ngu', orgName: 'NGU National' },
|
||||
people: [
|
||||
{ id: 'marcus-hale', name: 'Marcus Hale', title: 'Founding lead', photo: 'marcus-hale.jpg' },
|
||||
{ id: 'priya-nair', name: 'Priya Nair', photo: 'priya-nair.jpg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tl-0033',
|
||||
date: '2015-07',
|
||||
precision: 'month',
|
||||
kind: 'award',
|
||||
title: 'First Service Award presented',
|
||||
blurb: 'Created to name the work that had been going unnamed for a decade.',
|
||||
featured: true,
|
||||
ref: { kind: 'award', id: 'service' },
|
||||
logo: { file: 'service-award.svg', kind: 'award' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0034',
|
||||
date: '2015-02',
|
||||
precision: 'month',
|
||||
kind: 'milestone',
|
||||
title: 'Shared calendar goes live',
|
||||
blurb: 'Regions stop scheduling on top of each other.',
|
||||
},
|
||||
{
|
||||
id: 'tl-0035',
|
||||
date: '2012-06',
|
||||
precision: 'month',
|
||||
kind: 'event',
|
||||
title: 'Summer Conference',
|
||||
meta: 'The year attendance first passed 100',
|
||||
ref: { kind: 'event', id: 'summer-2012' },
|
||||
logo: { file: 'summer-conference.svg', kind: 'event' },
|
||||
},
|
||||
{
|
||||
id: 'tl-0036',
|
||||
date: '2012',
|
||||
precision: 'year',
|
||||
kind: 'milestone',
|
||||
title: 'Adopted the name Next Generation of Unity',
|
||||
},
|
||||
|
||||
// ——— 2000s ———
|
||||
{
|
||||
id: 'tl-0040',
|
||||
date: '2009',
|
||||
precision: 'year',
|
||||
kind: 'milestone',
|
||||
title: 'Four regions drawn on a map for the first time',
|
||||
},
|
||||
{
|
||||
id: 'tl-0041',
|
||||
date: '2005-08',
|
||||
precision: 'month',
|
||||
kind: 'milestone',
|
||||
title: 'The gathering becomes annual',
|
||||
blurb: 'Before this it happened when someone had the energy to organize it.',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'tl-0042',
|
||||
date: '2002-08',
|
||||
precision: 'month',
|
||||
kind: 'event',
|
||||
title: 'First young adult retreat',
|
||||
meta: 'Nineteen attendees',
|
||||
blurb:
|
||||
'Organized over six weeks at a borrowed retreat center. Everything else on this page follows from it.',
|
||||
featured: true,
|
||||
ref: { kind: 'event', id: 'first-retreat-2002' },
|
||||
},
|
||||
|
||||
// ——— Pre-program ———
|
||||
{
|
||||
id: 'tl-0050',
|
||||
date: '1997',
|
||||
precision: 'year',
|
||||
kind: 'milestone',
|
||||
title: 'Regional youth programs running independently',
|
||||
blurb:
|
||||
'Not NGU, and not connected to each other — but the people who started NGU came out of these.',
|
||||
},
|
||||
]
|
||||
|
|
@ -32,7 +32,7 @@ export function useOrganizations(kind) {
|
|||
? `/organizations?kind=${encodeURIComponent(kind)}`
|
||||
: "/organizations";
|
||||
|
||||
const { data, error, loading } = useResource(path, { fallback: EMPTY });
|
||||
const { data, error, loading } = useResource(path);
|
||||
|
||||
return {
|
||||
organizations: data?.organizations ?? [],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,78 @@ const AFFILIATION_ROLE_FIELDS = [
|
|||
{ path: "is_public", label: "Public", widget: "checkbox" },
|
||||
];
|
||||
|
||||
/* The timeline panel that appears on an event or organization once the
|
||||
box is ticked. Paths are prefixed with the extension key, exactly as
|
||||
'region.scope' and 'private.notes' are.
|
||||
|
||||
Everything here is an override. Left blank, the history page falls
|
||||
back to the record's own title, date and logo through v_timeline —
|
||||
which is the point of referencing rather than copying. */
|
||||
const timelineExtensionFields = (noun) => [
|
||||
{
|
||||
path: "timeline.occurred_on",
|
||||
label: "Date on the timeline",
|
||||
help: `Blank uses the ${noun}'s own date. Partial dates are fine: 2012, 2012-06`,
|
||||
},
|
||||
{
|
||||
path: "timeline.precision",
|
||||
label: "Date precision",
|
||||
widget: "select",
|
||||
options: ["year", "month", "day"],
|
||||
help: "How much of the date to trust. 'year' files it under 'Elsewhere in 2012'",
|
||||
},
|
||||
{
|
||||
path: "timeline.title",
|
||||
label: "Title override",
|
||||
full: true,
|
||||
help: `Blank uses the ${noun}'s name`,
|
||||
},
|
||||
{
|
||||
path: "timeline.blurb",
|
||||
label: "Blurb override",
|
||||
widget: "textarea",
|
||||
full: true,
|
||||
help: `Blank uses the ${noun}'s tagline`,
|
||||
},
|
||||
{ path: "timeline.meta", label: "Secondary line", help: "Region, venue, recipient" },
|
||||
{
|
||||
path: "timeline.link_url",
|
||||
label: "Link override",
|
||||
help: `Blank links to the ${noun}'s own page`,
|
||||
},
|
||||
{
|
||||
path: "timeline.is_featured",
|
||||
label: "Featured",
|
||||
widget: "checkbox",
|
||||
help: "Shown large, above the month list for its year",
|
||||
},
|
||||
{ path: "timeline.is_published", label: "Visible on the history page", widget: "checkbox" },
|
||||
{ path: "timeline.sort_order", label: "Sort order", widget: "number" },
|
||||
];
|
||||
|
||||
/* The checkbox itself, plus the panel it gates. One entry in `groups`. */
|
||||
const timelineGroup = (noun) => ({
|
||||
legend: "Timeline",
|
||||
note:
|
||||
`Adds this ${noun} to the history page. Nothing is copied — the entry ` +
|
||||
`reads this record, so editing it here updates the timeline too.`,
|
||||
fields: [
|
||||
{
|
||||
path: "in_timeline",
|
||||
label: "On the timeline",
|
||||
widget: "checkbox",
|
||||
help: "Show this on the history page",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const timelineDetailGroup = (noun) => ({
|
||||
legend: "Timeline entry",
|
||||
when: { path: "in_timeline", value: 1 },
|
||||
note: "Every field here is optional. Blank means \u201cuse the record's own value\u201d.",
|
||||
fields: timelineExtensionFields(noun),
|
||||
});
|
||||
|
||||
/* The two collections every entity carries. */
|
||||
const linksChild = {
|
||||
key: "links",
|
||||
|
|
@ -69,6 +141,43 @@ const linksChild = {
|
|||
],
|
||||
};
|
||||
|
||||
/* Hosts. One row is one host, ordered, and each is either an
|
||||
organization or a person — never both, which the CHECK on
|
||||
event_hosts enforces and this form can only ask nicely about.
|
||||
|
||||
The first row supplies the logo and colour when the event sets
|
||||
neither, so the order here is data rather than a display choice.
|
||||
A person supplies neither: there is no colour on a person and a
|
||||
headshot is not a logo, so a person-hosted event with no colour
|
||||
of its own falls through to the section default. */
|
||||
const hostsChild = {
|
||||
key: "event_hosts",
|
||||
label: "Hosts",
|
||||
addLabel: "Add host",
|
||||
title: (row, options) =>
|
||||
options?.organizations?.find((o) => o.id === row.org_id)?.label ??
|
||||
options?.people?.find((p) => p.id === row.person_id)?.label ??
|
||||
"New host",
|
||||
blank: { org_id: "", person_id: "" },
|
||||
fields: [
|
||||
{
|
||||
path: "org_id",
|
||||
label: "Organization",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
blankLabel: "— none —",
|
||||
},
|
||||
{
|
||||
path: "person_id",
|
||||
label: "Person",
|
||||
widget: "select",
|
||||
optionsFrom: "people",
|
||||
blankLabel: "— none —",
|
||||
help: "One or the other, not both. First host supplies the logo and colour.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blocksChild = {
|
||||
key: "content_blocks",
|
||||
label: "Content blocks",
|
||||
|
|
@ -184,6 +293,8 @@ const organizations = {
|
|||
],
|
||||
},
|
||||
{ legend: "Place", fields: PLACE_FIELDS },
|
||||
timelineGroup("organization"),
|
||||
timelineDetailGroup("organization"),
|
||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||
],
|
||||
|
||||
|
|
@ -225,13 +336,19 @@ const events = {
|
|||
list: {
|
||||
columns: [
|
||||
{ key: "title", label: "Title", primary: true },
|
||||
{ key: "section_id", label: "Section" },
|
||||
{ key: "section_id", label: "Scope" },
|
||||
{ key: "event_type", label: "Type" },
|
||||
{ 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: "section_id", label: "Scope", optionsFrom: "event_sections" },
|
||||
{
|
||||
key: "event_type",
|
||||
label: "Type",
|
||||
options: ["retreat", "class", "workshop", "meeting", "other"],
|
||||
},
|
||||
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
|
||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||
],
|
||||
|
|
@ -241,20 +358,25 @@ const events = {
|
|||
{
|
||||
legend: "Identity",
|
||||
fields: [
|
||||
// The column is still section_id — the rows in event_sections
|
||||
// are what changed, not the schema. Only the label moved,
|
||||
// because "scope" is what the field has always meant and
|
||||
// "section" described where it happened to be rendered.
|
||||
{
|
||||
path: "section_id",
|
||||
label: "Section",
|
||||
label: "Scope",
|
||||
widget: "select",
|
||||
optionsFrom: "event_sections",
|
||||
required: true,
|
||||
help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
|
||||
},
|
||||
{
|
||||
path: "host_org_id",
|
||||
label: "Host",
|
||||
path: "event_type",
|
||||
label: "Type",
|
||||
widget: "select",
|
||||
optionsFrom: "organizations",
|
||||
blankLabel: "— none —",
|
||||
help: "Supplies the logo and colour when this event sets neither",
|
||||
options: ["retreat", "class", "workshop", "meeting", "other"],
|
||||
required: true,
|
||||
help: "What kind of gathering. Independent of the scope",
|
||||
},
|
||||
{ path: "title", label: "Title", required: true },
|
||||
{ path: "theme", label: "Theme" },
|
||||
|
|
@ -290,10 +412,13 @@ const events = {
|
|||
{ path: "gradient", label: "Gradient", full: true },
|
||||
],
|
||||
},
|
||||
timelineGroup("event"),
|
||||
timelineDetailGroup("event"),
|
||||
{ legend: "Publishing", fields: PUBLISH_FIELDS },
|
||||
],
|
||||
|
||||
children: [
|
||||
hostsChild,
|
||||
{
|
||||
key: "event_people",
|
||||
label: "People at this event",
|
||||
|
|
@ -600,7 +725,160 @@ const awards = {
|
|||
],
|
||||
};
|
||||
|
||||
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
|
||||
/* ── Timeline ─────────────────────────────────────────── */
|
||||
|
||||
// The only entity with no slug: the table assigns the id, because an
|
||||
// entry referencing an event has no name of its own and one gets
|
||||
// created every time somebody ticks a checkbox. `idKind: "auto"` tells
|
||||
// EntityEdit to show the id rather than ask for it.
|
||||
//
|
||||
// Most rows here are created from an event or organization page, not
|
||||
// this one. What this page is for: hand-authored milestones with no
|
||||
// record behind them, 'people' entries about a team forming, and fixing
|
||||
// up the entries the checkboxes made.
|
||||
const timeline = {
|
||||
key: "timeline",
|
||||
label: "Timeline",
|
||||
singular: "entry",
|
||||
idLabel: "Entry",
|
||||
idKind: "auto",
|
||||
titleFrom: "title",
|
||||
|
||||
list: {
|
||||
columns: [
|
||||
{ key: "title", label: "Title", primary: true },
|
||||
{ key: "occurred_on", label: "Date" },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "ref_id", label: "References" },
|
||||
{ key: "is_featured", label: "Featured", widget: "bool" },
|
||||
{ key: "is_published", label: "Live", widget: "bool" },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: "kind",
|
||||
label: "Kind",
|
||||
options: ["milestone", "event", "organization", "award", "people"],
|
||||
},
|
||||
{
|
||||
key: "ref_kind",
|
||||
label: "References",
|
||||
options: ["event", "organization", "award", "person", "team"],
|
||||
},
|
||||
{ key: "is_featured", label: "Featured", options: [["1", "Featured"], ["0", "Normal"]] },
|
||||
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
|
||||
],
|
||||
},
|
||||
|
||||
groups: [
|
||||
{
|
||||
legend: "What this is",
|
||||
note:
|
||||
"An entry either points at a record or stands on its own. " +
|
||||
"Pointing at one means its title, date and logo come from that " +
|
||||
"record \u2014 nothing is copied, so editing the record updates this.",
|
||||
fields: [
|
||||
{
|
||||
path: "kind",
|
||||
label: "Kind",
|
||||
widget: "select",
|
||||
options: ["milestone", "event", "organization", "award", "people"],
|
||||
required: true,
|
||||
help: "Drives the marker and the layout. 'people' renders a roster",
|
||||
},
|
||||
{
|
||||
path: "ref_kind",
|
||||
label: "Points at",
|
||||
widget: "select",
|
||||
options: ["event", "organization", "award", "person", "team"],
|
||||
blankLabel: "\u2014 nothing, this stands alone \u2014",
|
||||
help: "Changing this leaves the record below orphaned \u2014 pick a new one",
|
||||
},
|
||||
{
|
||||
path: "ref_id",
|
||||
label: "Record",
|
||||
widget: "select",
|
||||
optionsFrom: "timeline_refs",
|
||||
blankLabel: "\u2014 none \u2014",
|
||||
// One flat list of every referenceable row, narrowed to the
|
||||
// kind chosen above. Five dropdowns of which four are always
|
||||
// wrong would be worse.
|
||||
filterBy: (option, row) => option.kind === row.ref_kind,
|
||||
help: "Only records of the kind chosen above",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "When",
|
||||
note:
|
||||
"Blank takes the referenced record's own date. Precision is what " +
|
||||
"says how much of the date to believe \u2014 a backfilled entry that " +
|
||||
"only knows the year should say so.",
|
||||
fields: [
|
||||
{
|
||||
path: "occurred_on",
|
||||
label: "Date",
|
||||
help: "2012, 2012-06 or 2012-06-14",
|
||||
},
|
||||
{
|
||||
path: "precision",
|
||||
label: "Precision",
|
||||
widget: "select",
|
||||
options: ["year", "month", "day"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Text",
|
||||
note: "All optional. Blank uses the referenced record's own wording.",
|
||||
fields: [
|
||||
{ path: "title", label: "Title", full: true },
|
||||
{ path: "blurb", label: "Blurb", widget: "textarea", full: true },
|
||||
{ path: "meta", label: "Secondary line", help: "Region, venue, recipient" },
|
||||
{ path: "link_url", label: "Link", help: "Blank links to the record's own page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
legend: "Publishing",
|
||||
fields: [
|
||||
{
|
||||
path: "is_featured",
|
||||
label: "Featured",
|
||||
widget: "checkbox",
|
||||
help: "Shown large, above the month list for its year",
|
||||
},
|
||||
{ path: "is_published", label: "Published", widget: "checkbox" },
|
||||
{ path: "sort_order", label: "Sort order", widget: "number" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "people",
|
||||
label: "People",
|
||||
addLabel: "Add person",
|
||||
note:
|
||||
"For an entry about people. A 'people' entry pointing at a team " +
|
||||
"already shows that team's members \u2014 this is for the cases where " +
|
||||
"the list is editorial rather than structural.",
|
||||
title: (row, options) =>
|
||||
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
|
||||
blank: { person_id: "" },
|
||||
fields: [
|
||||
{
|
||||
path: "person_id",
|
||||
label: "Person",
|
||||
widget: "select",
|
||||
optionsFrom: "people",
|
||||
required: true,
|
||||
},
|
||||
{ path: "note", label: "Note", help: "Founding lead, first chair" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline };
|
||||
|
||||
export function slugify(value) {
|
||||
return String(value ?? "")
|
||||
|
|
|
|||
46
src/lib/eventTypes.ts
Normal file
46
src/lib/eventTypes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT TYPES
|
||||
|
||||
The client half of the CHECK on events.event_type. Order here is
|
||||
display order — the filter chips read it straight off this array,
|
||||
so moving a line moves a chip.
|
||||
|
||||
What this is not: event_sections. A section owns presentation —
|
||||
Retreats.tsx keys its title, accent and background on the id, so
|
||||
an unrecognised section_id makes an event vanish with no error,
|
||||
which is why that one is a real table with a real foreign key. A
|
||||
type carries no presentation of its own and an unknown value
|
||||
renders as its own name, so a CHECK is enough.
|
||||
|
||||
Adding a type is three edits: the CHECK in a migration, the enum
|
||||
in both descriptor halves, and this list. Adding it here alone
|
||||
means the site offers a filter the database will refuse to store.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export type EventType = 'retreat' | 'class' | 'workshop' | 'meeting' | 'other'
|
||||
|
||||
export const EVENT_TYPES: { id: EventType; label: string; plural: string }[] = [
|
||||
{ id: 'retreat', label: 'Retreat', plural: 'Retreats' },
|
||||
{ id: 'class', label: 'Class', plural: 'Classes' },
|
||||
{ id: 'workshop', label: 'Workshop', plural: 'Workshops' },
|
||||
{ id: 'meeting', label: 'Meeting', plural: 'Meetings' },
|
||||
{ id: 'other', label: 'Other', plural: 'Other' },
|
||||
]
|
||||
|
||||
export const EVENT_TYPE_IDS: EventType[] = EVENT_TYPES.map((entry) => entry.id)
|
||||
|
||||
const BY_ID = new Map<string, { label: string; plural: string }>(
|
||||
EVENT_TYPES.map((entry) => [entry.id as string, entry]),
|
||||
)
|
||||
|
||||
const capitalize = (word: string) =>
|
||||
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
|
||||
|
||||
/* A value the CHECK has gained since this file was written renders
|
||||
as itself rather than vanishing — the same rule EventDetail's
|
||||
ROLE_ORDER follows for billing roles. */
|
||||
export const eventTypeLabel = (id?: string | null): string =>
|
||||
(id ? BY_ID.get(id)?.label : null) ?? capitalize(id ?? '')
|
||||
|
||||
export const eventTypePlural = (id?: string | null): string =>
|
||||
(id ? BY_ID.get(id)?.plural : null) ?? capitalize(id ?? '')
|
||||
174
src/lib/hrefs.ts
Normal file
174
src/lib/hrefs.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PUBLIC HREFS
|
||||
|
||||
One place that turns a record into a URL. The API deliberately
|
||||
never sends paths — it sends `{ kind, id }` and, for an
|
||||
organization, the `org_kind` that decides which of the three
|
||||
routes a slug belongs to. Deciding that in each component is how
|
||||
/regions/x and /chapters/x end up both existing for the same
|
||||
record, and how the history timeline ended up emitting /event/:id
|
||||
while the router only knew /retreats/:id.
|
||||
|
||||
timelineRefs.ts now delegates here rather than keeping its own
|
||||
table, so there is one answer to "where does an event live" and
|
||||
changing it is changing EVENT_BASE below.
|
||||
|
||||
navConfig.js stays the source of truth for the *nav*: these are
|
||||
record routes, which never appear in it.
|
||||
|
||||
── On missing ids ──
|
||||
Every builder takes an id the caller believed it had. When it
|
||||
doesn't, the old behaviour was to interpolate the string
|
||||
"undefined" into a path, render a link to it, mount a page and
|
||||
fetch /api/organizations/undefined — four steps between the
|
||||
mistake and any sign of it, none of which name the component
|
||||
that made it.
|
||||
|
||||
Now the id is checked here. In dev that's a console.error with a
|
||||
stack trace pointing at the caller; in production the path still
|
||||
comes out, because a broken link beats a crashed page, and
|
||||
useResource refuses to fetch it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export type OrgKind = 'national' | 'region' | 'chapter' | 'partner'
|
||||
export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person'
|
||||
|
||||
/* A region, a chapter and a partner read as different things to a
|
||||
visitor even though they are one table, so they get one route
|
||||
each. 'national' is NGU itself — one record, no listing to sit
|
||||
under, so it falls through to the generic path. */
|
||||
const ORG_BASE: Record<string, string> = {
|
||||
region: '/regions',
|
||||
chapter: '/chapters',
|
||||
partner: '/partners',
|
||||
national: '/organizations',
|
||||
}
|
||||
|
||||
/** Canonical base for an event page.
|
||||
*
|
||||
* /events/:id, not /retreats/:id. The listing page is called
|
||||
* Retreats because that's what NGU calls the gatherings it hosts,
|
||||
* but the records are `events`, the API route is /api/events, and
|
||||
* plenty of them — partner events, conferences — aren't retreats
|
||||
* at all. Naming the record route after one page's editorial
|
||||
* framing would have been wrong the first time a non-retreat got
|
||||
* its own page.
|
||||
*
|
||||
* Nothing redirects from /retreats/:id, because nothing ever
|
||||
* linked there. */
|
||||
export const EVENT_BASE = '/events'
|
||||
|
||||
const DEV = Boolean((import.meta as any)?.env?.DEV)
|
||||
|
||||
/* Router params arrive as strings, so an id that has already been
|
||||
through a template literal shows up as the literal word. Those
|
||||
are as broken as a genuine null. */
|
||||
const BAD = new Set(['', 'undefined', 'null', 'NaN'])
|
||||
|
||||
export const isBadId = (id: unknown): boolean =>
|
||||
id == null || BAD.has(String(id))
|
||||
|
||||
function checkId(id: unknown, what: string): string {
|
||||
if (!isBadId(id)) return String(id)
|
||||
|
||||
if (DEV) {
|
||||
// console.error rather than warn: this is always a bug, and the
|
||||
// stack is the whole point — it names the component that passed
|
||||
// nothing.
|
||||
console.error(
|
||||
`hrefs: ${what}() was given ${JSON.stringify(id)}. ` +
|
||||
`The link it returns will 404. Caller:`,
|
||||
new Error('hrefs: missing id').stack,
|
||||
)
|
||||
}
|
||||
|
||||
return 'undefined'
|
||||
}
|
||||
|
||||
/**
|
||||
* The API path for one record, or null when the id isn't usable.
|
||||
*
|
||||
* The mirror image of the builders below: they make the URL a
|
||||
* visitor sees, this makes the URL the client fetches, and both
|
||||
* have to agree about what counts as an id.
|
||||
*
|
||||
* It lives here rather than next to the hook that calls it because
|
||||
* it is a path, and paths are this file's job — and because a
|
||||
* component that interpolates a missing route param produces the
|
||||
* literal string "undefined", which the server cannot tell apart
|
||||
* from a slug somebody genuinely typed. It answers 404 either way,
|
||||
* and the log fills with GET /api/organizations/undefined with
|
||||
* nothing to say where it came from.
|
||||
*
|
||||
* Returning null costs a round trip and turns a mystery 404 into
|
||||
* the not-found page, which is what a visitor should see anyway.
|
||||
*/
|
||||
export function detailPath(base: string, id?: string | null): string | null {
|
||||
return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}`
|
||||
}
|
||||
|
||||
export const eventHref = (id?: string | null) =>
|
||||
`${EVENT_BASE}/${checkId(id, 'eventHref')}`
|
||||
|
||||
export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}`
|
||||
|
||||
export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}`
|
||||
|
||||
export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}`
|
||||
|
||||
export function orgHref(id?: string | null, kind?: string | null): string {
|
||||
return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}`
|
||||
}
|
||||
|
||||
/* For anything holding a polymorphic reference — timeline entries,
|
||||
content blocks — where the kind arrives as data rather than being
|
||||
known at the call site.
|
||||
|
||||
Returns null for a kind with no page AND for a reference with no
|
||||
id, so the caller renders plain text instead of a dead link.
|
||||
This is the one the timeline wants: an entry whose ref didn't
|
||||
resolve should read as text, not as a link to nowhere. */
|
||||
export function refHref(
|
||||
kind: string | null | undefined,
|
||||
id: string | null | undefined,
|
||||
orgKind?: string | null,
|
||||
): string | null {
|
||||
if (!kind || isBadId(id)) return null
|
||||
switch (kind) {
|
||||
case 'event':
|
||||
return eventHref(id)
|
||||
case 'organization':
|
||||
return orgHref(id, orgKind)
|
||||
case 'team':
|
||||
return teamHref(id)
|
||||
case 'award':
|
||||
return awardHref(id)
|
||||
case 'person':
|
||||
return personHref(id)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* What to call the kind in a breadcrumb or a back link. */
|
||||
export const ORG_KIND_LABEL: Record<string, string> = {
|
||||
national: 'Next Generation of Unity',
|
||||
region: 'Region',
|
||||
chapter: 'Chapter',
|
||||
partner: 'Partner organization',
|
||||
}
|
||||
|
||||
/* Where "back" goes from a record page. A chapter belongs to
|
||||
/community, a retreat to /retreats. */
|
||||
export function orgListHref(kind?: string | null): { to: string; label: string } {
|
||||
switch (kind) {
|
||||
case 'region':
|
||||
return { to: '/community#local', label: 'All regions' }
|
||||
case 'chapter':
|
||||
return { to: '/community#local', label: 'All chapters' }
|
||||
case 'partner':
|
||||
return { to: '/community#partners', label: 'All partner organizations' }
|
||||
default:
|
||||
return { to: '/community', label: 'Community' }
|
||||
}
|
||||
}
|
||||
77
src/lib/media.ts
Normal file
77
src/lib/media.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
MEDIA PATHS
|
||||
|
||||
Every image field in the API is a bare filename — "where the
|
||||
images live is the component's business", as history.js puts it.
|
||||
This is that business, in one file, so moving a directory is one
|
||||
edit rather than a grep.
|
||||
|
||||
⚠ Only two of these directories are confirmed by the admin help
|
||||
text: people/ and event-logos/. The other three are a guess at
|
||||
your convention. Check public/ and fix them here — nothing else
|
||||
references the paths.
|
||||
|
||||
A value that already looks like a path or a URL is returned
|
||||
untouched, so a hand-written entry can point anywhere.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const ABSOLUTE = /^(https?:|\/|data:)/
|
||||
|
||||
function inDir(dir: string) {
|
||||
return (file?: string | null): string | null => {
|
||||
if (!file) return null
|
||||
if (ABSOLUTE.test(file)) return file
|
||||
return `${dir}/${file}`
|
||||
}
|
||||
}
|
||||
|
||||
export const personPhoto = inDir('/people') // confirmed
|
||||
export const eventLogo = inDir('/event-logos') // confirmed
|
||||
export const orgLogo = inDir('/org-logos') // ⚠ guess
|
||||
export const teamLogo = inDir('/team-logos') // ⚠ guess
|
||||
export const awardLogo = inDir('/award-logos') // ⚠ guess
|
||||
|
||||
/* content_blocks.media, which can be an image on any owner's page,
|
||||
so it can't share a per-entity directory. */
|
||||
export const blockMedia = inDir('/media') // ⚠ guess
|
||||
|
||||
/* ── By record kind ────────────────────────────────────────────
|
||||
The timeline sends `logo: { file, kind }` rather than a path,
|
||||
because v_timeline COALESCEs across five tables and only the
|
||||
ref_kind says which one the filename came from.
|
||||
|
||||
⚠ Three of these directories are the guesses above. Your current
|
||||
timelineRefs.ts already has the real ones — timeline logos render
|
||||
today — so copy them into the map above and delete this note.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
const BY_KIND: Record<string, (file?: string | null) => string | null> = {
|
||||
event: eventLogo,
|
||||
organization: orgLogo,
|
||||
team: teamLogo,
|
||||
award: awardLogo,
|
||||
person: personPhoto,
|
||||
}
|
||||
|
||||
export function logoForKind(
|
||||
kind?: string | null,
|
||||
file?: string | null,
|
||||
): string | null {
|
||||
if (!file) return null
|
||||
// An unrecognised kind still renders: a filename with no home
|
||||
// directory is a bug, but a broken <img> says so louder than a
|
||||
// silently absent one.
|
||||
return (BY_KIND[kind ?? ''] ?? blockMedia)(file)
|
||||
}
|
||||
|
||||
/* Initials for a photo that is missing or fails to load. Same
|
||||
two-word rule PeopleTiles uses. */
|
||||
export function initials(name = ''): string {
|
||||
return name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0] || '')
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
57
src/lib/roles.ts
Normal file
57
src/lib/roles.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ROLES — src/lib/roles.ts
|
||||
|
||||
The same ladder as server/src/auth.js, and it has to stay the
|
||||
same ladder. This copy exists to decide what to draw; the
|
||||
server's copy decides what's allowed. If they ever disagree the
|
||||
worst case is a button that 403s, which is the right way round
|
||||
for them to fail.
|
||||
|
||||
Components should ask canDelete(user), not
|
||||
user.role === "admin". The second form is what silently locked
|
||||
superadmins out of saving when the third role went in: an
|
||||
equality check against a ladder is a bug waiting for the next
|
||||
role to be added, and there's now a fourth.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const ROLES = ["viewer", "editor", "admin", "superadmin"] as const;
|
||||
|
||||
export type Role = (typeof ROLES)[number];
|
||||
|
||||
export const ROLE_RANK: Record<Role, number> = {
|
||||
viewer: 1,
|
||||
editor: 2,
|
||||
admin: 3,
|
||||
superadmin: 4,
|
||||
};
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
viewer: "Viewer",
|
||||
editor: "Editor",
|
||||
admin: "Admin",
|
||||
superadmin: "Superadmin",
|
||||
};
|
||||
|
||||
/* Each line is what that role adds to the one above it in the
|
||||
list. Read top to bottom, they describe the whole ladder. */
|
||||
export const ROLE_NOTES: Record<Role, string> = {
|
||||
viewer: "Can read everything in the CMS and change nothing.",
|
||||
editor: "Can create and update records. Can't delete anything.",
|
||||
admin: "Can delete records, including feedback.",
|
||||
superadmin: "Can manage accounts, roles and sessions.",
|
||||
};
|
||||
|
||||
type MaybeUser = { role?: string | null } | null | undefined;
|
||||
|
||||
/* Minimum, not equality: a superadmin passes atLeast(user, "editor"). */
|
||||
export function atLeast(user: MaybeUser, role: Role): boolean {
|
||||
const have = ROLE_RANK[(user?.role ?? "") as Role] ?? 0;
|
||||
return have >= ROLE_RANK[role];
|
||||
}
|
||||
|
||||
/* Named for the capability rather than the rank, so call sites
|
||||
read as intent and a future reshuffle of the ladder is one edit
|
||||
here rather than a search for every comparison. */
|
||||
export const canWrite = (user: MaybeUser) => atLeast(user, "editor");
|
||||
export const canDelete = (user: MaybeUser) => atLeast(user, "admin");
|
||||
export const isSuper = (user: MaybeUser) => atLeast(user, "superadmin");
|
||||
390
src/lib/timeline.ts
Normal file
390
src/lib/timeline.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
/**
|
||||
* Timeline types + grouping.
|
||||
*
|
||||
* This is the contract between the future `GET /api/history` route and the
|
||||
* history page.
|
||||
*
|
||||
* ── Reference, don't duplicate ─────────────────────────────────────────
|
||||
* An entry is a *pointer* to a record plus an optional narrative override.
|
||||
* When the admin panel's "add to timeline" button fires on an event, it
|
||||
* writes a row holding the event's id and nothing else; title, logo and
|
||||
* date are read back from `events` at query time. Editing the event
|
||||
* therefore edits the timeline, and there is no second copy to drift.
|
||||
*
|
||||
* Hand-authored entries — "bylaws rewritten", "the gathering becomes
|
||||
* annual" — carry no ref and supply their own title and blurb. An entry
|
||||
* may also do both: reference an event but override its title, for when
|
||||
* the timeline wants to say something the event card doesn't.
|
||||
*
|
||||
* ── What the server resolves, and what it doesn't ──────────────────────
|
||||
* The server resolves *data*: title, date, logo filename, the members of
|
||||
* a referenced team. It does not resolve *routes* or *asset paths* —
|
||||
* those are presentation, and live in `timelineRefs.ts` so React Router
|
||||
* and the public/ layout stay the frontend's business.
|
||||
*/
|
||||
|
||||
export type DatePrecision = 'year' | 'month' | 'day'
|
||||
|
||||
/** What an entry is about. Drives the marker and the body layout. */
|
||||
export type TimelineKind =
|
||||
| 'milestone' // free-standing narrative, no record behind it
|
||||
| 'event'
|
||||
| 'organization'
|
||||
| 'award'
|
||||
| 'people' // a team forming, someone joining one
|
||||
|
||||
/** Tables an entry can point at. Mirrors the polymorphic owner_kind
|
||||
* pattern already used by content_blocks and links. */
|
||||
export type RefKind = 'event' | 'organization' | 'award' | 'person' | 'team'
|
||||
|
||||
export type TimelineRef = {
|
||||
kind: RefKind
|
||||
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Filename plus the table it came from; the directory is derived
|
||||
* frontend-side, because asset layout is not database business. */
|
||||
export type TimelineLogo = {
|
||||
file: string
|
||||
kind: RefKind
|
||||
}
|
||||
|
||||
/** A person as they appear in a 'people' entry. Resolved server-side,
|
||||
* whether the entry named a team or listed people directly. */
|
||||
export type PersonRef = {
|
||||
id: string
|
||||
name: string
|
||||
/** people.photo — filename only. */
|
||||
photo?: string
|
||||
/** Their affiliation title at the time, if it's worth printing. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type TeamRef = {
|
||||
id: string
|
||||
name: string
|
||||
orgId?: string
|
||||
orgName?: string
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export type TimelineItem = {
|
||||
/** The timeline row's own id, not the referenced record's. */
|
||||
id: string
|
||||
/** "2014" | "2014-06" | "2014-06-12" */
|
||||
date: string
|
||||
/** How much of `date` is trustworthy. Authoritative — a backfilled row
|
||||
* may hold a full date while only the year is actually known. */
|
||||
precision: DatePrecision
|
||||
kind: TimelineKind
|
||||
|
||||
/** Falls back to the referenced record's own name when the row has no
|
||||
* title of its own. Resolved server-side. */
|
||||
title: string
|
||||
blurb?: string
|
||||
/** Secondary line: host org, region, venue, recipient. */
|
||||
meta?: string
|
||||
featured?: boolean
|
||||
|
||||
/** The record this points at. Absent for free-standing milestones. */
|
||||
ref?: TimelineRef
|
||||
/** Explicit link override. Absent → derived from `ref`. Every kind can
|
||||
* carry one; event/organization/award fall back to their own page. */
|
||||
href?: string
|
||||
|
||||
logo?: TimelineLogo
|
||||
|
||||
/** kind === 'people': who the entry is about. Populated from the named
|
||||
* team's current members, or from an explicit person list. */
|
||||
people?: PersonRef[]
|
||||
/** Set when the entry named a team rather than loose people. */
|
||||
team?: TeamRef
|
||||
}
|
||||
|
||||
export type DecadeMeta = {
|
||||
/** 2010, 2020, … */
|
||||
decade: number
|
||||
title: string
|
||||
tagline: string
|
||||
blurb?: string
|
||||
/** Renders the ghosted treatment and the "before NGU" marker. */
|
||||
preProgram?: boolean
|
||||
}
|
||||
|
||||
export type GroupedMonth = {
|
||||
month: number
|
||||
label: string
|
||||
items: TimelineItem[]
|
||||
}
|
||||
|
||||
export type GroupedYear = {
|
||||
year: number
|
||||
featured: boolean
|
||||
count: number
|
||||
featuredItems: TimelineItem[]
|
||||
/** Year-precision items — known to be this year, month unknown. */
|
||||
undated: TimelineItem[]
|
||||
months: GroupedMonth[]
|
||||
}
|
||||
|
||||
export type GroupedDecade = DecadeMeta & {
|
||||
years: GroupedYear[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export type SortDirection = 'desc' | 'asc'
|
||||
|
||||
export const MONTH_LABELS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
export function decadeOf(year: number): number {
|
||||
return Math.floor(year / 10) * 10
|
||||
}
|
||||
|
||||
export function decadeLabel(decade: number): string {
|
||||
return `${decade}s`
|
||||
}
|
||||
|
||||
// ── dates ────────────────────────────────────────────────────────────
|
||||
|
||||
type DateParts = { year: number; month: number | null; day: number | null }
|
||||
|
||||
function parseDate(item: TimelineItem): DateParts {
|
||||
const [y, m, d] = item.date.split('-')
|
||||
const year = Number(y)
|
||||
if (!Number.isFinite(year)) {
|
||||
throw new Error(`Timeline item ${item.id} has an unparseable date: "${item.date}"`)
|
||||
}
|
||||
if (item.precision === 'year') return { year, month: null, day: null }
|
||||
const month = m ? Number(m) : null
|
||||
if (item.precision === 'month') return { year, month, day: null }
|
||||
return { year, month, day: d ? Number(d) : null }
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* Start of the item's date window, as a sortable YYYY-MM-DD.
|
||||
*
|
||||
* A year-precision item resolves to 1 January, a month-precision one to
|
||||
* the 1st. That makes "is this still upcoming?" answerable for imprecise
|
||||
* dates in the one way that can't surprise anyone: an entry stops being
|
||||
* upcoming as soon as any part of its window has passed. A row dated
|
||||
* only "2027" is upcoming through the end of 2026 and no longer is on
|
||||
* 1 January 2027, even though its real date may be months away.
|
||||
*/
|
||||
export function windowStart(item: TimelineItem): string {
|
||||
const { year, month, day } = parseDate(item)
|
||||
return `${year}-${pad(month ?? 1)}-${pad(day ?? 1)}`
|
||||
}
|
||||
|
||||
export function todayISO(now: Date = new Date()): string {
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Split upcoming from recorded, off the wall clock rather than a flag.
|
||||
* Nothing needs flipping when a date passes.
|
||||
*/
|
||||
export function partitionByDate(
|
||||
items: TimelineItem[],
|
||||
now: Date = new Date(),
|
||||
): { upcoming: TimelineItem[]; past: TimelineItem[] } {
|
||||
const today = todayISO(now)
|
||||
const upcoming: TimelineItem[] = []
|
||||
const past: TimelineItem[] = []
|
||||
for (const item of items) {
|
||||
if (windowStart(item) > today) upcoming.push(item)
|
||||
else past.push(item)
|
||||
}
|
||||
return { upcoming, past }
|
||||
}
|
||||
|
||||
// ── grouping ─────────────────────────────────────────────────────────
|
||||
|
||||
function byDay(dir: SortDirection) {
|
||||
return (a: TimelineItem, b: TimelineItem) => {
|
||||
const da = parseDate(a).day
|
||||
const db = parseDate(b).day
|
||||
if (da == null && db == null) return a.title.localeCompare(b.title)
|
||||
if (da == null) return 1
|
||||
if (db == null) return -1
|
||||
return dir === 'desc' ? db - da : da - db
|
||||
}
|
||||
}
|
||||
|
||||
function byFeaturedThenDay(dir: SortDirection) {
|
||||
const day = byDay(dir)
|
||||
return (a: TimelineItem, b: TimelineItem) => {
|
||||
if (!!a.featured !== !!b.featured) return a.featured ? -1 : 1
|
||||
return day(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
export type GroupOptions = {
|
||||
direction?: SortDirection
|
||||
/**
|
||||
* Decades ending before this year get the pre-program treatment even
|
||||
* if the decade row doesn't say so. Lets the gap survive missing
|
||||
* metadata.
|
||||
*/
|
||||
programStartYear?: number
|
||||
/**
|
||||
* Repeat featured items inside their month node as well as in the
|
||||
* featured block. Off by default — in a sparse year it just prints the
|
||||
* same line twice. A month left with nothing but featured items drops
|
||||
* out entirely.
|
||||
*/
|
||||
featuredInMonths?: boolean
|
||||
}
|
||||
|
||||
/** Bucket a flat item list into years. Shared by the main rail and the
|
||||
* upcoming block above it. */
|
||||
export function groupYears(
|
||||
items: TimelineItem[],
|
||||
options: GroupOptions = {},
|
||||
): GroupedYear[] {
|
||||
const direction = options.direction ?? 'desc'
|
||||
const featuredInMonths = options.featuredInMonths ?? false
|
||||
const sign = direction === 'desc' ? -1 : 1
|
||||
|
||||
const yearBuckets = new Map<number, TimelineItem[]>()
|
||||
for (const item of items) {
|
||||
const { year } = parseDate(item)
|
||||
const bucket = yearBuckets.get(year)
|
||||
if (bucket) bucket.push(item)
|
||||
else yearBuckets.set(year, [item])
|
||||
}
|
||||
|
||||
const years: GroupedYear[] = []
|
||||
|
||||
for (const [year, yearItems] of yearBuckets) {
|
||||
const featuredItems: TimelineItem[] = []
|
||||
const undated: TimelineItem[] = []
|
||||
const monthMap = new Map<number, TimelineItem[]>()
|
||||
|
||||
for (const item of yearItems) {
|
||||
if (item.featured) {
|
||||
featuredItems.push(item)
|
||||
if (!featuredInMonths) continue
|
||||
}
|
||||
const { month } = parseDate(item)
|
||||
if (month == null) {
|
||||
undated.push(item)
|
||||
continue
|
||||
}
|
||||
const bucket = monthMap.get(month)
|
||||
if (bucket) bucket.push(item)
|
||||
else monthMap.set(month, [item])
|
||||
}
|
||||
|
||||
const months: GroupedMonth[] = [...monthMap.entries()]
|
||||
.sort((a, b) => sign * (a[0] - b[0]))
|
||||
.map(([month, monthItems]) => ({
|
||||
month,
|
||||
label: MONTH_LABELS[month - 1] ?? `Month ${month}`,
|
||||
items: monthItems.sort(byFeaturedThenDay(direction)),
|
||||
}))
|
||||
|
||||
featuredItems.sort(byDay(direction))
|
||||
undated.sort((a, b) => a.title.localeCompare(b.title))
|
||||
|
||||
years.push({
|
||||
year,
|
||||
featured: featuredItems.length > 0,
|
||||
count: yearItems.length,
|
||||
featuredItems,
|
||||
undated,
|
||||
months,
|
||||
})
|
||||
}
|
||||
|
||||
return years.sort((a, b) => sign * (a.year - b.year))
|
||||
}
|
||||
|
||||
export function groupTimeline(
|
||||
items: TimelineItem[],
|
||||
decades: DecadeMeta[],
|
||||
options: GroupOptions = {},
|
||||
): GroupedDecade[] {
|
||||
const direction = options.direction ?? 'desc'
|
||||
const sign = direction === 'desc' ? -1 : 1
|
||||
const metaByDecade = new Map(decades.map((d) => [d.decade, d]))
|
||||
|
||||
const decadeBuckets = new Map<number, GroupedYear[]>()
|
||||
for (const year of groupYears(items, options)) {
|
||||
const dec = decadeOf(year.year)
|
||||
const bucket = decadeBuckets.get(dec)
|
||||
if (bucket) bucket.push(year)
|
||||
else decadeBuckets.set(dec, [year])
|
||||
}
|
||||
|
||||
// Include decades that have metadata but no items yet, so an authored
|
||||
// "before NGU" decade still renders its marker.
|
||||
for (const meta of decades) {
|
||||
if (!decadeBuckets.has(meta.decade)) decadeBuckets.set(meta.decade, [])
|
||||
}
|
||||
|
||||
return [...decadeBuckets.entries()]
|
||||
.sort((a, b) => sign * (a[0] - b[0]))
|
||||
.map(([decade, years]) => {
|
||||
const meta = metaByDecade.get(decade)
|
||||
const inferredPreProgram =
|
||||
options.programStartYear != null && decade + 9 < options.programStartYear
|
||||
return {
|
||||
decade,
|
||||
title: meta?.title ?? decadeLabel(decade),
|
||||
tagline: meta?.tagline ?? '',
|
||||
blurb: meta?.blurb,
|
||||
preProgram: meta?.preProgram ?? inferredPreProgram,
|
||||
years: years.sort((a, b) => sign * (a.year - b.year)),
|
||||
count: years.reduce((sum, y) => sum + y.count, 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert empty year nodes between the first and last year that actually
|
||||
* has data, so sparse decades read as gaps in the record rather than as
|
||||
* a shorter decade. Does not pad beyond the data.
|
||||
*/
|
||||
export function withGapYears(
|
||||
years: GroupedYear[],
|
||||
direction: SortDirection = 'desc',
|
||||
): GroupedYear[] {
|
||||
if (years.length < 2) return years
|
||||
|
||||
const present = new Map(years.map((y) => [y.year, y]))
|
||||
const all = years.map((y) => y.year)
|
||||
const min = Math.min(...all)
|
||||
const max = Math.max(...all)
|
||||
const filled: GroupedYear[] = []
|
||||
|
||||
for (let year = min; year <= max; year += 1) {
|
||||
filled.push(
|
||||
present.get(year) ?? {
|
||||
year,
|
||||
featured: false,
|
||||
count: 0,
|
||||
featuredItems: [],
|
||||
undated: [],
|
||||
months: [],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return direction === 'desc' ? filled.reverse() : filled
|
||||
}
|
||||
|
||||
/** Years that should start expanded: the most recent year with featured items. */
|
||||
export function defaultOpenYears(decades: GroupedDecade[]): number[] {
|
||||
for (const decade of decades) {
|
||||
if (decade.preProgram) continue
|
||||
const hit = decade.years.find((y) => y.featured)
|
||||
if (hit) return [hit.year]
|
||||
}
|
||||
return []
|
||||
}
|
||||
75
src/lib/timelineRefs.ts
Normal file
75
src/lib/timelineRefs.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
TIMELINE REFS
|
||||
|
||||
Turns a timeline item into a destination and an image. Both were
|
||||
answered locally here before, which is how the timeline came to
|
||||
link events at /event/:id while the router only knew about
|
||||
/retreats/:id — two files holding the same opinion, one of them
|
||||
wrong, neither aware of the other.
|
||||
|
||||
Now this file knows about timeline items and nothing else. Where
|
||||
a record lives is hrefs.ts; where an image lives is media.ts.
|
||||
|
||||
── The shape this reads, from history.js ──
|
||||
|
||||
item.href explicit link_url override, may be off-site
|
||||
item.ref { kind, id, orgKind? } — orgKind only on
|
||||
organizations, because only they have it
|
||||
item.logo { file, kind } — v_timeline COALESCEs the
|
||||
filename across five tables, so the kind is
|
||||
what says which directory it came from
|
||||
item.team { id, name, orgId? } on a team ref
|
||||
item.people[] { id, name, photo?, title? }
|
||||
|
||||
Every one of those is optional. An entry is a standalone
|
||||
milestone until proven otherwise, and the two accessors below
|
||||
return null rather than assuming a shape that isn't there —
|
||||
which is the other half of the /organizations/undefined bug:
|
||||
reaching into a ref that wasn't sent yields undefined, and
|
||||
undefined interpolates into a path perfectly happily.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { refHref } from './hrefs.ts'
|
||||
import { logoForKind, personPhoto } from './media.ts'
|
||||
import type { TimelineItem } from './timeline.ts'
|
||||
|
||||
/**
|
||||
* Where this entry points, or null if nowhere.
|
||||
*
|
||||
* An explicit link_url wins: it's the editor deliberately
|
||||
* overriding the record's own page, usually to send someone to an
|
||||
* external write-up. TimelineEntry checks for a scheme and renders
|
||||
* an <a> instead of a <Link>, so this returns it unchanged.
|
||||
*
|
||||
* Otherwise the reference decides, and refHref returns null for a
|
||||
* kind with no page yet ('person', until /people/:id exists) as
|
||||
* well as for a ref that didn't resolve. Null means the entry
|
||||
* renders as a plain <div> — right for a milestone, and right for
|
||||
* a reference that's missing an id, which used to render as a link
|
||||
* to a 404.
|
||||
*/
|
||||
export function hrefFor(item: TimelineItem): string | null {
|
||||
if (item.href) return item.href
|
||||
|
||||
const ref = item.ref
|
||||
if (!ref) return null
|
||||
|
||||
return refHref(ref.kind, ref.id, ref.orgKind)
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry's image, resolved against the directory for whatever
|
||||
* kind of record the filename came from.
|
||||
*/
|
||||
export function logoSrc(item: TimelineItem): string | null {
|
||||
if (!item.logo) return null
|
||||
return logoForKind(item.logo.kind, item.logo.file)
|
||||
}
|
||||
|
||||
/**
|
||||
* A roster member's photo. Same rule as everywhere else — the API
|
||||
* sends a bare filename.
|
||||
*/
|
||||
export function photoSrc(photo?: string | null): string | null {
|
||||
return personPhoto(photo)
|
||||
}
|
||||
243
src/lib/useContent.ts
Normal file
243
src/lib/useContent.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* The four detail endpoints, typed.
|
||||
*
|
||||
* Adding a fifth is a type and a one-line hook — useRecord owns
|
||||
* the fetching, the caching and the 404.
|
||||
*
|
||||
* An id that is missing — or the literal string "undefined", which
|
||||
* is what a template literal makes of a missing route param — never
|
||||
* reaches the network. detailPath returns null and the hook reports
|
||||
* notFound, which is what the visitor should see anyway, and
|
||||
* hrefs.ts has already logged the component that produced it.
|
||||
*/
|
||||
|
||||
import { detailPath } from './hrefs.ts'
|
||||
import { useRecord, type Resource } from './useRecord.ts'
|
||||
import type { EventType } from './eventTypes.ts'
|
||||
|
||||
/* ── Shared shapes ───────────────────────────────────────────── */
|
||||
|
||||
/** A content_blocks row with its `items` child, as shape.js sends it. */
|
||||
export type ContentBlock = {
|
||||
id?: number | string
|
||||
slot?: 'card' | 'body'
|
||||
type:
|
||||
| 'heading'
|
||||
| 'subheading'
|
||||
| 'paragraph'
|
||||
| 'list'
|
||||
| 'links'
|
||||
| 'quote'
|
||||
| 'image'
|
||||
| 'divider'
|
||||
text?: string | null
|
||||
media?: string | null
|
||||
href?: string | null
|
||||
items?: Array<{ text: string; detail?: string | null; url?: string | null }>
|
||||
}
|
||||
|
||||
export type Link = {
|
||||
kind?: string
|
||||
platform?: string | null
|
||||
label: string
|
||||
url: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
export type OrgRef = { id: string; name: string; kind?: string | null }
|
||||
|
||||
/* One host of an event. `kind` says which table the id is in;
|
||||
`org_kind` is the region/chapter/partner split that decides an
|
||||
organization's route, and is null for a person. The triple is
|
||||
exactly what refHref() in hrefs.ts takes. */
|
||||
export type EventHost = {
|
||||
kind: 'organization' | 'person'
|
||||
id: string
|
||||
name: string
|
||||
org_kind?: string | null
|
||||
}
|
||||
|
||||
/* ── Events ──────────────────────────────────────────────────── */
|
||||
|
||||
export type EventPerson = {
|
||||
person_id: string
|
||||
display_name: string
|
||||
pronouns?: string | null
|
||||
tagline?: string | null
|
||||
photo?: string | null
|
||||
role?: string | null
|
||||
title?: string | null
|
||||
}
|
||||
|
||||
export type EventAward = {
|
||||
award: { id: string; name: string; logo?: string | null }
|
||||
person: { id: string; name: string; photo?: string | null }
|
||||
awarded_on?: string | null
|
||||
citation?: string | null
|
||||
}
|
||||
|
||||
export type EventRecord = {
|
||||
id: string
|
||||
/** Which band of the Retreats page this belongs to. */
|
||||
section_id: string
|
||||
/** What kind of gathering it is. Orthogonal to section_id. */
|
||||
event_type: EventType
|
||||
title: string
|
||||
theme?: string | null
|
||||
tagline?: string | null
|
||||
starts_on?: string | null
|
||||
ends_on?: string | null
|
||||
date_label?: string | null
|
||||
status: 'upcoming' | 'past' | 'cancelled'
|
||||
location_label?: string | null
|
||||
locality?: string | null
|
||||
state_code?: string | null
|
||||
country?: string | null
|
||||
is_online: boolean
|
||||
org_logo?: string | null
|
||||
event_logo?: string | null
|
||||
color?: string | null
|
||||
gradient?: string | null
|
||||
/* In billing order. The first is the one `color` and `org_logo`
|
||||
fell back to when the event set neither. */
|
||||
hosts: EventHost[]
|
||||
description: string[]
|
||||
links: Link[]
|
||||
instagram?: Link | null
|
||||
blocks: ContentBlock[]
|
||||
people: EventPerson[]
|
||||
awards: EventAward[]
|
||||
}
|
||||
|
||||
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||
|
||||
/* ── Organizations ───────────────────────────────────────────── */
|
||||
|
||||
export type Leader = {
|
||||
person_id: string
|
||||
display_name: string
|
||||
pronouns?: string | null
|
||||
title?: string | null
|
||||
role?: string | null
|
||||
is_owner: boolean
|
||||
photo?: string | null
|
||||
public_email?: string | null
|
||||
team_id?: string | null
|
||||
team_name?: string | null
|
||||
}
|
||||
|
||||
export type OrgTeam = {
|
||||
id: string
|
||||
name: string
|
||||
tagline?: string | null
|
||||
color?: string | null
|
||||
logo?: string | null
|
||||
}
|
||||
|
||||
export type OrgAward = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
logo?: string | null
|
||||
recipient_count: number
|
||||
}
|
||||
|
||||
export type OrgEvent = {
|
||||
id: string
|
||||
title: string
|
||||
event_type: EventType
|
||||
date_label?: string | null
|
||||
status: string
|
||||
location_label?: string | null
|
||||
event_logo?: string | null
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
export type OrganizationRecord = {
|
||||
id: string
|
||||
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||
name: string
|
||||
short_name?: string | null
|
||||
tagline?: string | null
|
||||
color?: string | null
|
||||
logo?: string | null
|
||||
venue?: string | null
|
||||
address?: string | null
|
||||
locality?: string | null
|
||||
state_code?: string | null
|
||||
country?: string | null
|
||||
location_label?: string | null
|
||||
is_online: boolean
|
||||
description: string[]
|
||||
blocks: ContentBlock[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
website?: Link | null
|
||||
email?: Link | null
|
||||
instagram?: Link | null
|
||||
/** Shape depends on `kind`; empty object for national and partner. */
|
||||
details: {
|
||||
scope?: string | null
|
||||
map_note?: string | null
|
||||
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
||||
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||
region_id?: string | null
|
||||
region_name?: string | null
|
||||
region_color?: string | null
|
||||
meets?: string | null
|
||||
started?: string | null
|
||||
}
|
||||
leadership: Leader[]
|
||||
teams: OrgTeam[]
|
||||
awards: OrgAward[]
|
||||
events: OrgEvent[]
|
||||
}
|
||||
|
||||
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||
|
||||
/* ── Teams ───────────────────────────────────────────────────── */
|
||||
|
||||
/** No members here on purpose — PeopleTiles fetches
|
||||
* /teams/:id/people itself. See the note in content.js. */
|
||||
export type TeamRecord = {
|
||||
id: string
|
||||
name: string
|
||||
tagline?: string | null
|
||||
color?: string | null
|
||||
logo?: string | null
|
||||
org: OrgRef
|
||||
description: string[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
instagram?: Link | null
|
||||
blocks: ContentBlock[]
|
||||
}
|
||||
|
||||
export const useTeam = (id?: string): Resource<TeamRecord> =>
|
||||
useRecord<TeamRecord>(detailPath('/teams', id), 'team')
|
||||
|
||||
/* ── Awards ──────────────────────────────────────────────────── */
|
||||
|
||||
export type Recipient = {
|
||||
id: string
|
||||
name: string
|
||||
photo?: string | null
|
||||
tagline?: string | null
|
||||
awarded_on?: string | null
|
||||
citation?: string | null
|
||||
event: { id: string; title: string } | null
|
||||
}
|
||||
|
||||
export type AwardRecord = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
logo?: string | null
|
||||
org: OrgRef | null
|
||||
recipients: Recipient[]
|
||||
}
|
||||
|
||||
export const useAward = (id?: string): Resource<AwardRecord> =>
|
||||
useRecord<AwardRecord>(detailPath('/awards', id), 'award')
|
||||
87
src/lib/useHistory.ts
Normal file
87
src/lib/useHistory.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Loads the history timeline from `GET /api/history`.
|
||||
*
|
||||
* Goes through the shared client, so the request is deduped and cached
|
||||
* for 60s like every other read. Two consequences worth knowing:
|
||||
*
|
||||
* · `reload` has to invalidate before it refetches. Without that, the
|
||||
* retry button inside the TTL would hand back the same settled
|
||||
* promise and look like it did nothing. A *failed* request is
|
||||
* already evicted by the client, so this matters for the refresh
|
||||
* case rather than the error case.
|
||||
*
|
||||
* · there's no AbortController. `get` shares one promise between
|
||||
* callers, so aborting on unmount would cancel someone else's
|
||||
* request. The `live` flag drops the result instead.
|
||||
*
|
||||
* No `fallback` on purpose. Handing this the mock data would render a
|
||||
* plausible-looking history with no indication the server is down, and
|
||||
* the wrong history is worse than a visible error — the same reason
|
||||
* `fallback: EMPTY` came out elsewhere.
|
||||
*
|
||||
* `undated` is the number of published entries the API left out because
|
||||
* nothing gave them a date. Not rendered publicly — a visitor can't act
|
||||
* on it — but returned so it's reachable if you want a warning in the
|
||||
* admin later.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { get, invalidate, ApiError } from './api.js'
|
||||
import type { TimelineItem } from './timeline'
|
||||
|
||||
const PATH = '/history'
|
||||
|
||||
type HistoryResponse = {
|
||||
items?: TimelineItem[]
|
||||
undated?: number
|
||||
}
|
||||
|
||||
type State = {
|
||||
items: TimelineItem[]
|
||||
undated: number
|
||||
loading: boolean
|
||||
error: string | null
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
export function useHistory(): State {
|
||||
const [items, setItems] = useState<TimelineItem[]>([])
|
||||
const [undated, setUndated] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
invalidate(PATH)
|
||||
setAttempt((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data: HistoryResponse = await get(PATH)
|
||||
if (!live) return
|
||||
setItems(Array.isArray(data?.items) ? data.items : [])
|
||||
setUndated(Number(data?.undated) || 0)
|
||||
} catch (err) {
|
||||
if (!live) return
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||
)
|
||||
} finally {
|
||||
if (live) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [attempt])
|
||||
|
||||
return { items, undated, loading, error, reload }
|
||||
}
|
||||
127
src/lib/useRecord.ts
Normal file
127
src/lib/useRecord.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* useRecord — one record from one endpoint, on the useHistory
|
||||
* pattern.
|
||||
*
|
||||
* Named for what it returns, and deliberately not useResource:
|
||||
* src/lib/useResource.js is a different hook — it hands back the
|
||||
* whole response body and takes an options object — and a .ts file
|
||||
* of the same name would sit one extension away from it. An import
|
||||
* whose target goes missing then resolves to the other file without
|
||||
* a word, and every detail page renders the wrapper object instead
|
||||
* of the record. That is exactly how this file came to exist.
|
||||
*
|
||||
* Every detail route in content.js answers the same shape: 200 with
|
||||
* a single top-level key, or 404 with `{ error }`. So the hook takes
|
||||
* the path and the key, and the four callers in useContent.ts are
|
||||
* one line each rather than four copies of this file.
|
||||
*
|
||||
* `key` is a string rather than a selector function on purpose. A
|
||||
* selector passed inline would be a new identity every render, and
|
||||
* putting it in the effect's deps would refetch forever; leaving it
|
||||
* out would silently use a stale closure. A string has neither
|
||||
* problem.
|
||||
*
|
||||
* Carried over from useHistory, and worth restating:
|
||||
*
|
||||
* · `reload` invalidates before it refetches. Without that, the
|
||||
* retry button inside the 60s TTL hands back the same settled
|
||||
* promise and looks like it did nothing. A *failed* request is
|
||||
* already evicted by api.js, so this matters for the refresh
|
||||
* case rather than the error case.
|
||||
*
|
||||
* · no AbortController. `get` shares one promise between callers,
|
||||
* so aborting on unmount would cancel someone else's request.
|
||||
* The `live` flag drops the result instead.
|
||||
*
|
||||
* · no `fallback`. Rendering plausible-looking content with no
|
||||
* sign the server is down is worse than a visible error.
|
||||
*
|
||||
* `notFound` is separated from `error` because they are different
|
||||
* pages: a 404 is a slug that doesn't exist and retrying won't help,
|
||||
* anything else is worth a Try again button.
|
||||
*
|
||||
* A null `path` means there is nothing to ask for, and the hook
|
||||
* reports notFound rather than loading forever. Callers build the
|
||||
* path with detailPath() from hrefs.ts, which returns null for an
|
||||
* id that could never be real — "undefined" chief among them.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { get, invalidate, ApiError } from './api.js'
|
||||
|
||||
export type Resource<T> = {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
notFound: boolean
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (path) invalidate(path)
|
||||
setAttempt((n) => n + 1)
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
// Nothing to ask for. Distinguished from "not asked yet" by the
|
||||
// caller: useContent passes null only when the id is unusable,
|
||||
// and an unusable id is a 404 as far as the visitor is
|
||||
// concerned.
|
||||
if (!path) {
|
||||
setData(null)
|
||||
setError(null)
|
||||
setNotFound(true)
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let live = true
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setNotFound(false)
|
||||
try {
|
||||
const body = await get(path as string)
|
||||
if (!live) return
|
||||
// A 200 with the key absent is a server-side shaping bug,
|
||||
// not an empty record. Say so rather than rendering a page
|
||||
// full of blanks.
|
||||
const record = body?.[key]
|
||||
if (record === undefined) {
|
||||
setError(`The server sent no "${key}".`)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setData(record as T)
|
||||
} catch (err) {
|
||||
if (!live) return
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setNotFound(true)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
||||
)
|
||||
setData(null)
|
||||
} finally {
|
||||
if (live) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [path, key, attempt])
|
||||
|
||||
return { data, loading, error, notFound, reload }
|
||||
}
|
||||
10
src/lib/version.ts
Normal file
10
src/lib/version.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
SITE VERSION — src/lib/version.ts
|
||||
|
||||
One string, bumped by hand when the working directory name
|
||||
changes. The admin footer is the only thing reading it today;
|
||||
keep it here rather than in a component so the panel, an about
|
||||
box or a build banner can read the same value later.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const SITE_VERSION = "NGU-Web.v1.5-history";
|
||||
|
|
@ -7,6 +7,7 @@ export const PAGE_LINKS = [
|
|||
{ label: "Community", path: "/community" },
|
||||
{ label: "Leadership", path: "/leadership" },
|
||||
{ label: "Resources", path: "/resources" },
|
||||
{ label: "History", path: "/history" },
|
||||
];
|
||||
|
||||
// Sections belong to a page, keyed by that page's path.
|
||||
|
|
@ -38,6 +39,9 @@ export const PAGE_SECTIONS = {
|
|||
{ label: "Branding & Marketing", hash: "#branding" },
|
||||
{ label: "Partner Resources", hash: "#partner" },
|
||||
],
|
||||
"/history": [
|
||||
{ label: "Timeline", hash: "#timeline" },
|
||||
],
|
||||
};
|
||||
|
||||
// Right-side actions. `variant` picks the styling, not the destination.
|
||||
|
|
|
|||
178
src/pages/AwardDetail.tsx
Normal file
178
src/pages/AwardDetail.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
AWARD DETAIL — /awards/:id
|
||||
|
||||
The thinnest of the four, by design. `awards` has no links and no
|
||||
content blocks — 'award' isn't in the owner_kind CHECK on either
|
||||
polymorphic table, and widening it means rebuilding two STRICT
|
||||
tables. `description` is the prose.
|
||||
|
||||
So the recipients are the page. They're rendered as a list rather
|
||||
than as PeopleTiles because each one carries a citation, and a
|
||||
citation is the point of an award — it doesn't belong folded
|
||||
behind a chevron.
|
||||
|
||||
Note also that `awards` has no is_published column: every row
|
||||
here is live the moment it's created. Worth an ADD COLUMN before
|
||||
this ships.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import { useAward, type Recipient } from '../lib/useContent.ts'
|
||||
import { eventHref, orgHref, personHref } from '../lib/hrefs.ts'
|
||||
import { awardLogo, initials, personPhoto } from '../lib/media.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
export default function AwardDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: award, loading, error, notFound, reload } = useAward(id)
|
||||
|
||||
if (!award) {
|
||||
return (
|
||||
<PageState
|
||||
loading={loading}
|
||||
error={error}
|
||||
notFound={notFound}
|
||||
onRetry={reload}
|
||||
noun="award"
|
||||
backTo="/community"
|
||||
backLabel="Community"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const accent = TEAL
|
||||
const logo = awardLogo(award.logo)
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={award.name}
|
||||
intro={award.description ?? undefined}
|
||||
sections={[
|
||||
{
|
||||
id: 'recipients',
|
||||
title: 'Recipients',
|
||||
blurb:
|
||||
award.recipients.length === 0
|
||||
? 'Nobody has received this yet.'
|
||||
: undefined,
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||
{logo && (
|
||||
<img src={logo} alt="" loading="lazy" className="h-12 w-12 object-contain" />
|
||||
)}
|
||||
|
||||
{award.org && (
|
||||
<span style={{ color: BODY }}>
|
||||
Given by{' '}
|
||||
<Link
|
||||
to={orgHref(award.org.id, award.org.kind)}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{award.org.name}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ color: BODY }}>
|
||||
{award.recipients.length}{' '}
|
||||
{award.recipients.length === 1 ? 'recipient' : 'recipients'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol className="space-y-8">
|
||||
{award.recipients.map((recipient, index) => (
|
||||
<li key={`${recipient.id}:${recipient.awarded_on ?? index}`}>
|
||||
<RecipientRow recipient={recipient} accent={accent} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RecipientRow({ recipient, accent }: { recipient: Recipient; accent: string }) {
|
||||
const photo = personPhoto(recipient.photo)
|
||||
|
||||
return (
|
||||
<div className="flex gap-5">
|
||||
{photo ? (
|
||||
<img
|
||||
src={photo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-16 w-16 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{initials(recipient.name)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-lg font-semibold">
|
||||
<Link
|
||||
to={personHref(recipient.id)}
|
||||
className="hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{recipient.name}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="text-sm" style={{ color: BODY }}>
|
||||
{[
|
||||
year(recipient.awarded_on),
|
||||
recipient.tagline,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
{recipient.event && (
|
||||
<>
|
||||
{(recipient.awarded_on || recipient.tagline) && ' · '}
|
||||
<Link
|
||||
to={eventHref(recipient.event.id)}
|
||||
className="hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{recipient.event.title}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{recipient.citation && (
|
||||
<p className="mt-2 max-w-2xl leading-relaxed" style={{ color: BODY }}>
|
||||
{recipient.citation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* awarded_on may be a partial date, so this takes the leading year
|
||||
rather than parsing. A full date would be false precision on a
|
||||
row backfilled from a programme booklet. */
|
||||
function year(date?: string | null): string | null {
|
||||
if (!date) return null
|
||||
const match = /^(\d{4})/.exec(date)
|
||||
return match ? match[1] : date
|
||||
}
|
||||
352
src/pages/EventDetail.tsx
Normal file
352
src/pages/EventDetail.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT DETAIL — /retreats/:id
|
||||
|
||||
Reached from the cards on Retreats.tsx. The API has already
|
||||
resolved the fallbacks — `color` is the event's own or its first
|
||||
host's, `status` is derived from the dates when nobody set it —
|
||||
so nothing here reimplements those rules.
|
||||
|
||||
Hosts arrive as a list in billing order, each one either an
|
||||
organization or a person. refHref takes the kind and works out
|
||||
the route, which is why this file doesn't branch on it.
|
||||
|
||||
People are grouped by their billing role rather than listed flat.
|
||||
A retreat with four speakers and eleven volunteers reads as two
|
||||
different things, and v_event_people already sorts within a role
|
||||
by sort_order.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||
import {
|
||||
useEvent,
|
||||
type EventHost,
|
||||
type EventPerson,
|
||||
type EventRecord,
|
||||
} from '../lib/useContent.ts'
|
||||
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
|
||||
import { personPhoto } from '../lib/media.ts'
|
||||
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
/* Billing order. A role missing from here still renders, at the
|
||||
end, under its own name — better than a speaker vanishing
|
||||
because somebody added a role to the CHECK and not to this list. */
|
||||
const ROLE_ORDER = [
|
||||
'speaker',
|
||||
'leader',
|
||||
'facilitator',
|
||||
'host',
|
||||
'musician',
|
||||
'volunteer',
|
||||
'attendee',
|
||||
] as const
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
speaker: 'Speakers',
|
||||
leader: 'Leaders',
|
||||
facilitator: 'Facilitators',
|
||||
host: 'Hosts',
|
||||
musician: 'Music',
|
||||
volunteer: 'Volunteers',
|
||||
attendee: 'Also there',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
upcoming: 'Upcoming',
|
||||
past: 'Past',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
export default function EventDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: event, loading, error, notFound, reload } = useEvent(id)
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<PageState
|
||||
loading={loading}
|
||||
error={error}
|
||||
notFound={notFound}
|
||||
onRetry={reload}
|
||||
noun="retreat"
|
||||
backTo="/retreats"
|
||||
backLabel="All retreats"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const accent = event.color || TEAL
|
||||
const groups = peopleGroups(event.people, accent)
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'about',
|
||||
title: event.theme || 'About',
|
||||
blurb: event.theme ? event.tagline ?? undefined : undefined,
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||
<Facts event={event} accent={accent} />
|
||||
|
||||
{event.description.map((paragraph, index) => (
|
||||
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<ContentBlocks blocks={event.blocks} accent={accent} />
|
||||
</div>
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{event.links.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (groups.length > 0) {
|
||||
sections.push({
|
||||
id: 'people',
|
||||
title: 'Who’s there',
|
||||
accent,
|
||||
background: '#eef9fb',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<PeopleTiles size="lg" groups={groups} accent={accent} />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (event.awards.length > 0) {
|
||||
sections.push({
|
||||
id: 'awards',
|
||||
title: 'Presented here',
|
||||
blurb: 'Awards given at this gathering.',
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||
{event.awards.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.award.id}:${entry.person.id}:${index}`}
|
||||
className="flex gap-4 border-l-2 pl-5"
|
||||
style={{ borderColor: accent }}
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold" style={{ color: accent }}>
|
||||
<Link to={awardHref(entry.award.id)} className="hover:underline">
|
||||
{entry.award.name}
|
||||
</Link>
|
||||
</p>
|
||||
<p style={{ color: BODY }}>
|
||||
<Link to={personHref(entry.person.id)} className="hover:underline">
|
||||
{entry.person.name}
|
||||
</Link>
|
||||
</p>
|
||||
{entry.citation && (
|
||||
<p className="mt-1 text-sm italic opacity-80" style={{ color: BODY }}>
|
||||
{entry.citation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={event.title}
|
||||
intro={event.theme ? event.tagline ?? undefined : event.tagline ?? undefined}
|
||||
sections={sections}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── The strip of facts under the heading ────────────────────── */
|
||||
|
||||
function Facts({ event, accent }: { event: EventRecord; accent: string }) {
|
||||
const where =
|
||||
event.location_label ||
|
||||
[event.locality, event.state_code].filter(Boolean).join(', ') ||
|
||||
(event.is_online ? 'Online' : null)
|
||||
|
||||
const when = event.date_label || dateRange(event.starts_on, event.ends_on)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||
<span
|
||||
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
|
||||
style={{
|
||||
background: event.status === 'cancelled' ? '#b3261e' : accent,
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[event.status] ?? event.status}
|
||||
</span>
|
||||
|
||||
{/* Outlined rather than filled: the status pill is the one
|
||||
thing in this strip that should read as loud, and two
|
||||
solid blocks side by side would compete. Shown
|
||||
unconditionally — a card suppresses its own type badge in
|
||||
a band of one kind, but here there is no band to make it
|
||||
redundant. */}
|
||||
{event.event_type && (
|
||||
<span
|
||||
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
{eventTypeLabel(event.event_type)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{when && <span style={{ color: BODY }}>{when}</span>}
|
||||
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||
{event.is_online && where !== 'Online' && (
|
||||
<span style={{ color: BODY }}>Online too</span>
|
||||
)}
|
||||
|
||||
{(event.hosts?.length ?? 0) > 0 && (
|
||||
<span style={{ color: BODY }}>
|
||||
Hosted by <HostList hosts={event.hosts} accent={accent} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link to="/retreats" className="ml-auto hover:underline" style={{ color: accent }}>
|
||||
All retreats
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* One host reads as "Hosted by Northwest"; several read as a
|
||||
sentence, so they're joined with commas and an "and" rather than
|
||||
stacked. A host whose id didn't resolve to a route renders as
|
||||
plain text — refHref returns null for that — because a dead link
|
||||
is worse than a name. */
|
||||
function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) {
|
||||
return (
|
||||
<>
|
||||
{hosts.map((host, index) => {
|
||||
const to = refHref(host.kind, host.id, host.org_kind)
|
||||
|
||||
return (
|
||||
<span key={`${host.kind}:${host.id}`}>
|
||||
{index > 0 && (hosts.length > 2 ? ', ' : ' ')}
|
||||
{index > 0 && index === hosts.length - 1 && 'and '}
|
||||
{to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{host.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">{host.name}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* date_label is what a card shows and is free text — "March/April
|
||||
2026" is legitimate. This is only the fallback for an event that
|
||||
has dates and no label. */
|
||||
function dateRange(start?: string | null, end?: string | null): string | null {
|
||||
if (!start) return null
|
||||
const from = new Date(`${start}T00:00:00`)
|
||||
if (Number.isNaN(from.getTime())) return start
|
||||
|
||||
const full: Intl.DateTimeFormatOptions = {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}
|
||||
|
||||
if (!end || end === start) return from.toLocaleDateString(undefined, full)
|
||||
|
||||
const to = new Date(`${end}T00:00:00`)
|
||||
if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full)
|
||||
|
||||
const sameYear = from.getFullYear() === to.getFullYear()
|
||||
const sameMonth = sameYear && from.getMonth() === to.getMonth()
|
||||
|
||||
const left = from.toLocaleDateString(
|
||||
undefined,
|
||||
sameMonth
|
||||
? { month: 'long', day: 'numeric' }
|
||||
: sameYear
|
||||
? { month: 'long', day: 'numeric' }
|
||||
: full,
|
||||
)
|
||||
|
||||
return `${left} – ${to.toLocaleDateString(undefined, full)}`
|
||||
}
|
||||
|
||||
/* ── v_event_people → PeopleTiles groups ─────────────────────── */
|
||||
|
||||
function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] {
|
||||
if (!people.length) return []
|
||||
|
||||
const byRole = new Map<string, EventPerson[]>()
|
||||
for (const person of people) {
|
||||
const role = person.role || 'attendee'
|
||||
const list = byRole.get(role)
|
||||
if (list) list.push(person)
|
||||
else byRole.set(role, [person])
|
||||
}
|
||||
|
||||
// Known roles in billing order, then anything the CHECK has
|
||||
// gained since this file was written.
|
||||
const roles = [
|
||||
...ROLE_ORDER.filter((role) => byRole.has(role)),
|
||||
...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)),
|
||||
]
|
||||
|
||||
return roles.map((role) => ({
|
||||
id: `role-${role}`,
|
||||
label: ROLE_LABEL[role] ?? capitalize(role),
|
||||
accent,
|
||||
people: (byRole.get(role) ?? []).map((person) => ({
|
||||
id: person.person_id,
|
||||
name: person.display_name,
|
||||
title: person.title,
|
||||
tagline: person.tagline,
|
||||
pronouns: person.pronouns,
|
||||
// PeopleTiles resolves a bare filename itself; this is here
|
||||
// so a hand-written entry elsewhere can't diverge.
|
||||
photo: personPhoto(person.photo),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
|
||||
84
src/pages/History.tsx
Normal file
84
src/pages/History.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import PageShell from "../components/PageShell";
|
||||
import HistoryTimeline from "./sections/history/HistoryTimeline";
|
||||
import { HISTORY_DECADES } from "../data/historyDecades";
|
||||
import { useHistory } from "../lib/useHistory";
|
||||
|
||||
/**
|
||||
* Year the program starts. Decades ending before this render with the
|
||||
* pre-program treatment, so the gap moves if the founding date is ever
|
||||
* corrected — no hardcoded 2000 anywhere in the components.
|
||||
*/
|
||||
const PROGRAM_START_YEAR = 2000;
|
||||
|
||||
const ACCENT = "#138ba0";
|
||||
const BACKGROUND = "#ffffff";
|
||||
|
||||
export default function History() {
|
||||
const { items, loading, error, reload } = useHistory();
|
||||
|
||||
// Section renders `content` outside its own max-width wrapper, so the
|
||||
// container lives here. The custom properties bind the timeline to
|
||||
// this section's accent and background — --tl-surface must match
|
||||
// `background` or the rail will show through the year dots.
|
||||
const wrap = (children: React.ReactNode) => (
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-6"
|
||||
style={{ "--tl-accent": ACCENT, "--tl-surface": BACKGROUND } as React.CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (loading) {
|
||||
content = wrap(
|
||||
<p className="py-12 text-[#4a6b72]" role="status">
|
||||
Loading the timeline…
|
||||
</p>,
|
||||
);
|
||||
} else if (error) {
|
||||
// Say what failed and offer the one action that might fix it.
|
||||
// A history page that silently renders nothing looks like an
|
||||
// organization with no history.
|
||||
content = wrap(
|
||||
<div className="py-12">
|
||||
<p className="text-[#b3261e]">Couldn’t load the timeline. {error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
content = wrap(
|
||||
<HistoryTimeline
|
||||
items={items}
|
||||
decades={HISTORY_DECADES}
|
||||
direction="desc"
|
||||
programStartYear={PROGRAM_START_YEAR}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="History"
|
||||
intro="A near-complete timeline of Next Generation of Unity (NGU), and other Unity Young Adult programs."
|
||||
sections={[
|
||||
{
|
||||
id: "timeline",
|
||||
title: "NGU Timeline",
|
||||
blurb:
|
||||
"Open a year to see what happened.",
|
||||
accent: ACCENT,
|
||||
background: BACKGROUND,
|
||||
content,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
438
src/pages/OrganizationDetail.tsx
Normal file
438
src/pages/OrganizationDetail.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ORGANIZATION DETAIL
|
||||
/regions/:id /chapters/:id /partners/:id /organizations/:id
|
||||
|
||||
One component behind four routes, because organizations are one
|
||||
table. The kind decides which extras render, not which file runs.
|
||||
|
||||
The URL kind is decoration — the slug is what identifies the
|
||||
record — so a request for /chapters/great-lakes when that slug is
|
||||
a region redirects to the canonical path rather than rendering a
|
||||
correct page at a wrong address.
|
||||
|
||||
Leadership arrives flat with team_id and team_name on each row,
|
||||
so grouping costs nothing. `teams` is fetched alongside anyway:
|
||||
a team with no current public members would otherwise be
|
||||
invisible here instead of listed, and its page unreachable.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||
import PeopleTiles from '../components/PeopleTiles.tsx'
|
||||
import EventListCards from './sections/EventList-Cards.tsx'
|
||||
import {
|
||||
useOrganization,
|
||||
type Leader,
|
||||
type OrganizationRecord,
|
||||
} from '../lib/useContent.ts'
|
||||
import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts'
|
||||
import { orgLogo, personPhoto } from '../lib/media.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
export default function OrganizationDetail() {
|
||||
const { id } = useParams()
|
||||
const { pathname } = useLocation()
|
||||
const { data: org, loading, error, notFound, reload } = useOrganization(id)
|
||||
|
||||
if (!org) {
|
||||
return (
|
||||
<PageState
|
||||
loading={loading}
|
||||
error={error}
|
||||
notFound={notFound}
|
||||
onRetry={reload}
|
||||
noun="organization"
|
||||
backTo="/community"
|
||||
backLabel="Community"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// /chapters/x when x is a region: same record, wrong address.
|
||||
//
|
||||
// useLocation, not window.location: the latter is outside the
|
||||
// router's awareness — always "/" under HashRouter, and free to
|
||||
// be stale mid-navigation, either of which turns this into a
|
||||
// redirect loop rather than a one-shot correction.
|
||||
//
|
||||
// Guarded on org.id because a record with no id would redirect to
|
||||
// /chapters/undefined, which is a worse page than the one we're
|
||||
// already on. All four organization routes must be registered or
|
||||
// this redirects somewhere nothing matches.
|
||||
const canonical = org.id ? orgHref(org.id, org.kind) : null
|
||||
if (canonical && decodeURIComponent(pathname) !== canonical) {
|
||||
return <Navigate to={canonical} replace />
|
||||
}
|
||||
|
||||
const accent = org.color || TEAL
|
||||
const back = orgListHref(org.kind)
|
||||
|
||||
const sections: any[] = [
|
||||
{
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||
<Facts org={org} accent={accent} back={back} />
|
||||
|
||||
{org.description.map((paragraph, index) => (
|
||||
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<ContentBlocks blocks={org.blocks} accent={accent} />
|
||||
</div>
|
||||
|
||||
<Contact org={org} accent={accent} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const teamBlocks = groupLeadership(org)
|
||||
|
||||
if (teamBlocks.length > 0) {
|
||||
sections.push({
|
||||
id: 'leadership',
|
||||
title: 'Who runs it',
|
||||
accent,
|
||||
background: '#eef9fb',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-12">
|
||||
{teamBlocks.map((block) => (
|
||||
<div key={block.key}>
|
||||
<div className="mb-4">
|
||||
{block.teamId ? (
|
||||
<h3 className="text-xl font-bold">
|
||||
<Link
|
||||
to={teamHref(block.teamId)}
|
||||
className="hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{block.label}
|
||||
</Link>
|
||||
</h3>
|
||||
) : (
|
||||
block.label && (
|
||||
<h3 className="text-xl font-bold" style={{ color: accent }}>
|
||||
{block.label}
|
||||
</h3>
|
||||
)
|
||||
)}
|
||||
{block.tagline && (
|
||||
<p className="mt-1 text-sm" style={{ color: BODY }}>
|
||||
{block.tagline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{block.people.length > 0 ? (
|
||||
<PeopleTiles
|
||||
size="lg"
|
||||
accent={block.accent || accent}
|
||||
people={block.people.map((leader) => ({
|
||||
id: leader.person_id,
|
||||
name: leader.display_name,
|
||||
title: leader.title,
|
||||
pronouns: leader.pronouns,
|
||||
public_email: leader.public_email,
|
||||
photo: personPhoto(leader.photo),
|
||||
is_owner: leader.is_owner,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm italic" style={{ color: BODY }}>
|
||||
No members listed yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (org.kind === 'region' && (org.details.chapters?.length ?? 0) > 0) {
|
||||
sections.push({
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
blurb: org.details.map_note ?? undefined,
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(org.details.chapters ?? []).map((chapter) => (
|
||||
<Link
|
||||
key={chapter.id}
|
||||
to={orgHref(chapter.id, 'chapter')}
|
||||
className="flex items-center gap-4 rounded-lg border p-4 transition-colors hover:bg-[#eef9fb]"
|
||||
style={{ borderColor: `${accent}40` }}
|
||||
>
|
||||
<Logo file={chapter.logo} name={chapter.name} accent={accent} />
|
||||
<span>
|
||||
<span className="block font-semibold" style={{ color: accent }}>
|
||||
{chapter.name}
|
||||
</span>
|
||||
{chapter.location_label && (
|
||||
<span className="block text-sm" style={{ color: BODY }}>
|
||||
{chapter.location_label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (org.awards.length > 0) {
|
||||
sections.push({
|
||||
id: 'awards',
|
||||
title: 'Awards',
|
||||
blurb: `Given by ${org.short_name || org.name}.`,
|
||||
accent,
|
||||
background: '#eef9fb',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2">
|
||||
{org.awards.map((award) => (
|
||||
<Link
|
||||
key={award.id}
|
||||
to={`/awards/${award.id}`}
|
||||
className="rounded-lg border bg-white p-5 transition-colors hover:bg-[#f6fbfc]"
|
||||
style={{ borderColor: `${accent}40` }}
|
||||
>
|
||||
<span className="block font-semibold" style={{ color: accent }}>
|
||||
{award.name}
|
||||
</span>
|
||||
{award.description && (
|
||||
<span className="mt-1 block text-sm" style={{ color: BODY }}>
|
||||
{award.description}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-3 block text-xs uppercase tracking-wide opacity-70" style={{ color: BODY }}>
|
||||
{award.recipient_count === 0
|
||||
? 'No recipients yet'
|
||||
: `${award.recipient_count} recipient${award.recipient_count === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// EventList-Cards fetches and renders this itself — its own
|
||||
// docstring offers `host` for exactly this case, and a card there
|
||||
// already knows about gradients, logos, past-event collapsing and
|
||||
// the carousel. A second grid here was the same component written
|
||||
// worse.
|
||||
//
|
||||
// `org.events` is still what decides whether the section exists at
|
||||
// all: an organization that has never hosted anything shouldn't
|
||||
// get a heading followed by "events coming soon".
|
||||
if (org.events.length > 0) {
|
||||
sections.push({
|
||||
id: 'events',
|
||||
title: 'Gatherings',
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<EventListCards
|
||||
host={org.id}
|
||||
view="grid"
|
||||
accent={accent}
|
||||
empty="· Nothing on the calendar right now ·"
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return <PageShell title={org.name} intro={org.tagline ?? undefined} sections={sections} />
|
||||
}
|
||||
|
||||
/* ── Pieces ──────────────────────────────────────────────────── */
|
||||
|
||||
function Facts({
|
||||
org,
|
||||
accent,
|
||||
back,
|
||||
}: {
|
||||
org: OrganizationRecord
|
||||
accent: string
|
||||
back: { to: string; label: string }
|
||||
}) {
|
||||
const where =
|
||||
org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||
|
||||
{org.kind === 'region' && org.details.scope && (
|
||||
<span style={{ color: BODY }}>{org.details.scope}</span>
|
||||
)}
|
||||
|
||||
{org.kind === 'chapter' && org.details.region_id && (
|
||||
<span style={{ color: BODY }}>
|
||||
Part of{' '}
|
||||
<Link
|
||||
to={orgHref(org.details.region_id, 'region')}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: org.details.region_color || accent }}
|
||||
>
|
||||
{org.details.region_name}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{org.kind === 'chapter' && org.details.meets && (
|
||||
<span style={{ color: BODY }}>Meets {org.details.meets}</span>
|
||||
)}
|
||||
{org.kind === 'chapter' && org.details.started && (
|
||||
<span style={{ color: BODY }}>Since {org.details.started}</span>
|
||||
)}
|
||||
{org.is_online && <span style={{ color: BODY }}>Online</span>}
|
||||
|
||||
<Link to={back.to} className="ml-auto hover:underline" style={{ color: accent }}>
|
||||
{back.label}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
|
||||
const hasAny =
|
||||
org.links.length > 0 || org.socials.length > 0 || org.website || org.email
|
||||
|
||||
if (!hasAny) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{org.links.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{[org.website, org.email, ...org.socials]
|
||||
.filter((link): link is NonNullable<typeof link> => Boolean(link))
|
||||
.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
|
||||
style={{ borderColor: accent, color: accent }}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Logo({
|
||||
file,
|
||||
name,
|
||||
accent,
|
||||
}: {
|
||||
file?: string | null
|
||||
name: string
|
||||
accent: string
|
||||
}) {
|
||||
const src = orgLogo(file)
|
||||
if (!src) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <img src={src} alt="" loading="lazy" className="h-10 w-10 shrink-0 object-contain" />
|
||||
}
|
||||
|
||||
/* ── Leadership → team blocks ────────────────────────────────── */
|
||||
|
||||
type TeamBlock = {
|
||||
key: string
|
||||
teamId: string | null
|
||||
label: string | null
|
||||
tagline?: string | null
|
||||
accent?: string | null
|
||||
people: Leader[]
|
||||
}
|
||||
|
||||
/* Order comes from `teams` (the admin's sort_order), not from the
|
||||
order people happen to appear in. Three cases to cover:
|
||||
affiliations with no team at all, teams with nobody in them, and
|
||||
people filed under a team that is no longer published. */
|
||||
function groupLeadership(org: OrganizationRecord): TeamBlock[] {
|
||||
const byTeam = new Map<string, Leader[]>()
|
||||
const loose: Leader[] = []
|
||||
|
||||
for (const leader of org.leadership) {
|
||||
if (!leader.team_id) {
|
||||
loose.push(leader)
|
||||
continue
|
||||
}
|
||||
const list = byTeam.get(leader.team_id)
|
||||
if (list) list.push(leader)
|
||||
else byTeam.set(leader.team_id, [leader])
|
||||
}
|
||||
|
||||
const blocks: TeamBlock[] = []
|
||||
|
||||
// People who hold a role in the organization without sitting on a
|
||||
// team. Usually the leads. No heading — they are the page.
|
||||
if (loose.length > 0) {
|
||||
blocks.push({ key: 'loose', teamId: null, label: null, people: loose })
|
||||
}
|
||||
|
||||
for (const team of org.teams) {
|
||||
blocks.push({
|
||||
key: team.id,
|
||||
teamId: team.id,
|
||||
label: team.name,
|
||||
tagline: team.tagline,
|
||||
accent: team.color,
|
||||
people: byTeam.get(team.id) ?? [],
|
||||
})
|
||||
byTeam.delete(team.id)
|
||||
}
|
||||
|
||||
// Whatever is left is filed under an unpublished team. Its page
|
||||
// isn't reachable, so the heading is plain text — but the people
|
||||
// are real and shouldn't silently vanish from the org.
|
||||
for (const [teamId, people] of byTeam) {
|
||||
blocks.push({
|
||||
key: teamId,
|
||||
teamId: null,
|
||||
label: people[0]?.team_name ?? null,
|
||||
people,
|
||||
})
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
|
@ -9,12 +9,34 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
|
|||
different filter. The page declares them and does nothing else —
|
||||
each section fetches its own slice.
|
||||
|
||||
Two filters per band now, and they answer different questions:
|
||||
|
||||
section whose gathering it is — the scope. event_sections
|
||||
holds six of these; this page draws three.
|
||||
type what kind of gathering it is. Pinned to "retreat"
|
||||
everywhere on this page, which is what the page is
|
||||
for and what lets "Partner" read as a heading rather
|
||||
than a catch-all.
|
||||
|
||||
Because every band is pinned to one type, EventListCards finds a
|
||||
single kind in each and draws no type chips. Drop the `type` from
|
||||
a band and the chips appear on their own.
|
||||
|
||||
What this page deliberately does not show: local, international
|
||||
and other scopes, and every non-retreat type. Those are reachable
|
||||
from their host's page and by URL, and get a band here when
|
||||
there's enough of them to fill one — see the block at the bottom.
|
||||
|
||||
To reorder the page, move a line. To add a different kind of
|
||||
section, add an entry with another Component.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
|
||||
|
||||
/* Pinned on every band. Named rather than repeated so turning this
|
||||
page into "everything, filtered" later is one deletion. */
|
||||
const RETREATS = { type: "retreat" };
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
id: "national",
|
||||
|
|
@ -23,7 +45,7 @@ const SECTIONS = [
|
|||
accent: "#138ba0",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "national" },
|
||||
props: { section: "national", ...RETREATS },
|
||||
views: { ...CARD_VIEWS, default: "carousel" },
|
||||
},
|
||||
{
|
||||
|
|
@ -33,19 +55,56 @@ const SECTIONS = [
|
|||
accent: "#aac992",
|
||||
background: "#ffffff",
|
||||
Component: EventListCards,
|
||||
props: { section: "regional" },
|
||||
props: { section: "regional", ...RETREATS },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
{
|
||||
id: "partner",
|
||||
title: "Partner Events",
|
||||
blurb: "Events hosted by organizations we collaborate with.",
|
||||
blurb: "Retreats hosted by organizations we collaborate with.",
|
||||
accent: "#7a5ea8",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "partner" },
|
||||
props: { section: "partner", ...RETREATS },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
|
||||
/* The other three scopes, ready to uncomment. Each needs an accent
|
||||
and a background of its own — those are presentation and live
|
||||
here, not in event_sections.
|
||||
|
||||
{
|
||||
id: "local",
|
||||
title: "Local Events",
|
||||
blurb: "Hosted by individual chapters.",
|
||||
accent: "#d08a3c",
|
||||
background: "#ffffff",
|
||||
Component: EventListCards,
|
||||
props: { section: "local", ...RETREATS_ONLY },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
{
|
||||
id: "international",
|
||||
title: "International Events",
|
||||
blurb: "Gatherings beyond the US.",
|
||||
accent: "#3c7fd0",
|
||||
background: "#eef9fb",
|
||||
Component: EventListCards,
|
||||
props: { section: "international", ...RETREATS_ONLY },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
title: "Other Events",
|
||||
blurb: "Everything that doesn't fit the categories above.",
|
||||
accent: "#7a8a8e",
|
||||
background: "#ffffff",
|
||||
Component: EventListCards,
|
||||
props: { section: "other", ...RETREATS_ONLY },
|
||||
views: { ...CARD_VIEWS, default: "grid" },
|
||||
},
|
||||
|
||||
*/
|
||||
];
|
||||
|
||||
export default function RetreatsPage() {
|
||||
|
|
@ -54,7 +113,7 @@ export default function RetreatsPage() {
|
|||
return (
|
||||
<PageShell
|
||||
title="Retreats"
|
||||
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
|
||||
intro="Retreats hosted by Next Generation of Unity, our regions, and our partners throughout the year."
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
155
src/pages/TeamDetail.tsx
Normal file
155
src/pages/TeamDetail.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
TEAM DETAIL — /teams/:id
|
||||
|
||||
teams.id is a global primary key rather than scoped to the
|
||||
organization, which is what lets this route be flat: 'ngu-board'
|
||||
can only mean one thing site-wide.
|
||||
|
||||
The roster is not in this page's data. PeopleTiles fetches
|
||||
/teams/:id/people itself, off v_org_leadership, which already
|
||||
decides who counts as current and public. Two requests, both
|
||||
cached for 60s by api.js, and one set of visibility rules.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
import PageShell from '../components/PageShell.tsx'
|
||||
import PageState from '../components/PageState.tsx'
|
||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||
import PeopleTiles from '../components/PeopleTiles.tsx'
|
||||
import { useTeam } from '../lib/useContent.ts'
|
||||
import { orgHref } from '../lib/hrefs.ts'
|
||||
|
||||
const TEAL = '#138ba0'
|
||||
const BODY = '#4a6b72'
|
||||
|
||||
export default function TeamDetail() {
|
||||
const { id } = useParams()
|
||||
const { data: team, loading, error, notFound, reload } = useTeam(id)
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<PageState
|
||||
loading={loading}
|
||||
error={error}
|
||||
notFound={notFound}
|
||||
onRetry={reload}
|
||||
noun="team"
|
||||
backTo="/leadership"
|
||||
backLabel="Leadership"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const accent = team.color || TEAL
|
||||
const hasAbout =
|
||||
team.description.length > 0 || team.blocks.length > 0 || team.links.length > 0
|
||||
|
||||
const sections: any[] = []
|
||||
|
||||
if (hasAbout) {
|
||||
sections.push({
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
accent,
|
||||
background: '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-8">
|
||||
<Facts orgId={team.org.id} orgKind={team.org.kind} orgName={team.org.name} accent={accent} />
|
||||
|
||||
{team.description.map((paragraph, index) => (
|
||||
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<ContentBlocks blocks={team.blocks} accent={accent} />
|
||||
</div>
|
||||
|
||||
{team.links.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{team.links.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
|
||||
style={{ background: accent }}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
sections.push({
|
||||
id: 'members',
|
||||
title: hasAbout ? 'Members' : team.name,
|
||||
accent,
|
||||
background: hasAbout ? '#eef9fb' : '#ffffff',
|
||||
content: (
|
||||
<div className="max-w-6xl mx-auto px-6 space-y-6">
|
||||
{!hasAbout && (
|
||||
<Facts
|
||||
orgId={team.org.id}
|
||||
orgKind={team.org.kind}
|
||||
orgName={team.org.name}
|
||||
accent={accent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PeopleTiles
|
||||
size="lg"
|
||||
accent={accent}
|
||||
teams={[{ id: team.id, label: 'Members', accent }]}
|
||||
emptyMessage="Nobody is currently listed on this team."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell title={team.name} intro={team.tagline ?? undefined} sections={sections} />
|
||||
)
|
||||
}
|
||||
|
||||
function Facts({
|
||||
orgId,
|
||||
orgKind,
|
||||
orgName,
|
||||
accent,
|
||||
}: {
|
||||
orgId: string
|
||||
orgKind?: string | null
|
||||
orgName: string
|
||||
accent: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
||||
<span style={{ color: BODY }}>
|
||||
A team of{' '}
|
||||
<Link
|
||||
to={orgHref(orgId, orgKind)}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{orgName}
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
<Link
|
||||
to={orgHref(orgId, orgKind)}
|
||||
className="ml-auto hover:underline"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
Back to {orgName}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,8 +2,15 @@
|
|||
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.
|
||||
PATCH, and deletes through DELETE. Deliberately a flat list
|
||||
rather than a table: the message is the content, and messages
|
||||
don't fit in a cell.
|
||||
|
||||
Two capabilities, two ranks. Editors and above can change a
|
||||
status or leave a note; deleting is admin and above, matching
|
||||
requireRole on the server. Both come from the roles ladder
|
||||
rather than an equality check — a superadmin is not role ===
|
||||
"admin", and reading it that way is what hid these controls.
|
||||
|
||||
Every read passes ttl: 0. The api cache exists for public
|
||||
content that changes weekly; a triage queue two people are
|
||||
|
|
@ -13,8 +20,9 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { get, patch, ApiError } from "../../lib/api.js";
|
||||
import { del, get, patch, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
||||
|
||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||
|
|
@ -44,10 +52,11 @@ function locationOf(row) {
|
|||
|
||||
/* ── One submission ──────────────────────────────────────────── */
|
||||
|
||||
function FeedbackCard({ row, onChange, canWrite }) {
|
||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
||||
const [note, setNote] = useState(row.admin_note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const noteDirty = note !== (row.admin_note ?? "");
|
||||
|
||||
|
|
@ -64,6 +73,21 @@ function FeedbackCard({ row, onChange, canWrite }) {
|
|||
}
|
||||
}
|
||||
|
||||
// On success this card unmounts, so there's no finally here:
|
||||
// busy only needs clearing on the path where the row survives.
|
||||
async function remove() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await del(`/admin/feedback/${row.id}`);
|
||||
onRemove(row);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't delete that.");
|
||||
setConfirming(false);
|
||||
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">
|
||||
|
|
@ -126,8 +150,6 @@ function FeedbackCard({ row, onChange, canWrite }) {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
|
|
@ -150,6 +172,52 @@ function FeedbackCard({ row, onChange, canWrite }) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deleting is the irreversible option; marking something
|
||||
spam or archived is the habit this defers to. Hence the
|
||||
second click rather than a window.confirm. */}
|
||||
{canRemove && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[#4a6b72]/15 pt-4">
|
||||
{confirming ? (
|
||||
<>
|
||||
<span className="text-sm text-[#26454c]">
|
||||
Delete #{row.id} for good? Marking it spam keeps it recoverable.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={remove}
|
||||
className="rounded-full bg-[#b3261e] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#8f1e18] disabled:bg-[#4a6b72]/25"
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirming(false)}
|
||||
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#4a6b72]/50"
|
||||
>
|
||||
Keep it
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirming(true)}
|
||||
className="rounded-full border border-[#b3261e]/30 px-4 py-1.5 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-[#b3261e]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -170,7 +238,9 @@ export default function AdminFeedback() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const canWrite = user?.role === "admin";
|
||||
// Minimums, not equality — see lib/roles.ts.
|
||||
const canWrite = roleCanWrite(user);
|
||||
const canRemove = canDelete(user);
|
||||
|
||||
const load = useCallback(
|
||||
async (before = null) => {
|
||||
|
|
@ -218,6 +288,16 @@ export default function AdminFeedback() {
|
|||
setCounts((prev) => ({ ...prev })); // counts refresh on next load
|
||||
}
|
||||
|
||||
// The deleted row is passed whole rather than by id: its status
|
||||
// is what says which tab count to drop.
|
||||
function removeRow(removed) {
|
||||
setRows((prev) => prev.filter((row) => row.id !== removed.id));
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
[removed.status]: Math.max((prev[removed.status] ?? 1) - 1, 0),
|
||||
}));
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "all", label: "All" },
|
||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||
|
|
@ -294,7 +374,9 @@ export default function AdminFeedback() {
|
|||
key={row.id}
|
||||
row={row}
|
||||
canWrite={canWrite}
|
||||
canRemove={canRemove}
|
||||
onChange={replaceRow}
|
||||
onRemove={removeRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
182
src/pages/admin/AdminHome.tsx
Normal file
182
src/pages/admin/AdminHome.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN HOME
|
||||
|
||||
Where login lands. Two columns: the cards are the navigation —
|
||||
the header deliberately drops its tab row here so the same links
|
||||
aren't drawn twice — and a standing info panel on the right.
|
||||
|
||||
The cards come from adminNav.js, the same list the CMS header
|
||||
reads, so a new entity shows up here the moment it's registered.
|
||||
Forms sit in their own block below: they're submissions coming
|
||||
in rather than content going out, and there'll be more of them
|
||||
than the feedback queue eventually. The Panel block below that
|
||||
only exists for superadmins.
|
||||
|
||||
The right-hand panel is deliberately inert. Nothing here
|
||||
fetches, so the landing page can't be slow or half-broken on
|
||||
arrival; anything live (open feedback count, last-edited
|
||||
record) wants to be a separate component that fails on its own.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { SITE_VERSION } from "../../lib/version.ts";
|
||||
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
|
||||
|
||||
/* One card. The title link is stretched over the whole card with
|
||||
`after:absolute`, which makes the card clickable without nesting
|
||||
an anchor inside an anchor; the sub-links sit above it on z-10 so
|
||||
they stay separately clickable. */
|
||||
function NavCard({ item }) {
|
||||
const to = target(item);
|
||||
|
||||
// Drop the child that just repeats the card's own destination —
|
||||
// "All organizations" under Organizations.
|
||||
const extras = (item.children ?? []).filter((child) => child.to !== to);
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-2xl border border-[#138ba0]/20 bg-white p-5 transition-all hover:border-[#138ba0]/60 hover:shadow-sm">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h3 className="text-base font-semibold text-[#138ba0]">
|
||||
<Link
|
||||
to={to}
|
||||
className="after:absolute after:inset-0 after:rounded-2xl after:content-['']"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</h3>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-auto text-[#138ba0] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.blurb && (
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||
)}
|
||||
|
||||
{extras.length > 0 && (
|
||||
<div className="relative z-10 mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-[#138ba0]/10 pt-3 text-sm">
|
||||
{extras.map((child) => (
|
||||
<Link
|
||||
key={child.to}
|
||||
to={child.to}
|
||||
className="text-[#4a6b72] underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardBlock({ title, blurb, children }) {
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
|
||||
{blurb && <p className="text-sm text-[#4a6b72]">{blurb}</p>}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, children }) {
|
||||
return (
|
||||
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="mt-2.5 text-sm text-[#4a6b72]">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const QUICK_ADD = [
|
||||
{ to: "/admin/events/new", label: "New event" },
|
||||
{ to: "/admin/people/new", label: "New person" },
|
||||
{ to: "/admin/organizations/new", label: "New organization" },
|
||||
{ to: "/admin/timeline/new", label: "New timeline entry" },
|
||||
];
|
||||
|
||||
export default function AdminHome() {
|
||||
const { user } = useAuth();
|
||||
const role = ROLE_LABELS[user?.role] ?? user?.role;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#138ba0]">
|
||||
{user?.name ? `Welcome back, ${user.name.split(" ")[0]}.` : "Welcome back."}
|
||||
</h1>
|
||||
<p className="mt-1 text-[#4a6b72]">Pick a section to work in.</p>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
{CMS_NAV.map((item) => (
|
||||
<NavCard key={item.label} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CardBlock title={FORMS_NAV.label} blurb={FORMS_NAV.blurb}>
|
||||
{FORMS_NAV.children.map((form) => (
|
||||
<NavCard key={form.to} item={form} />
|
||||
))}
|
||||
</CardBlock>
|
||||
|
||||
{isSuper(user) && (
|
||||
<CardBlock title="Superadmin" blurb="Only superadmins see this block.">
|
||||
<NavCard item={PANEL_NAV} />
|
||||
</CardBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky so it stays put once the card column outgrows it. */}
|
||||
<aside className="divide-y divide-[#138ba0]/10 rounded-2xl border border-[#138ba0]/20 bg-white lg:sticky lg:top-6">
|
||||
<PanelSection title="Signed in">
|
||||
<p className="font-medium text-[#0f2f36]">{user?.name || user?.email}</p>
|
||||
{user?.name && user?.email && (
|
||||
<p className="mt-0.5 break-all text-xs text-[#4a6b72]/80">{user.email}</p>
|
||||
)}
|
||||
{role && (
|
||||
<span className="mt-2 inline-block rounded-full bg-[#eef9fb] px-2.5 py-0.5 text-xs font-medium text-[#138ba0]">
|
||||
{role}
|
||||
</span>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Start something">
|
||||
<ul className="space-y-1.5">
|
||||
{QUICK_ADD.map((link) => (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className="underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Site">
|
||||
<p className="font-mono text-xs text-[#4a6b72]/80">{SITE_VERSION}</p>
|
||||
<a
|
||||
href="/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-block underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
|
||||
>
|
||||
View the public site ↗
|
||||
</a>
|
||||
</PanelSection>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,59 +5,45 @@
|
|||
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.
|
||||
Three areas share this chrome: Home, the CMS and the Panel. The
|
||||
wordmark names whichever one you're in, and from anywhere but
|
||||
Home it's the way back to Home.
|
||||
|
||||
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.
|
||||
The tab row is drawn everywhere except Home, where the card grid
|
||||
is the navigation and drawing both would say the same thing
|
||||
twice. Which tabs appear depends on the signed-in user —
|
||||
navFor() drops the superadmin-only ones — but that's cosmetics.
|
||||
The route guard and the API are what actually say no.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Link, 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;
|
||||
import { SITE_VERSION } from "../../lib/version.ts";
|
||||
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||
import nguLogo from "../../assets/NGU_Logo.svg";
|
||||
import {
|
||||
ADMIN_HOME,
|
||||
AREA_TITLES,
|
||||
areaFor,
|
||||
matches,
|
||||
navFor,
|
||||
target,
|
||||
} from "./adminNav.js";
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const active = NAV.find(
|
||||
const area = areaFor(pathname);
|
||||
const areaTitle = AREA_TITLES[area];
|
||||
const isHome = area === "home";
|
||||
|
||||
const nav = useMemo(() => navFor(user), [user]);
|
||||
|
||||
const active = nav.find(
|
||||
(item) =>
|
||||
matches(pathname, item.to) ||
|
||||
(item.children ?? []).some((child) => matches(pathname, child.to)),
|
||||
|
|
@ -75,9 +61,12 @@ export default function AdminLayout() {
|
|||
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]);
|
||||
// Sections only exist in the CMS. Home and Panel already say
|
||||
// what they are in the area title; "Panel | NGU Admin Panel"
|
||||
// would just stutter.
|
||||
const section = area === "cms" ? activeChild?.label ?? active?.label : null;
|
||||
document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
|
||||
}, [area, areaTitle, active, activeChild, detail]);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
|
|
@ -86,37 +75,54 @@ export default function AdminLayout() {
|
|||
|
||||
const subnav = active?.children ?? [];
|
||||
|
||||
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f6fbfc]">
|
||||
<div className="flex min-h-screen flex-col 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>
|
||||
{/* Already home, so nothing to link to. */}
|
||||
{isHome ? (
|
||||
wordmark
|
||||
) : (
|
||||
<Link
|
||||
to={ADMIN_HOME}
|
||||
className="rounded transition-opacity hover:opacity-70"
|
||||
title="Back to the admin home"
|
||||
>
|
||||
{wordmark}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{!isHome && (
|
||||
<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>
|
||||
<span>
|
||||
{user?.name || user?.email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
|
|
@ -150,11 +156,25 @@ export default function AdminLayout() {
|
|||
)}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
{/* flex-1 rather than a fixed height: the footer sits at the
|
||||
bottom of a short page and below the content of a long one,
|
||||
without ever floating over it. */}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
||||
<AdminTitleContext.Provider value={titleContext}>
|
||||
<Outlet />
|
||||
</AdminTitleContext.Provider>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-[#138ba0]/15 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center gap-4 px-6 py-3">
|
||||
<Link to={ADMIN_HOME} className="transition-opacity hover:opacity-70">
|
||||
<img src={nguLogo} alt="NGU" className="h-6 w-auto" />
|
||||
</Link>
|
||||
<span className="ml-auto font-mono text-xs text-[#4a6b72]/70">
|
||||
{SITE_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export default function AdminLogin() {
|
|||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destination = location.state?.from?.pathname ?? "/admin/feedback";
|
||||
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
|
|
|||
331
src/pages/admin/AdminPanel.tsx
Normal file
331
src/pages/admin/AdminPanel.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN PANEL
|
||||
|
||||
Superadmin only, guarded by RequireRole on the route and by
|
||||
requireRole("superadmin") on every endpoint it calls. This page
|
||||
assumes neither: it renders whatever the API gives it and shows
|
||||
whatever the API refuses.
|
||||
|
||||
Three blocks, deliberately boring:
|
||||
|
||||
System — what's actually running, for when something is off
|
||||
Content — row counts, the cheapest "is the database there"
|
||||
Accounts — roles, access and live sessions
|
||||
|
||||
The role select is driven by ROLES from lib/roles.ts, so a new
|
||||
rung on the ladder appears here without this file changing. The
|
||||
legend beside it is the same list — four roles is past the
|
||||
point where "Editor" explains itself.
|
||||
|
||||
Every account write signs that person out, which the server
|
||||
does rather than this page. Two things you can't do here: edit
|
||||
your own row, or take the last active superadmin away. Both are
|
||||
enforced server-side and mirrored in the disabled states, so
|
||||
the reason shows up before the click rather than after it.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { del, get, patch } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||
|
||||
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
|
||||
marker that says so. Left alone, browsers read it as local time
|
||||
and last-login drifts by the timezone offset. */
|
||||
function when(value) {
|
||||
if (!value) return "—";
|
||||
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function uptime(seconds) {
|
||||
if (seconds == null) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d) return `${d}d ${h}h`;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function Block({ title, note, children }) {
|
||||
return (
|
||||
<section className="mt-8 first:mt-0">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
|
||||
{note && <p className="text-sm text-[#4a6b72]">{note}</p>}
|
||||
</div>
|
||||
<div className="mt-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 break-words text-sm font-medium text-[#0f2f36]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { user: me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||
// the row so a failure on one account doesn't blank the table.
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [rowError, setRowError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setError(err.message || "Couldn't load the panel.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
/* Replace the one row the server returns rather than refetching
|
||||
the whole overview — the counts didn't change. */
|
||||
function mergeUser(updated) {
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
users: current.users.map((u) => (u.id === updated.id ? updated : u)),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
|
||||
async function run(id, work) {
|
||||
setBusyId(id);
|
||||
setRowError(null);
|
||||
try {
|
||||
mergeUser(await work());
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||
setRowError({ id, message: err.message || "That didn't work." });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = (row, role) =>
|
||||
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
|
||||
|
||||
const setActive = (row, is_active) =>
|
||||
run(
|
||||
row.id,
|
||||
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
|
||||
);
|
||||
|
||||
const revoke = (row) =>
|
||||
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="py-12 text-[#4a6b72]" role="status">
|
||||
Loading the panel…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<p className="text-[#b3261e]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { system, content, users } = data;
|
||||
const activeSupers = users.filter(
|
||||
(u) => u.role === "superadmin" && u.is_active === 1,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#138ba0]">Panel</h1>
|
||||
<p className="mt-1 text-[#4a6b72]">
|
||||
Accounts and server state. Everything here is superadmin-only.
|
||||
</p>
|
||||
|
||||
<Block title="System">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Stat label="Schema version" value={system.schemaVersion} />
|
||||
<Stat label="API uptime" value={uptime(system.uptimeSeconds)} />
|
||||
<Stat label="Started" value={when(system.startedAt)} />
|
||||
<Stat label="Node" value={system.nodeVersion} />
|
||||
<Stat label="Platform" value={system.platform} />
|
||||
<Stat label="Live sessions" value={system.sessions} />
|
||||
</div>
|
||||
{system.dbPath && (
|
||||
<p className="mt-3 font-mono text-xs text-[#4a6b72]/70">{system.dbPath}</p>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
<Block title="Content" note="Row counts, straight from the tables.">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{content.map((row) => (
|
||||
<span
|
||||
key={row.label}
|
||||
className="rounded-full border border-[#138ba0]/20 bg-white px-3 py-1 text-sm text-[#4a6b72]"
|
||||
>
|
||||
{row.label}{" "}
|
||||
<strong className="font-semibold text-[#0f2f36]">
|
||||
{row.count ?? "—"}
|
||||
</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
title="Accounts"
|
||||
note="Changing a role or disabling an account signs that person out."
|
||||
>
|
||||
<div className="overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
|
||||
<table className="w-full min-w-[46rem] text-left text-sm">
|
||||
<thead className="border-b border-[#138ba0]/15 text-xs uppercase tracking-wider text-[#4a6b72]/70">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Account</th>
|
||||
<th className="px-4 py-3 font-medium">Role</th>
|
||||
<th className="px-4 py-3 font-medium">Last login</th>
|
||||
<th className="px-4 py-3 font-medium">Sessions</th>
|
||||
<th className="px-4 py-3 font-medium">Access</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#138ba0]/10">
|
||||
{users.map((row) => {
|
||||
const isMe = row.id === me?.id;
|
||||
const lastSuper =
|
||||
row.role === "superadmin" && row.is_active === 1 && activeSupers <= 1;
|
||||
const locked = isMe || lastSuper;
|
||||
const busy = busyId === row.id;
|
||||
|
||||
return (
|
||||
<tr key={row.id} className={row.is_active ? "" : "bg-[#f6fbfc]"}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-[#0f2f36]">
|
||||
{row.name || row.email}
|
||||
{isMe && (
|
||||
<span className="ml-2 text-xs font-normal text-[#4a6b72]/70">
|
||||
you
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.name && (
|
||||
<div className="text-xs text-[#4a6b72]/80">{row.email}</div>
|
||||
)}
|
||||
{rowError?.id === row.id && (
|
||||
<div className="mt-1 text-xs text-[#b3261e]">
|
||||
{rowError.message}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={row.role}
|
||||
disabled={locked || busy}
|
||||
onChange={(e) => changeRole(row, e.target.value)}
|
||||
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
|
||||
title={
|
||||
isMe
|
||||
? "You can't change your own role."
|
||||
: lastSuper
|
||||
? "The last active superadmin can't be demoted."
|
||||
: ROLE_NOTES[row.role]
|
||||
}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABELS[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 text-[#4a6b72]">{when(row.last_login_at)}</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[#4a6b72]">{row.sessions}</span>
|
||||
{row.sessions > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => revoke(row)}
|
||||
className="ml-3 text-xs font-medium text-[#138ba0] underline-offset-4 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked || busy}
|
||||
onClick={() => setActive(row, row.is_active ? 0 : 1)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
row.is_active
|
||||
? "border-[#4a6b72]/30 text-[#4a6b72] hover:border-[#b3261e]/50 hover:text-[#b3261e]"
|
||||
: "border-[#138ba0]/40 text-[#138ba0] hover:bg-[#eef9fb]"
|
||||
}`}
|
||||
>
|
||||
{row.is_active ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Each rung adds to the one above it. Worth stating, because
|
||||
"Editor" doesn't tell you where the line falls. */}
|
||||
<dl className="mt-4 grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
|
||||
{ROLES.map((role) => (
|
||||
<div key={role} className="flex gap-2">
|
||||
<dt className="shrink-0 font-medium text-[#0f2f36]">
|
||||
{ROLE_LABELS[role]}
|
||||
</dt>
|
||||
<dd className="text-[#4a6b72]">{ROLE_NOTES[role]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<p className="mt-4 text-xs text-[#4a6b72]/80">
|
||||
New accounts are still created with <code>admin-cli.js</code> on the server.
|
||||
</p>
|
||||
</Block>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,16 @@
|
|||
updated_at column simply never get one, and the 409 path stays
|
||||
dormant for them.
|
||||
|
||||
Two capabilities, not one role. An editor may create and update
|
||||
but not delete, so the action bar asks canWrite/canDelete rather
|
||||
than comparing user.role to a string. The comparison this
|
||||
replaced — role === "admin" — locked superadmins out of saving
|
||||
the moment a rank above admin existed, which is what an equality
|
||||
test against a ladder always eventually does.
|
||||
|
||||
None of this is protection. The server refuses the request; this
|
||||
only decides whether to draw a button that would be refused.
|
||||
|
||||
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
|
||||
|
|
@ -37,6 +47,7 @@ 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 { atLeast } from "../../lib/roles.ts";
|
||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||
|
||||
/* A foreign key refusing to budge is the most common way a save or
|
||||
|
|
@ -56,7 +67,14 @@ export default function EntityEdit() {
|
|||
const { user } = useAuth();
|
||||
|
||||
const isNew = id === "new";
|
||||
const canWrite = user?.role === "admin";
|
||||
const canWrite = atLeast(user, "editor");
|
||||
const canDelete = atLeast(user, "admin");
|
||||
|
||||
// Some entities have no slug: the table assigns an integer id, so
|
||||
// there is nothing to type on create and nothing to compose from
|
||||
// other fields. Timeline entries are the first — an entry that
|
||||
// references an event has no name of its own.
|
||||
const autoId = manifest?.idKind === "auto";
|
||||
|
||||
// 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
|
||||
|
|
@ -92,8 +110,9 @@ export default function EntityEdit() {
|
|||
};
|
||||
|
||||
// The heading wants the specific half, not the qualifier: a team
|
||||
// page reads "Board", not "northwest Board".
|
||||
const headingPath = slugPaths[slugPaths.length - 1];
|
||||
// page reads "Board", not "northwest Board". An entity with no slug
|
||||
// names the field to read instead.
|
||||
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [options, setOptions] = useState({});
|
||||
|
|
@ -120,7 +139,9 @@ export default function EntityEdit() {
|
|||
// 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: "" };
|
||||
// No id key for an auto entity: the table assigns it, and
|
||||
// sending "" would be an explicit value rather than an absence.
|
||||
const blank = autoId ? {} : { id: "" };
|
||||
for (const child of manifest.children ?? []) blank[child.key] = [];
|
||||
setForm(blank);
|
||||
baseline.current = JSON.stringify(blank);
|
||||
|
|
@ -139,7 +160,7 @@ export default function EntityEdit() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [manifest, id, isNew, navigate]);
|
||||
}, [manifest, id, isNew, autoId, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
|
@ -288,7 +309,19 @@ export default function EntityEdit() {
|
|||
{isNew ? `New ${manifest.singular}` : heading}
|
||||
</h1>
|
||||
|
||||
{/* Slug */}
|
||||
{/* Slug. An auto-id entity has nothing to ask for on create, and
|
||||
nothing editable afterwards — so it gets a plain line rather
|
||||
than a disabled box pretending to be a field. */}
|
||||
{autoId ? (
|
||||
!isNew && (
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<p className="text-sm text-[#4a6b72]">
|
||||
{manifest.idLabel} #{form.id}
|
||||
{form.updated_at && <> · last saved {form.updated_at}</>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||
<Field
|
||||
field={{
|
||||
|
|
@ -317,6 +350,7 @@ export default function EntityEdit() {
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Field groups */}
|
||||
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
|
||||
|
|
@ -332,6 +366,7 @@ export default function EntityEdit() {
|
|||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
row={form}
|
||||
value={getPath(form, field.path)}
|
||||
options={options}
|
||||
error={errors[field.path]}
|
||||
|
|
@ -376,7 +411,7 @@ export default function EntityEdit() {
|
|||
>
|
||||
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||
</button>
|
||||
{!isNew && (
|
||||
{!isNew && canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
|
|
@ -388,7 +423,7 @@ export default function EntityEdit() {
|
|||
</>
|
||||
) : (
|
||||
<span className="text-sm text-[#4a6b72]">
|
||||
Read-only: your account can't save changes.
|
||||
Read-only: your account can view this but not change it.
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
One component for organizations, events and people. The :entity
|
||||
route param picks the manifest; nothing here knows what a
|
||||
chapter or a retreat is.
|
||||
|
||||
The "New X" link follows the same rule as EntityEdit's save
|
||||
button: atLeast(user, "editor"), matching requireRole("editor")
|
||||
on POST /api/admin/:entity. Drawing it is not permission — the
|
||||
server decides — it only avoids offering a click that 403s, and
|
||||
avoids hiding one that wouldn't.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
|
@ -12,6 +18,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
|
|||
import { get, ApiError } from "../../lib/api.js";
|
||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
|
||||
export default function EntityList() {
|
||||
const { entity: entityKey } = useParams();
|
||||
|
|
@ -26,7 +33,12 @@ export default function EntityList() {
|
|||
const [error, setError] = useState(null);
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
|
||||
const canWrite = user?.role === "admin";
|
||||
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||
// at "editor", so anyone from editor upward may create — and the
|
||||
// equality test this replaced hid the button from superadmins as
|
||||
// well as editors, which is the failure mode an == against a
|
||||
// ladder always produces once a rank is added above it.
|
||||
const canWrite = atLeast(user, "editor");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!manifest) return;
|
||||
|
|
|
|||
34
src/pages/admin/RequireRole.tsx
Normal file
34
src/pages/admin/RequireRole.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ROLE GUARD — src/pages/admin/RequireRole.tsx
|
||||
|
||||
Sits inside RequireAuth, never instead of it: by the time this
|
||||
renders, the session question has already been answered. All
|
||||
this decides is whether the answer was good enough.
|
||||
|
||||
Same caveat as RequireAuth — this hides the interface, not the
|
||||
data. /api/admin/panel/* is superadmin-only on the server, and
|
||||
that's the part that matters. Without it, a bookmarked URL and
|
||||
a disabled select would be the only thing between a viewer and
|
||||
the account list.
|
||||
|
||||
Bounces to Home rather than showing a "denied" page. Someone
|
||||
who lands here has almost always followed a stale link, and a
|
||||
working page beats an explanation of one.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../../lib/auth.tsx";
|
||||
import { atLeast } from "../../lib/roles.ts";
|
||||
import { ADMIN_HOME } from "./adminNav.js";
|
||||
|
||||
export default function RequireRole({ role = "superadmin" }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// RequireAuth is already showing its own placeholder above this.
|
||||
if (loading) return null;
|
||||
|
||||
if (!user) return <Navigate to="/admin/login" replace />;
|
||||
if (!atLeast(user, role)) return <Navigate to={ADMIN_HOME} replace />;
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
114
src/pages/admin/adminNav.js
Normal file
114
src/pages/admin/adminNav.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
ADMIN NAVIGATION — src/pages/admin/adminNav.js
|
||||
|
||||
Lifted out of AdminLayout because two things read it now: the
|
||||
header tabs and the card grid on the home page. Adding an entity
|
||||
stays one entry here plus a descriptor — there's still no second
|
||||
list to keep in step, it just isn't inside the layout file
|
||||
any more.
|
||||
|
||||
`blurb` is only read by the home cards. The header ignores it.
|
||||
|
||||
`superOnly` hides an entry from anyone below superadmin. It
|
||||
hides, nothing more: the route still has to be guarded and the
|
||||
API still has to say no. Treating a filtered menu as access
|
||||
control is how you end up with a URL that works.
|
||||
|
||||
Three areas, three titles. The area is derived from the path
|
||||
rather than declared per route, so a page added under
|
||||
/admin/panel/… inherits the right title without registering
|
||||
anything.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { isSuper } from "../../lib/roles.ts";
|
||||
|
||||
export const ADMIN_HOME = "/admin/home";
|
||||
export const ADMIN_PANEL = "/admin/panel";
|
||||
|
||||
export const AREA_TITLES = {
|
||||
home: "NGU Admin Home",
|
||||
cms: "NGU Admin CMS",
|
||||
panel: "NGU Admin Panel",
|
||||
};
|
||||
|
||||
/* The CMS tabs. 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. */
|
||||
export const CMS_NAV = [
|
||||
{
|
||||
to: "/admin/events",
|
||||
label: "Events",
|
||||
blurb: "Retreats, conferences and gatherings, with their sections and rosters.",
|
||||
},
|
||||
{
|
||||
to: "/admin/organizations",
|
||||
label: "Organizations",
|
||||
blurb: "Regions, chapters and partners — plus the teams and awards they own.",
|
||||
children: [
|
||||
{ to: "/admin/organizations", label: "All organizations" },
|
||||
{ to: "/admin/teams", label: "Teams" },
|
||||
{ to: "/admin/awards", label: "Awards" },
|
||||
],
|
||||
},
|
||||
{
|
||||
to: "/admin/people",
|
||||
label: "People",
|
||||
blurb: "Bios, contact details, affiliations and awards received.",
|
||||
},
|
||||
// Its own tab rather than a child of anything: a timeline entry can
|
||||
// point at an event, an organization, an award, a person or a team,
|
||||
// so filing it under one of them would be arbitrary.
|
||||
{
|
||||
to: "/admin/timeline",
|
||||
label: "Timeline",
|
||||
blurb: "What the history page shows, and the order it shows it in.",
|
||||
},
|
||||
];
|
||||
|
||||
/* Forms are submissions coming in rather than content going out, so
|
||||
they get their own group in the header and their own block on the
|
||||
home page. A group with no `to` of its own opens its first child,
|
||||
so clicking the word Forms goes somewhere rather than nowhere. */
|
||||
export const FORMS_NAV = {
|
||||
label: "Forms",
|
||||
blurb: "Whatever the public site has sent us.",
|
||||
separated: true,
|
||||
children: [
|
||||
{
|
||||
to: "/admin/feedback",
|
||||
label: "Website feedback",
|
||||
blurb: "The triage queue for the feedback form.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* Accounts and server state, not content — which is why it sits
|
||||
outside the CMS rather than as another tab within it. */
|
||||
export const PANEL_NAV = {
|
||||
to: ADMIN_PANEL,
|
||||
label: "Panel",
|
||||
blurb: "Accounts, sessions and the state of the server.",
|
||||
separated: true,
|
||||
superOnly: true,
|
||||
};
|
||||
|
||||
export const NAV = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
|
||||
|
||||
/* What this user may see. Call it with the user from useAuth. */
|
||||
export function navFor(user) {
|
||||
return NAV.filter((item) => !item.superOnly || isSuper(user));
|
||||
}
|
||||
|
||||
/* A tab owns its own page and everything below it, so editing
|
||||
/admin/teams/ngu-board keeps Teams lit. */
|
||||
export const matches = (pathname, to) =>
|
||||
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
|
||||
|
||||
export const target = (item) => item.to ?? item.children?.[0]?.to;
|
||||
|
||||
export function areaFor(pathname) {
|
||||
if (matches(pathname, ADMIN_HOME)) return "home";
|
||||
if (matches(pathname, ADMIN_PANEL)) return "panel";
|
||||
return "cms";
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
||||
import { eventHref } from "../../lib/hrefs.ts";
|
||||
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT LIST — CARDS
|
||||
|
|
@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
|
|||
|
||||
<EventListCards section="national" view="carousel" />
|
||||
<EventListCards host="northwest" view="grid" />
|
||||
<EventListCards type={["class", "workshop"]} view="grid" />
|
||||
|
||||
`view` and `accent` come from the page's section manifest.
|
||||
|
||||
`type` pre-filters the band the way `section` and `host` do. Left
|
||||
off, the band takes every kind it finds and grows a row of chips
|
||||
to narrow by — but only once it holds more than one, so a band of
|
||||
nothing but retreats shows no control at all.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||
|
|
@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
/* The title is the way into the event's own page.
|
||||
|
||||
A link on the title rather than a wrapper around the whole card:
|
||||
the footer already holds anchors, and an anchor inside an anchor
|
||||
is invalid markup that every browser resolves by guessing. The
|
||||
carousel has the same constraint — it needs the card's click to
|
||||
mean "bring this one to the front" on anything that isn't the
|
||||
active slide. */
|
||||
function TitleLink({ ev, linked, children }) {
|
||||
if (!linked) return <>{children}</>;
|
||||
return (
|
||||
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CARD — one component, two sizes.
|
||||
compact=false → the full card used in the carousel
|
||||
|
|
@ -119,6 +145,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
|||
Fields arrive pre-resolved from the API — `color` is the event's
|
||||
own or its host's, `status` is derived from the dates when it
|
||||
isn't set — so nothing here reimplements those rules.
|
||||
|
||||
`linked` is the one thing a caller turns off: a card on the
|
||||
event's own page shouldn't link to the page it's already on.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function Card({
|
||||
ev,
|
||||
|
|
@ -126,6 +155,8 @@ export function Card({
|
|||
accent = TEAL,
|
||||
compact = false,
|
||||
interactive = true,
|
||||
linked = true,
|
||||
showType = false,
|
||||
}) {
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || defaultColor;
|
||||
|
|
@ -137,6 +168,19 @@ export function Card({
|
|||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: null;
|
||||
|
||||
/* Off unless the caller says the band is mixed. A "Retreat" badge
|
||||
on every card in a row of nothing but retreats is noise, and
|
||||
the card can't tell on its own — it only ever sees one event. */
|
||||
const typeBadge =
|
||||
showType && ev.event_type ? (
|
||||
<span
|
||||
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
{eventTypeLabel(ev.event_type)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/* An ordered array, so a card can carry one paragraph or five
|
||||
without the component changing. */
|
||||
const descriptions = (ev.description ?? []).map((text, i) => (
|
||||
|
|
@ -168,7 +212,12 @@ export function Card({
|
|||
<div className="ev-grid mb-2">
|
||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||
<div className="ev-info">
|
||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
||||
{typeBadge}
|
||||
<h3 className="text-3xl font-900 leading-tight">
|
||||
<TitleLink ev={ev} linked={linked}>
|
||||
{ev.title}
|
||||
</TitleLink>
|
||||
</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
|
|
@ -190,7 +239,12 @@ export function Card({
|
|||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||||
{typeBadge}
|
||||
<h3 className="text-4xl font-900">
|
||||
<TitleLink ev={ev} linked={linked}>
|
||||
{ev.title}
|
||||
</TitleLink>
|
||||
</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
|
|
@ -211,10 +265,32 @@ export function Card({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
||||
across every card in a grid row. */}
|
||||
{links.length > 0 ? (
|
||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||||
{/* Footer — mt-auto pins the whole block to the bottom so
|
||||
buttons line up across every card in a grid row.
|
||||
|
||||
Three registration states, as before: links to follow, a
|
||||
past event, or an announcement still to come. What's new
|
||||
is that all three end in the same row, because every
|
||||
event now has a page and a past one is often the more
|
||||
worth reading — speakers, awards, what actually
|
||||
happened. The notice is what changes; the way in
|
||||
doesn't. */}
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
{links.length === 0 && (
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{past
|
||||
? "This event has concluded — thank you to everyone who joined us!"
|
||||
: igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-wrap justify-center items-center ${
|
||||
compact ? "gap-2" : "gap-3"
|
||||
} ${links.length === 0 ? "mt-4" : ""}`}
|
||||
>
|
||||
{links.map(item => (
|
||||
<a
|
||||
key={item.label}
|
||||
|
|
@ -227,28 +303,17 @@ export function Card({
|
|||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : past ? (
|
||||
<p
|
||||
className="mt-auto text-center font-600 pt-8"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
This event has concluded — thank you to everyone who joined us!
|
||||
</p>
|
||||
) : (
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
{igHandle && (
|
||||
|
||||
{/* Only when there's nothing to register for and the
|
||||
event hasn't happened — the same condition as before,
|
||||
just no longer nested inside that branch. */}
|
||||
{igHandle && !past && links.length === 0 && (
|
||||
<a
|
||||
href={igUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
||||
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
|
||||
compact ? "py-2.5 px-5" : "py-3 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
|
|
@ -256,13 +321,70 @@ export function Card({
|
|||
{igHandle}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Last, so Register reads first when there is one. */}
|
||||
{linked && (
|
||||
<Link
|
||||
to={eventHref(ev.id)}
|
||||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${color}` }}
|
||||
>
|
||||
Event details
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TYPE FILTER
|
||||
|
||||
Drawn only when a band actually holds more than one kind, so it
|
||||
costs nothing today — every event is a retreat — and appears on
|
||||
its own the first time a class or a workshop lands in that band.
|
||||
Nothing on Retreats.tsx has to be reconfigured for it.
|
||||
|
||||
Chips rather than a select: with four or five options, all of
|
||||
them visible is one tap, and the row reads as what the section
|
||||
contains rather than as a form control.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function TypeFilter({ types, active, setActive, accent }) {
|
||||
const chip = on => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: on ? accent : "transparent",
|
||||
color: on ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
const button = (id, label) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActive(id)}
|
||||
aria-pressed={active === id}
|
||||
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
|
||||
style={chip(active === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
|
||||
style={{ maxWidth: GRID_MAX }}
|
||||
role="group"
|
||||
aria-label="Filter events by type"
|
||||
>
|
||||
{button("all", "All")}
|
||||
{types.map(entry => button(entry.id, entry.plural))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
|
@ -323,22 +445,57 @@ export default function EventListCards({
|
|||
section,
|
||||
host,
|
||||
status,
|
||||
type,
|
||||
view = "carousel",
|
||||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}) {
|
||||
const { events, loading, error } = useEvents({ section, host, status });
|
||||
const { events: fetched, loading, error } = useEvents({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
type,
|
||||
});
|
||||
const cardColor = defaultColor ?? accent;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [activeType, setActiveType] = useState("all");
|
||||
|
||||
/* What this band holds, which is what the chips offer — not the
|
||||
full list of declared types, three quarters of which would be
|
||||
dead buttons. */
|
||||
const availableTypes = useMemo(
|
||||
() => typesPresent(fetched, EVENT_TYPES),
|
||||
[fetched],
|
||||
);
|
||||
|
||||
const mixed = availableTypes.length > 1;
|
||||
|
||||
const events = useMemo(
|
||||
() =>
|
||||
activeType === "all"
|
||||
? fetched
|
||||
: fetched.filter(e => e.event_type === activeType),
|
||||
[fetched, activeType],
|
||||
);
|
||||
|
||||
/* Open on the first upcoming event. The list is empty on the
|
||||
first render, so this can't be a useState initialiser — it has
|
||||
to wait for the data and then run once. Clearing the guard when
|
||||
the list empties means a refetch re-seeds. */
|
||||
const seeded = useRef(false);
|
||||
|
||||
/* Changing the chip is a different list, so the carousel re-seeds
|
||||
on the first upcoming event of that kind rather than holding an
|
||||
index that may now be past the end. Declared before the seed
|
||||
effect so the guard is already clear when it runs. */
|
||||
useEffect(() => {
|
||||
seeded.current = false;
|
||||
setIndex(0);
|
||||
}, [activeType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length === 0) {
|
||||
seeded.current = false;
|
||||
|
|
@ -395,8 +552,24 @@ export default function EventListCards({
|
|||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{mixed && (
|
||||
<TypeFilter
|
||||
types={availableTypes}
|
||||
active={activeType}
|
||||
setActive={setActiveType}
|
||||
accent={accent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length === 0 ? (
|
||||
notice(empty)
|
||||
/* Two different empties. Nothing scheduled is news; nothing
|
||||
of the kind you just picked is a filter you can undo, and
|
||||
the chips are still on screen to undo it with. */
|
||||
notice(
|
||||
activeType === "all"
|
||||
? empty
|
||||
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
|
||||
)
|
||||
) : view === "grid" ? (
|
||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||
|
|
@ -412,6 +585,7 @@ export default function EventListCards({
|
|||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
compact
|
||||
showType={mixed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -450,6 +624,7 @@ export default function EventListCards({
|
|||
accent={accent}
|
||||
compact
|
||||
interactive={showPast}
|
||||
showType={mixed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -514,6 +689,7 @@ export default function EventListCards({
|
|||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
interactive={active}
|
||||
showType={mixed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback, useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
defaultOpenYears,
|
||||
groupTimeline,
|
||||
groupYears,
|
||||
partitionByDate,
|
||||
type DecadeMeta,
|
||||
type SortDirection,
|
||||
type TimelineItem,
|
||||
} from '../../../lib/timeline'
|
||||
import TimelineDecade from './TimelineDecade'
|
||||
import TimelineUpcoming from './TimelineUpcoming'
|
||||
import './timeline.css'
|
||||
|
||||
const PARAM = 'y'
|
||||
|
||||
type Props = {
|
||||
items: TimelineItem[]
|
||||
decades: DecadeMeta[]
|
||||
direction?: SortDirection
|
||||
/** Decades ending before this year get the pre-program treatment. */
|
||||
programStartYear?: number
|
||||
fillGaps?: boolean
|
||||
/** Injectable for tests and for pinning a date in a screenshot. */
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export default function HistoryTimeline({
|
||||
items,
|
||||
decades,
|
||||
direction = 'desc',
|
||||
programStartYear = 2002,
|
||||
fillGaps = true,
|
||||
now,
|
||||
}: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// Upcoming vs recorded is a function of the clock, so an event crosses
|
||||
// over on its own date with nothing to flip.
|
||||
const { upcoming, past } = useMemo(
|
||||
() => partitionByDate(items, now ?? new Date()),
|
||||
[items, now],
|
||||
)
|
||||
|
||||
const upcomingYears = useMemo(
|
||||
() => groupYears(upcoming, { direction }),
|
||||
[upcoming, direction],
|
||||
)
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupTimeline(past, decades, { direction, programStartYear }),
|
||||
[past, decades, direction, programStartYear],
|
||||
)
|
||||
|
||||
// `?y=2014,2022` deep-links straight to open years. Absence of the
|
||||
// param means "not chosen yet", so fall back to the most recent
|
||||
// featured year; an empty param means everything was collapsed on
|
||||
// purpose.
|
||||
const openYears = useMemo(() => {
|
||||
if (!searchParams.has(PARAM)) return new Set(defaultOpenYears(grouped))
|
||||
const raw = searchParams.get(PARAM) ?? ''
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => Number(part.trim()))
|
||||
.filter((n) => Number.isInteger(n)),
|
||||
)
|
||||
}, [searchParams, grouped])
|
||||
|
||||
const commit = useCallback(
|
||||
(next: Set<number>) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set(PARAM, [...next].sort((a, b) => b - a).join(','))
|
||||
// replace: toggling shouldn't fill the back button with history.
|
||||
setSearchParams(params, { replace: true })
|
||||
},
|
||||
[searchParams, setSearchParams],
|
||||
)
|
||||
|
||||
const toggleYear = useCallback(
|
||||
(year: number) => {
|
||||
const next = new Set(openYears)
|
||||
if (next.has(year)) next.delete(year)
|
||||
else next.add(year)
|
||||
commit(next)
|
||||
},
|
||||
[openYears, commit],
|
||||
)
|
||||
|
||||
const allYears = useMemo(() => {
|
||||
const years = grouped.flatMap((decade) =>
|
||||
decade.years.filter((y) => y.count > 0).map((y) => y.year),
|
||||
)
|
||||
return [...years, ...upcomingYears.map((y) => y.year)]
|
||||
}, [grouped, upcomingYears])
|
||||
|
||||
const allOpen = allYears.length > 0 && allYears.every((y) => openYears.has(y))
|
||||
|
||||
if (grouped.length === 0 && upcomingYears.length === 0) {
|
||||
return (
|
||||
<div className="ngu-tl">
|
||||
<p className="ngu-tl__emptyState">
|
||||
The timeline is empty. Add a milestone to start the record.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ngu-tl">
|
||||
<div className="ngu-tl__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="ngu-tl__toolbarBtn"
|
||||
onClick={() => commit(allOpen ? new Set() : new Set(allYears))}
|
||||
>
|
||||
{allOpen ? 'Collapse all years' : 'Expand all years'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimelineUpcoming
|
||||
years={upcomingYears}
|
||||
count={upcoming.length}
|
||||
direction={direction}
|
||||
openYears={openYears}
|
||||
onToggleYear={toggleYear}
|
||||
/>
|
||||
|
||||
{grouped.map((decade) => (
|
||||
<TimelineDecade
|
||||
key={decade.decade}
|
||||
decade={decade}
|
||||
openYears={openYears}
|
||||
onToggleYear={toggleYear}
|
||||
direction={direction}
|
||||
fillGaps={fillGaps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
decadeLabel,
|
||||
withGapYears,
|
||||
type GroupedDecade,
|
||||
type SortDirection,
|
||||
} from '../../../lib/timeline'
|
||||
import TimelineYear from './TimelineYear'
|
||||
|
||||
type Props = {
|
||||
decade: GroupedDecade
|
||||
openYears: Set<number>
|
||||
onToggleYear: (year: number) => void
|
||||
direction?: SortDirection
|
||||
/** Show years with no records as muted nodes, so gaps stay visible. */
|
||||
fillGaps?: boolean
|
||||
}
|
||||
|
||||
export default function TimelineDecade({
|
||||
decade,
|
||||
openYears,
|
||||
onToggleYear,
|
||||
direction = 'desc',
|
||||
fillGaps = true,
|
||||
}: Props) {
|
||||
const years = fillGaps ? withGapYears(decade.years, direction) : decade.years
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`ngu-tl__decade${decade.preProgram ? ' ngu-tl__decade--pre' : ''}`}
|
||||
aria-labelledby={`ngu-tl-decade-${decade.decade}`}
|
||||
>
|
||||
<header className="ngu-tl__decadeHead">
|
||||
<span className="ngu-tl__decadeNum" aria-hidden="true">
|
||||
{decadeLabel(decade.decade)}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="ngu-tl__decadeTitle" id={`ngu-tl-decade-${decade.decade}`}>
|
||||
<span className="ngu-tl__srOnly">{decadeLabel(decade.decade)}: </span>
|
||||
{decade.title}
|
||||
</h3>
|
||||
{decade.tagline ? (
|
||||
<p className="ngu-tl__decadeTagline">{decade.tagline}</p>
|
||||
) : null}
|
||||
{decade.blurb ? <p className="ngu-tl__decadeBlurb">{decade.blurb}</p> : null}
|
||||
{decade.preProgram ? (
|
||||
<p className="ngu-tl__gapNote">
|
||||
<span aria-hidden="true">◌</span>
|
||||
Before the program — kept for context, not part of our record
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{years.map((year) => (
|
||||
<TimelineYear
|
||||
key={year.year}
|
||||
year={year}
|
||||
open={openYears.has(year.year)}
|
||||
onToggle={onToggleYear}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { MONTH_LABELS, type PersonRef, type TimelineItem } from '../../../lib/timeline'
|
||||
import { hrefFor, logoSrc, photoSrc } from '../../../lib/timelineRefs'
|
||||
|
||||
const KIND_NAMES: Record<TimelineItem['kind'], string> = {
|
||||
milestone: 'Milestone',
|
||||
event: 'Event',
|
||||
organization: 'Organization',
|
||||
award: 'Award',
|
||||
people: 'People',
|
||||
}
|
||||
|
||||
/** Day number only — the month node above already carries the month. */
|
||||
function dayMarker(item: TimelineItem): string {
|
||||
if (item.precision !== 'day') return ''
|
||||
const day = Number(item.date.split('-')[2])
|
||||
return Number.isFinite(day) ? String(day) : ''
|
||||
}
|
||||
|
||||
/** Featured entries sit above the month nodes, so they spell out their date. */
|
||||
function fullMarker(item: TimelineItem): string {
|
||||
const [, m, d] = item.date.split('-')
|
||||
if (item.precision === 'year' || !m) return 'Date unrecorded'
|
||||
const month = MONTH_LABELS[Number(m) - 1] ?? ''
|
||||
if (item.precision === 'month' || !d) return month
|
||||
return `${month} ${Number(d)}`
|
||||
}
|
||||
|
||||
/** Initials stand in when a person has no photo on file. */
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function PersonChip({ person }: { person: PersonRef }) {
|
||||
const src = photoSrc(person.photo)
|
||||
return (
|
||||
<li className="ngu-tl__person">
|
||||
{src ? (
|
||||
<img className="ngu-tl__personPhoto" src={src} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="ngu-tl__personPhoto ngu-tl__personPhoto--blank" aria-hidden="true">
|
||||
{initials(person.name)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ngu-tl__personName">
|
||||
{person.name}
|
||||
{person.title ? (
|
||||
<span className="ngu-tl__personTitle">{person.title}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
item: TimelineItem
|
||||
/** Featured entries render larger and lead the year. */
|
||||
variant?: 'inline' | 'featured'
|
||||
}
|
||||
|
||||
export default function TimelineEntry({ item, variant = 'inline' }: Props) {
|
||||
const featured = variant === 'featured'
|
||||
const href = hrefFor(item)
|
||||
const logo = logoSrc(item)
|
||||
|
||||
const className = [
|
||||
'ngu-tl__entry',
|
||||
featured ? 'ngu-tl__entry--featured' : '',
|
||||
href ? 'ngu-tl__entry--linked' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const marker = (
|
||||
<span className="ngu-tl__entryDay">
|
||||
{featured ? fullMarker(item) : dayMarker(item)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const body = (
|
||||
<span className="ngu-tl__entryBody">
|
||||
<span className="ngu-tl__entryTitle">
|
||||
{logo ? (
|
||||
<img className="ngu-tl__logo" src={logo} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span
|
||||
className={`ngu-tl__kind ngu-tl__kind--${item.kind}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.meta ? <span className="ngu-tl__entryMeta">{item.meta}</span> : null}
|
||||
{item.blurb ? <p className="ngu-tl__entryBlurb">{item.blurb}</p> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
// The people list sits outside the link: each name is its own
|
||||
// destination, and nesting anchors is invalid markup anyway.
|
||||
const roster =
|
||||
item.kind === 'people' && item.people && item.people.length > 0 ? (
|
||||
<div
|
||||
className={`ngu-tl__roster${featured ? ' ngu-tl__roster--featured' : ''}`}
|
||||
>
|
||||
{item.team ? (
|
||||
<span className="ngu-tl__rosterTeam">
|
||||
{item.team.name}
|
||||
{item.team.orgName ? ` · ${item.team.orgName}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
<ul className="ngu-tl__people">
|
||||
{item.people.map((person) => (
|
||||
<PersonChip key={person.id} person={person} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const label = `${KIND_NAMES[item.kind]}: ${item.title}`
|
||||
// An explicit href may point off-site; Link is for in-app routes only.
|
||||
const external = !!href && /^(https?:)?\/\//.test(href)
|
||||
|
||||
return (
|
||||
<li className="ngu-tl__entryWrap">
|
||||
{href && external ? (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{marker}
|
||||
{body}
|
||||
</a>
|
||||
) : href ? (
|
||||
<Link to={href} className={className} aria-label={label}>
|
||||
{marker}
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={className}>
|
||||
{marker}
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
{roster}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useState } from 'react'
|
||||
import type { GroupedYear, SortDirection } from '../../../lib/timeline'
|
||||
import TimelineYear from './TimelineYear'
|
||||
|
||||
type Props = {
|
||||
years: GroupedYear[]
|
||||
count: number
|
||||
direction?: SortDirection
|
||||
openYears: Set<number>
|
||||
onToggleYear: (year: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled but not yet happened. Sits above the most recent decade and
|
||||
* opens upward: the toggle is first in the DOM and the list is rendered
|
||||
* after it, with `flex-direction: column-reverse` flipping the visual
|
||||
* order. That keeps the button anchored next to the decade marker
|
||||
* instead of drifting up the page as items appear.
|
||||
*
|
||||
* Membership is decided by date, not by a flag — see partitionByDate.
|
||||
* An event moves into the record on its own, with nothing to update.
|
||||
*/
|
||||
export default function TimelineUpcoming({
|
||||
years,
|
||||
count,
|
||||
direction = 'desc',
|
||||
openYears,
|
||||
onToggleYear,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (count === 0) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`ngu-tl__upcoming${open ? ' ngu-tl__upcoming--open' : ''}`}
|
||||
aria-label="Upcoming"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ngu-tl__upcomingBtn"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="ngu-tl__upcomingDot" aria-hidden="true" />
|
||||
<span className="ngu-tl__upcomingLabel">
|
||||
{open
|
||||
? 'Hide what’s scheduled'
|
||||
: `${count} ${count === 1 ? 'thing' : 'things'} still to come`}
|
||||
</span>
|
||||
<span className="ngu-tl__chevron" aria-hidden="true">
|
||||
{open ? '▾' : '▴'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="ngu-tl__upcomingList">
|
||||
{years.map((year) => (
|
||||
<TimelineYear
|
||||
key={year.year}
|
||||
year={year}
|
||||
open={openYears.has(year.year)}
|
||||
onToggle={onToggleYear}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
90
src/pages/sections/history/TimelineYear.tsx
Normal file
90
src/pages/sections/history/TimelineYear.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import type { GroupedYear } from '../../../lib/timeline'
|
||||
import TimelineEntry from './TimelineEntry'
|
||||
|
||||
/** Dot diameter scales with volume, so the rail reads as a density chart. */
|
||||
function dotSize(count: number): string {
|
||||
return `${(7 + Math.min(count, 14) * 0.55).toFixed(2)}px`
|
||||
}
|
||||
|
||||
type Props = {
|
||||
year: GroupedYear
|
||||
open: boolean
|
||||
onToggle: (year: number) => void
|
||||
}
|
||||
|
||||
export default function TimelineYear({ year, open, onToggle }: Props) {
|
||||
const empty = year.count === 0
|
||||
const panelId = `ngu-tl-year-${year.year}`
|
||||
|
||||
const className = [
|
||||
'ngu-tl__year',
|
||||
year.featured ? 'ngu-tl__year--featured' : '',
|
||||
open ? 'ngu-tl__year--open' : '',
|
||||
empty ? 'ngu-tl__year--empty' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
type="button"
|
||||
className="ngu-tl__yearBtn"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => onToggle(year.year)}
|
||||
>
|
||||
<span
|
||||
className="ngu-tl__dot"
|
||||
style={{ '--tl-dot': dotSize(year.count) } as CSSProperties}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ngu-tl__yearLabel">{year.year}</span>
|
||||
<span className="ngu-tl__yearCount">
|
||||
{empty ? '—' : `${year.count} ${year.count === 1 ? 'entry' : 'entries'}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
empty ? (
|
||||
<div className="ngu-tl__empty" id={panelId}>
|
||||
Nothing recorded for {year.year} yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="ngu-tl__panel" id={panelId}>
|
||||
{year.featuredItems.length > 0 ? (
|
||||
<ul className="ngu-tl__featured ngu-tl__list">
|
||||
{year.featuredItems.map((item) => (
|
||||
<TimelineEntry key={item.id} item={item} variant="featured" />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{year.months.map((month) => (
|
||||
<section className="ngu-tl__month" key={month.month}>
|
||||
<h4 className="ngu-tl__monthLabel">{month.label}</h4>
|
||||
<ul className="ngu-tl__list">
|
||||
{month.items.map((item) => (
|
||||
<TimelineEntry key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{year.undated.length > 0 ? (
|
||||
<section className="ngu-tl__month ngu-tl__undated">
|
||||
<h4 className="ngu-tl__monthLabel">Elsewhere in {year.year}</h4>
|
||||
<ul className="ngu-tl__list">
|
||||
{year.undated.map((item) => (
|
||||
<TimelineEntry key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
663
src/pages/sections/history/timeline.css
Normal file
663
src/pages/sections/history/timeline.css
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
/**
|
||||
* History timeline — scoped under .ngu-tl.
|
||||
*
|
||||
* Everything themeable is a custom property with a fallback, so rewiring to the
|
||||
* site tokens is a one-block edit at the top. Typefaces are deliberately not
|
||||
* set: the timeline inherits the page's type and only controls scale, weight
|
||||
* and tracking.
|
||||
*/
|
||||
|
||||
.ngu-tl {
|
||||
/* Defaults match the site palette; History.tsx overrides accent and
|
||||
surface per section so the rail sits on the right background. */
|
||||
--tl-ink: #16343a;
|
||||
--tl-muted: #4a6b72;
|
||||
--tl-faint: #8aa7ad;
|
||||
--tl-rail: #cfe4e8;
|
||||
--tl-surface: #ffffff;
|
||||
--tl-accent: #138ba0;
|
||||
--tl-accent-soft: color-mix(in srgb, var(--tl-accent) 12%, transparent);
|
||||
|
||||
--tl-gutter: 5rem;
|
||||
--tl-rail-x: 1.75rem;
|
||||
--tl-rail-w: 2px;
|
||||
|
||||
color: var(--tl-ink);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ngu-tl {
|
||||
--tl-gutter: 3rem;
|
||||
--tl-rail-x: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Decade block ——————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__decade {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The rail. One per decade block so the pre-program run can change style
|
||||
without breaking the line's continuity. */
|
||||
.ngu-tl__decade::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--tl-rail-x);
|
||||
width: var(--tl-rail-w);
|
||||
background: var(--tl-rail);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre::before {
|
||||
background: none;
|
||||
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Last decade fades out rather than stopping dead. */
|
||||
.ngu-tl__decade:last-child::before {
|
||||
bottom: 2rem;
|
||||
mask-image: linear-gradient(to bottom, #000 60%, transparent);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre {
|
||||
color: var(--tl-muted);
|
||||
}
|
||||
|
||||
/* ——— Decade header ——————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__decadeHead {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gutter) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding-block: 3.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.ngu-tl__decade:first-child .ngu-tl__decadeHead {
|
||||
padding-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
/* Numeral breaks the rail. */
|
||||
.ngu-tl__decadeNum {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: calc(var(--tl-rail-x) * -1 + 0.125rem);
|
||||
padding-block: 0.35rem;
|
||||
background: var(--tl-surface);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.1;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: sideways;
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre .ngu-tl__decadeNum {
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__decadeTitle {
|
||||
font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0;
|
||||
max-width: 22ch;
|
||||
}
|
||||
|
||||
.ngu-tl__decadeTagline {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.5;
|
||||
color: var(--tl-muted);
|
||||
max-width: 54ch;
|
||||
}
|
||||
|
||||
.ngu-tl__decadeBlurb {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
color: var(--tl-muted);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.ngu-tl__gapNote {
|
||||
margin: 1rem 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px dashed var(--tl-rail);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
/* ——— Year node ———————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__year {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-block: 0.6875rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn:hover .ngu-tl__yearLabel {
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dot sits on the rail; --tl-dot is set inline from the year's item count, so
|
||||
scrolling the page gives you the organization's density at a glance. */
|
||||
.ngu-tl__dot {
|
||||
position: relative;
|
||||
justify-self: start;
|
||||
margin-left: calc(var(--tl-rail-x) - (var(--tl-dot, 8px) / 2) + (var(--tl-rail-w) / 2));
|
||||
width: var(--tl-dot, 8px);
|
||||
height: var(--tl-dot, 8px);
|
||||
border-radius: 50%;
|
||||
background: var(--tl-rail);
|
||||
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn:hover .ngu-tl__dot {
|
||||
background: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__year--featured .ngu-tl__dot {
|
||||
background: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__year--featured .ngu-tl__dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
border: 1.5px solid var(--tl-accent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.ngu-tl__year--open .ngu-tl__dot {
|
||||
background: var(--tl-accent);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre .ngu-tl__dot {
|
||||
background: var(--tl-surface);
|
||||
border: 1.5px solid var(--tl-rail);
|
||||
}
|
||||
|
||||
.ngu-tl__yearLabel {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 550;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.ngu-tl__yearCount {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--tl-faint);
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.ngu-tl__year--empty .ngu-tl__yearLabel {
|
||||
color: var(--tl-faint);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* ——— Expanded panel ——————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__panel {
|
||||
padding: 0.25rem 0 1.5rem var(--tl-gutter);
|
||||
animation: ngu-tl-reveal 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes ngu-tl-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.ngu-tl__month {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__month:first-child {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__monthLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 550;
|
||||
color: var(--tl-muted);
|
||||
}
|
||||
|
||||
.ngu-tl__monthLabel::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--tl-rail);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ngu-tl__undated .ngu-tl__monthLabel {
|
||||
font-style: italic;
|
||||
font-weight: 450;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* ——— Entry ————————————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__entry {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a.ngu-tl__entry:hover {
|
||||
background: var(--tl-accent-soft);
|
||||
}
|
||||
|
||||
a.ngu-tl__entry:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ngu-tl__entryDay {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--tl-faint);
|
||||
padding-top: 0.1875rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ngu-tl__entryTitle {
|
||||
display: block;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a.ngu-tl__entry:hover .ngu-tl__entryTitle {
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__entryMeta {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--tl-muted);
|
||||
}
|
||||
|
||||
.ngu-tl__entryBlurb {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--tl-muted);
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
/* Kind marker: a shape, not a colored pill, so a screenful of entries stays
|
||||
calm and the type still carries the hierarchy. */
|
||||
.ngu-tl__kind {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: 0.0625rem;
|
||||
background: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--milestone {
|
||||
border-radius: 50%;
|
||||
background: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--event {
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.ngu-tl__kind--award {
|
||||
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--person {
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||
}
|
||||
|
||||
/* ——— Featured entries ————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__featured {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 1rem 1.125rem;
|
||||
border-left: 2px solid var(--tl-accent);
|
||||
background: var(--tl-accent-soft);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured .ngu-tl__entryTitle {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured .ngu-tl__entryDay {
|
||||
text-align: left;
|
||||
padding: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
a.ngu-tl__entry--featured:hover {
|
||||
background: color-mix(in srgb, var(--tl-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
/* ——— Empty state ——————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__empty {
|
||||
padding: 0.5rem 0 1.25rem var(--tl-gutter);
|
||||
font-size: 0.875rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ngu-tl *,
|
||||
.ngu-tl *::before,
|
||||
.ngu-tl *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Toolbar + utilities ————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ngu-tl__toolbarBtn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--tl-rail);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--tl-muted);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.ngu-tl__toolbarBtn:hover {
|
||||
color: var(--tl-accent);
|
||||
border-color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__toolbarBtn:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ngu-tl__emptyState {
|
||||
padding: 3rem 0;
|
||||
color: var(--tl-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.ngu-tl__srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ——— Upcoming block ——————————————————————————————————————————
|
||||
Opens upward: the toggle is first in the DOM, column-reverse puts the
|
||||
list above it. Faded because none of it has happened yet. */
|
||||
|
||||
.ngu-tl__upcoming {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__upcoming::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
bottom: 0;
|
||||
left: var(--tl-rail-x);
|
||||
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingBtn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-block: 0.75rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
color: var(--tl-muted);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingBtn:hover {
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingBtn:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingDot {
|
||||
justify-self: start;
|
||||
margin-left: calc(var(--tl-rail-x) - 4px + (var(--tl-rail-w) / 2));
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px dashed var(--tl-accent);
|
||||
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingLabel {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ngu-tl__chevron {
|
||||
font-size: 0.75rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Everything above the toggle is provisional, and reads that way. */
|
||||
.ngu-tl__upcomingList {
|
||||
opacity: 0.62;
|
||||
animation: ngu-tl-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingList:hover,
|
||||
.ngu-tl__upcomingList:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingList .ngu-tl__dot {
|
||||
background: var(--tl-surface);
|
||||
border: 1.5px dashed var(--tl-accent);
|
||||
}
|
||||
|
||||
@keyframes ngu-tl-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Event / org / award logo ————————————————————————————————— */
|
||||
|
||||
.ngu-tl__logo {
|
||||
display: inline-block;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: -0.1875rem;
|
||||
object-fit: contain;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured .ngu-tl__logo {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
vertical-align: -0.3125rem;
|
||||
}
|
||||
|
||||
/* ——— People roster ——————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__entryWrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ngu-tl__roster {
|
||||
padding: 0.125rem 0 0.5rem 3.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__roster--featured {
|
||||
padding: 0.5rem 1.125rem 1rem;
|
||||
border-left: 2px solid var(--tl-accent);
|
||||
background: var(--tl-accent-soft);
|
||||
margin-top: -0.5rem;
|
||||
border-radius: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.ngu-tl__rosterTeam {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem 1rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ngu-tl__person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__personPhoto {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--tl-accent-soft);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ngu-tl__personPhoto--blank {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__personName {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ngu-tl__personTitle {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
/* ——— Kind markers, continued ————————————————————————————————— */
|
||||
|
||||
.ngu-tl__kind--organization {
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--people {
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__entryBody {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue