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