Files
narlblog/frontend/src/pages/posts/[slug].astro
T
2026-05-14 18:12:58 +02:00

215 lines
7.9 KiB
Plaintext

---
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/atom-one-dark.css';
import Layout from '../../layouts/Layout.astro';
import DeletePostButton from '../../components/react/DeletePostButton';
import { renderMarkdown } from '../../lib/markdown';
const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
interface CoverImage { url: string; alt: string; w?: number; h?: number }
interface PostNeighbor {
slug: string;
title?: string;
}
interface PostDetail {
slug: string;
date: string;
content: string;
title?: string;
summary?: string;
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
prev?: PostNeighbor;
next?: PostNeighbor;
dimensions?: Record<string, { w: number; h: number }>;
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
function formatSlug(s: string) {
if (!s) return '';
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
let post: PostDetail | null = null;
let html = '';
let error = '';
try {
const postRes = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
if (postRes.ok) {
post = await postRes.json();
html = renderMarkdown(post!.content, post!.dimensions);
} else {
error = 'Work not found in the catalogue';
}
} catch (e) {
const cause = (e as any)?.cause;
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
console.error(error);
}
const neighbors = {
prev: post?.prev,
next: post?.next,
};
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
---
<Layout
title={displayTitle}
description={post?.summary}
image={post?.cover_image?.url}
type="article"
>
{post?.cover_image?.url && (
<Fragment slot="head">
<link rel="preload" as="image" href={post.cover_image.url} fetchpriority="high" />
</Fragment>
)}
<div id="reading-progress" class="reading-progress" aria-hidden="true"></div>
{error && (
<div class="max-w-2xl mx-auto py-20 md:py-32 text-center">
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">Pardon —</div>
<h2 class="font-display italic text-3xl md:text-5xl text-[var(--mauve)] mb-6 leading-tight">{error}</h2>
<a href="/" class="btn-ghost">← Return to the catalogue</a>
</div>
)}
{post && (
<article class="plate-enter">
{/* Toolbar — exhibit nav */}
<div class="flex items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]">
<a href="/" class="inline-flex items-center gap-2 hover:text-[var(--mauve)] transition-colors group">
<span class="transition-transform group-hover:-translate-x-1">←</span>
Back to catalogue
</a>
{isAdmin && (
<div class="flex items-center gap-2">
<a href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`} class="btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
Edit
</a>
<DeletePostButton slug={post.slug} title={displayTitle} client:idle />
</div>
)}
</div>
{/* Plaque header */}
<header class="max-w-3xl mx-auto text-center mb-12 md:mb-16">
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-6xl lg:text-7xl leading-[0.95] tracking-tight mb-6">
{displayTitle}
</h1>
<div class="section-rule max-w-md mx-auto mb-6">
<span class="ornament">✦</span>
<span>{formatDate(post.date)}</span>
{post.image_count > 0 && (
<>
<span class="ornament">·</span>
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
</>
)}
<span class="ornament">✦</span>
</div>
{post.summary && (
<p class="font-display italic text-[var(--subtext1)] text-lg md:text-xl leading-relaxed max-w-2xl mx-auto">
{post.summary}
</p>
)}
{post.draft && (
<div class="mt-6 inline-block">
<span class="chip" style="background: var(--peach); color: var(--crust); border-color: var(--peach);">
Sketch · unpublished
</span>
</div>
)}
{post.tags?.length > 0 && (
<div class="flex flex-wrap justify-center gap-2 mt-6">
{post.tags.map(tag => <span class="chip">{tag}</span>)}
</div>
)}
</header>
{/* Body — works on paper */}
<div id="post-content" class="prose" set:html={html} />
{/* Closing — continue the room */}
<div class="max-w-3xl mx-auto mt-20 md:mt-28">
<div class="section-rule mb-10">
<span class="ornament">✦</span>
<span>continue the gallery</span>
<span class="ornament">✦</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{neighbors.prev && (
<a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left">
<div class="font-sans text-xs tracking-[0.22em] uppercase text-[var(--subtext0)] mb-2">← Previously hung</div>
<div class="font-display italic text-xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors leading-snug">
{neighbors.prev.title || formatSlug(neighbors.prev.slug)}
</div>
</a>
)}
{neighbors.next && (
<a href={`/posts/${encodeURIComponent(neighbors.next.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-right md:col-start-2">
<div class="font-sans text-xs tracking-[0.22em] uppercase text-[var(--subtext0)] mb-2">Next on the wall →</div>
<div class="font-display italic text-xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors leading-snug">
{neighbors.next.title || formatSlug(neighbors.next.slug)}
</div>
</a>
)}
{!neighbors.prev && !neighbors.next && (
<div class="md:col-span-2 text-center font-display italic text-[var(--subtext0)]">
This is the sole work currently on view.
</div>
)}
</div>
<div class="mt-12 text-center">
<a href="/" class="btn-ghost">↶ Return to catalogue</a>
</div>
</div>
</article>
)}
<script is:inline>
(function () {
const bar = document.getElementById('reading-progress');
const article = document.getElementById('post-content');
if (!bar || !article) return;
function update() {
const startY = article.offsetTop;
const distance = Math.max(1, article.offsetHeight - window.innerHeight);
const pct = Math.max(0, Math.min(1, (window.scrollY - startY) / distance));
bar.style.transform = 'scaleX(' + pct + ')';
}
let pending = false;
function schedule() {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
update();
});
}
update();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
})();
</script>
</Layout>