200 lines
7.4 KiB
Plaintext
200 lines
7.4 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} />
|
|
|
|
{(neighbors.prev || neighbors.next) && (
|
|
<nav class="max-w-3xl mx-auto mt-20 md:mt-28 grid grid-cols-1 md:grid-cols-2 gap-6" aria-label="Post navigation">
|
|
{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">← Previous</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 →</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>
|
|
)}
|
|
</nav>
|
|
)}
|
|
</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>
|