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
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' }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue