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