v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View 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>
)
}
}

View 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 }]}
/>
)
}

View file

@ -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);
}