project-bifrost-platform/src/pages/dispatches/index.astro
Jonathan Hvid 1bf1993040 feat(page): /dispatches index + /dispatches/[slug] detail
/dispatches: editorial header (DISPATCHES · 'Notes from the studio.' ·
'Decisions, half-built ideas, and things we've changed our mind about.')
+ a vertical list of every published dispatch. Each row is a single link
target with a three-column grid: 180px byline (avatar + author name + title
or role label) / 1fr body (serif italic title + kind pill, then a single-
paragraph excerpt) / 130px date column. 0.5px bottom borders, hover tint.

/dispatches/[slug]: 720px single-column read view. Header is kind pill +
publish date, serif italic title at 2rem, author byline with 32px avatar.
Body uses the existing renderMd() (marked) with serif italic h2s, copper
blockquotes, mono code blocks. Footer is a 0.5px divider then two adj-card
links (prev / next in published order) on opposite ends — the missing side
renders an empty grid slot so layout is preserved.

Canonical-slug redirect: if /dispatches/12-old-title is hit but the title
has since changed, the page issues a 302 to /dispatches/12-new-title. id
is the authority, kebab title is for readability.

format.ts: adds roleLabel (pilot/cab/fenja → 'Pilot' / 'Council' /
'Fenja team') for the byline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:07:13 +02:00

182 lines
5.3 KiB
Text

---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { getLatestPublishedDispatches } from '../../lib/db';
import {
dispatchSlug, dispatchKindLabel, dispatchKindPigment,
dispatchExcerptParas, roleLabel,
} from '../../lib/format';
const user = Astro.locals.user;
const dispatches = getLatestPublishedDispatches(200);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(iso));
}
---
<AppLayout title="Dispatches" user={user}>
<div class="page">
<header class="head">
<p class="label-sm head-eyebrow">Dispatches</p>
<h1 class="head-title"><em>Notes from the studio.</em></h1>
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
</header>
{dispatches.length === 0 ? (
<p class="body-md empty">Nothing posted yet.</p>
) : (
<ul class="d-list">
{dispatches.map(d => (
<li class="d-row">
<a href={`/dispatches/${dispatchSlug(d)}`} class="d-link">
<div class="d-byline">
<Avatar id={d.author_id} name={d.author_name} size={28} />
<span class="d-author-text">
<span class="d-author-name">{d.author_name}</span>
<span class="d-author-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
</span>
</div>
<div class="d-body">
<header class="d-title-row">
<h2 class="d-title">{d.title}</h2>
<span class="d-kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
{dispatchKindLabel(d.kind)}
</span>
</header>
<p class="d-excerpt">{dispatchExcerptParas(d).lead}</p>
</div>
<time class="d-date label-sm" datetime={d.published_at ?? d.created_at}>
{fmt(d.published_at ?? d.created_at)}
</time>
</a>
</li>
))}
</ul>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-title em { font-style: italic; }
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.empty { color: var(--on-surface-muted); }
.d-list { list-style: none; padding: 0; margin: 0; }
.d-row { border-bottom: 0.5px solid var(--surface-card-border); }
.d-row:last-child { border-bottom: none; }
.d-link {
display: grid;
grid-template-columns: 180px 1fr 130px;
gap: var(--space-5);
padding: var(--space-5) var(--space-3);
align-items: start;
text-decoration: none;
border-bottom: none;
color: inherit;
transition: background var(--duration-fast) var(--ease-standard);
}
.d-link:hover {
background: color-mix(in oklab, var(--surface-card) 60%, transparent);
border-bottom: none;
}
.d-byline { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
.d-author-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.d-author-name {
font-weight: 600;
color: var(--on-surface);
font-size: var(--text-body-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.d-author-role {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.d-body { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.d-title-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
flex-wrap: wrap;
}
.d-title {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.d-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.d-excerpt {
color: var(--on-surface-variant);
margin: 0;
line-height: var(--leading-relaxed);
}
.d-date {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
justify-self: end;
white-space: nowrap;
}
@media (max-width: 720px) {
.d-link {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.d-date { justify-self: start; }
}
</style>