feat: updates
This commit is contained in:
parent
76c7dfa985
commit
d300e4a76e
3 changed files with 455 additions and 0 deletions
243
src/pages/updates/[slug].astro
Normal file
243
src/pages/updates/[slug].astro
Normal 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>
|
||||||
41
src/pages/updates/feed.xml.ts
Normal file
41
src/pages/updates/feed.xml.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
171
src/pages/updates/index.astro
Normal file
171
src/pages/updates/index.astro
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue