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>
This commit is contained in:
parent
b0e6d7e18b
commit
1bf1993040
3 changed files with 440 additions and 0 deletions
Binary file not shown.
258
src/pages/dispatches/[slug].astro
Normal file
258
src/pages/dispatches/[slug].astro
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
|
||||
import {
|
||||
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
||||
dispatchKindPigment, roleLabel,
|
||||
} from '../../lib/format';
|
||||
import { renderMd } from '../../lib/markdown';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const slugParam = Astro.params.slug ?? '';
|
||||
const id = parseDispatchSlug(slugParam);
|
||||
|
||||
if (!id) return Astro.redirect('/dispatches');
|
||||
|
||||
const d = getDispatchById(id);
|
||||
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
||||
|
||||
// Canonical-redirect when the slug changes after a rename — id is the authority
|
||||
const canonical = dispatchSlug(d);
|
||||
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
||||
|
||||
const { prev, next } = getAdjacentDispatches(d.id);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
const bodyHtml = renderMd(d.body);
|
||||
---
|
||||
<AppLayout title={d.title} user={user}>
|
||||
<article class="page">
|
||||
|
||||
<a href="/dispatches" class="back-link label-sm">← All dispatches</a>
|
||||
|
||||
<header class="head">
|
||||
<div class="head-meta">
|
||||
<span class="kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
|
||||
{dispatchKindLabel(d.kind)}
|
||||
</span>
|
||||
<time class="head-date label-sm" datetime={d.published_at ?? d.created_at}>
|
||||
{fmt(d.published_at ?? d.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 class="title"><em>{d.title}</em></h1>
|
||||
|
||||
<div class="byline">
|
||||
<Avatar id={d.author_id} name={d.author_name} size={32} />
|
||||
<div class="byline-text">
|
||||
<span class="byline-name">{d.author_name}</span>
|
||||
<span class="byline-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body prose" set:html={bodyHtml} />
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<nav class="adjacent" aria-label="Adjacent dispatches">
|
||||
{prev ? (
|
||||
<a class="adj-card adj-prev" href={`/dispatches/${dispatchSlug(prev)}`}>
|
||||
<span class="adj-direction label-sm">← Previous</span>
|
||||
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(prev.kind)}`}>
|
||||
{dispatchKindLabel(prev.kind)}
|
||||
</span>
|
||||
<span class="adj-title">{prev.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="adj-empty"></span>
|
||||
)}
|
||||
{next ? (
|
||||
<a class="adj-card adj-next" href={`/dispatches/${dispatchSlug(next)}`}>
|
||||
<span class="adj-direction label-sm">Next →</span>
|
||||
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(next.kind)}`}>
|
||||
{dispatchKindLabel(next.kind)}
|
||||
</span>
|
||||
<span class="adj-title">{next.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="adj-empty"></span>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
</article>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--on-surface-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||
|
||||
.head { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
.head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.kind-pill {
|
||||
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
||||
color: var(--pill);
|
||||
padding: 3px 12px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
.head-date {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.title em { font-style: italic; }
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.byline-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.byline-name { font-weight: 600; color: var(--on-surface); }
|
||||
.byline-role {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-body-lg);
|
||||
line-height: 1.7;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.body :global(p) { margin: 0 0 var(--space-4); }
|
||||
.body :global(h2) {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
margin: var(--space-6) 0 var(--space-3);
|
||||
}
|
||||
.body :global(h3) {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
margin: var(--space-5) 0 var(--space-2);
|
||||
}
|
||||
.body :global(blockquote) {
|
||||
border-left: 2px solid color-mix(in oklab, var(--pigment-terracotta) 40%, transparent);
|
||||
padding-left: var(--space-4);
|
||||
color: var(--on-surface-variant);
|
||||
font-style: italic;
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
.body :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--surface-container);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.body :global(ul), .body :global(ol) {
|
||||
padding-left: var(--space-5);
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
height: 0.5px;
|
||||
background: var(--surface-card-border);
|
||||
margin: var(--space-6) 0 0;
|
||||
}
|
||||
|
||||
.adjacent {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.adj-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
border-bottom: 0.5px solid var(--surface-card-border);
|
||||
color: inherit;
|
||||
transition: transform 300ms var(--ease-standard);
|
||||
}
|
||||
.adj-card:hover { transform: translateY(-2px); border-bottom-color: var(--surface-card-border); }
|
||||
.adj-next { text-align: right; align-items: flex-end; }
|
||||
|
||||
.adj-direction {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.adj-kind-pill {
|
||||
align-self: flex-start;
|
||||
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;
|
||||
}
|
||||
.adj-next .adj-kind-pill { align-self: flex-end; }
|
||||
.adj-title {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.adj-empty {} /* placeholder for missing prev/next slot */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.adjacent { grid-template-columns: 1fr; }
|
||||
.adj-next { text-align: left; align-items: flex-start; }
|
||||
.adj-next .adj-kind-pill { align-self: flex-start; }
|
||||
}
|
||||
</style>
|
||||
182
src/pages/dispatches/index.astro
Normal file
182
src/pages/dispatches/index.astro
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
---
|
||||
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>
|
||||
Loading…
Add table
Reference in a new issue