feat: updates

This commit is contained in:
Jonathan 2026-04-18 22:47:13 +02:00
parent 76c7dfa985
commit d300e4a76e
3 changed files with 455 additions and 0 deletions

View file

@ -0,0 +1,243 @@
---
import { getCollection, getEntry } from 'astro:content';
import AppLayout from '../../layouts/AppLayout.astro';
import { fmtDate } from '../../lib/markdown';
const user = Astro.locals.user;
const { slug } = Astro.params;
const update = await getEntry('updates', slug as string);
if (!update) return Astro.redirect('/updates');
const { Content } = await update.render();
const allUpdates = await getCollection('updates');
const sorted = allUpdates.sort(
(a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
const currentIndex = sorted.findIndex((u) => u.slug === update.slug);
const prev = sorted[currentIndex + 1] ?? null;
const next = sorted[currentIndex - 1] ?? null;
---
<AppLayout title={update.data.title} user={user}>
<div class="page">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/updates" class="crumb label-sm">Updates</a>
<span class="crumb-sep" aria-hidden="true"></span>
<span class="crumb label-sm crumb-current">{update.data.title}</span>
</nav>
<article class="article">
<header class="article-header">
<time class="label-sm post-date" datetime={String(update.data.date)}>
{fmtDate(String(update.data.date))}
</time>
<h1 class="display-md article-title">{update.data.title}</h1>
<p class="lead article-summary">{update.data.summary}</p>
<p class="label-sm article-author">
<span class="author-name">{update.data.author}</span>
</p>
</header>
<div class="prose">
<Content />
</div>
</article>
<nav class="post-nav" aria-label="Post navigation">
{prev && (
<a href={`/updates/${prev.slug}`} class="post-nav-link post-nav-prev">
<span class="label-sm nav-dir">Earlier</span>
<span class="nav-title body-md">{prev.data.title}</span>
</a>
)}
{next && (
<a href={`/updates/${next.slug}`} class="post-nav-link post-nav-next">
<span class="label-sm nav-dir">Later</span>
<span class="nav-title body-md">{next.data.title}</span>
</a>
)}
</nav>
</div>
</AppLayout>
<style>
.page {
padding: var(--space-10) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Breadcrumb ──────────────────────────────────────────────────── */
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-8);
}
.crumb {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
a.crumb:hover {
color: var(--on-surface-variant);
border-bottom: none;
}
.crumb-sep {
color: var(--on-surface-muted);
}
.crumb-current {
color: var(--on-surface-variant);
}
/* ── Article ─────────────────────────────────────────────────────── */
.article {
max-width: var(--reading-max);
}
.article-header {
margin-bottom: var(--space-10);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.post-date {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.article-title {
margin: 0;
}
.article-summary {
color: var(--on-surface-variant);
margin: 0;
}
.article-author {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin: 0;
}
.author-name {
color: var(--secondary);
}
/* ── Prose content ───────────────────────────────────────────────── */
.prose :global(p) {
margin: 0 0 var(--space-5);
color: var(--on-surface-variant);
line-height: var(--leading-relaxed);
font-size: var(--text-body-lg);
}
.prose :global(h2) {
font-family: var(--font-serif);
font-size: var(--text-headline-md);
font-weight: 400;
letter-spacing: var(--tracking-snug);
color: var(--on-surface);
margin: var(--space-10) 0 var(--space-4);
line-height: var(--leading-snug);
}
.prose :global(h3) {
font-family: var(--font-serif);
font-size: var(--text-headline-sm);
font-weight: 400;
letter-spacing: var(--tracking-snug);
color: var(--on-surface);
margin: var(--space-8) 0 var(--space-3);
line-height: var(--leading-snug);
}
.prose :global(ul),
.prose :global(ol) {
padding-left: var(--space-6);
margin: 0 0 var(--space-5);
color: var(--on-surface-variant);
line-height: var(--leading-relaxed);
font-size: var(--text-body-lg);
}
.prose :global(li) {
margin-bottom: var(--space-2);
}
.prose :global(strong) {
font-weight: 600;
color: var(--on-surface);
}
.prose :global(em) {
font-style: italic;
}
.prose :global(code) {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--surface-container);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
color: var(--on-surface);
}
.prose :global(hr) {
border: none;
border-top: var(--ghost-border);
margin: var(--space-8) 0;
}
/* ── Post nav ────────────────────────────────────────────────────── */
.post-nav {
display: flex;
justify-content: space-between;
gap: var(--space-6);
margin-top: var(--space-12);
padding-top: var(--space-8);
border-top: var(--ghost-border);
max-width: var(--reading-max);
}
.post-nav-link {
display: flex;
flex-direction: column;
gap: var(--space-2);
text-decoration: none;
border-bottom: none;
max-width: 48%;
}
.post-nav-link:hover {
border-bottom: none;
}
.post-nav-next {
margin-left: auto;
text-align: right;
}
.nav-dir {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.nav-title {
color: var(--on-surface-variant);
transition: color var(--duration-fast) var(--ease-standard);
}
.post-nav-link:hover .nav-title {
color: var(--on-surface);
}
</style>

View file

@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
export const GET: APIRoute = async ({ site }) => {
const updates = await getCollection('updates');
const sorted = updates.sort(
(a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
const base = 'https://bifrost.fenja.ai';
const items = sorted.map((u) => {
const url = `${base}/updates/${u.slug}`;
const date = new Date(u.data.date).toUTCString();
return `
<item>
<title><![CDATA[${u.data.title}]]></title>
<description><![CDATA[${u.data.summary}]]></description>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${date}</pubDate>
<author>${u.data.author}</author>
</item>`;
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Project Bifrost Updates</title>
<description>Progress notes from Fenja AI during the Bifrost pilot.</description>
<link>${base}/updates</link>
<atom:link href="${base}/updates/feed.xml" rel="self" type="application/rss+xml"/>
<language>en</language>
${items.join('')}
</channel>
</rss>`;
return new Response(xml, {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
};

View file

@ -0,0 +1,171 @@
---
import { getCollection } from 'astro:content';
import AppLayout from '../../layouts/AppLayout.astro';
import { fmtDate } from '../../lib/markdown';
const user = Astro.locals.user;
const allUpdates = await getCollection('updates');
const updates = allUpdates.sort(
(a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
---
<AppLayout title="Updates" user={user}>
<div class="page">
<header class="page-header">
<p class="label-sm eyebrow">Updates</p>
<h1 class="display-md page-title">What we have built.</h1>
<p class="lead subtitle">
Progress notes from Fenja — what shipped, what shifted, what is next.
Posted after each sprint.
</p>
</header>
<ol class="update-list" reversed>
{updates.map((update) => (
<li class="update-item">
<time class="label-sm update-date" datetime={String(update.data.date)}>
{fmtDate(String(update.data.date))}
</time>
<div class="update-content">
<a href={`/updates/${update.slug}`} class="update-link">
<h2 class="headline-md update-title">{update.data.title}</h2>
</a>
<p class="body-md update-summary">{update.data.summary}</p>
<a href={`/updates/${update.slug}`} class="read-more label-sm">
Read update ↗
</a>
</div>
</li>
))}
</ol>
<footer class="feed-footer">
<a href="/updates/feed.xml" class="rss-link label-sm">RSS feed</a>
</footer>
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Header ──────────────────────────────────────────────────────── */
.page-header {
max-width: 44rem;
margin-bottom: var(--space-12);
}
.eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-3);
}
.page-title {
margin-bottom: var(--space-5);
}
.subtitle {
color: var(--on-surface-variant);
max-width: var(--reading-max);
margin: 0;
}
/* ── List ────────────────────────────────────────────────────────── */
.update-list {
list-style: none;
padding: 0;
margin: 0;
max-width: 52rem;
}
.update-item {
display: grid;
grid-template-columns: 9rem 1fr;
gap: var(--space-6);
padding: var(--space-8) 0;
border-top: var(--ghost-border);
align-items: start;
}
.update-item:last-child {
border-bottom: var(--ghost-border);
}
.update-date {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding-top: 0.25rem;
}
.update-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.update-link {
text-decoration: none;
border-bottom: none;
display: block;
}
.update-link:hover .update-title {
color: var(--secondary);
}
.update-link:hover {
border-bottom: none;
}
.update-title {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
margin: 0;
color: var(--on-surface);
transition: color var(--duration-fast) var(--ease-standard);
}
.update-summary {
color: var(--on-surface-variant);
margin: 0;
line-height: var(--leading-relaxed);
max-width: var(--reading-max);
}
.read-more {
color: var(--secondary);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
.read-more:hover {
color: var(--secondary-dim);
border-bottom: none;
}
/* ── Footer ──────────────────────────────────────────────────────── */
.feed-footer {
margin-top: var(--space-8);
}
.rss-link {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
}
.rss-link:hover {
color: var(--on-surface-variant);
border-bottom: none;
}
</style>