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
|
|
@ -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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue