init elas atelier

This commit is contained in:
2026-05-14 08:24:41 +02:00
parent 3b704a24a7
commit 38f33cacb1
19 changed files with 1436 additions and 657 deletions
+17 -15
View File
@@ -2,21 +2,23 @@
import Layout from '../layouts/Layout.astro';
---
<Layout title="Not found" description="The page you're looking for doesn't exist.">
<div class="glass p-8 md:p-16 text-center max-w-2xl mx-auto mt-8 md:mt-16">
<p class="text-7xl md:text-8xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal mb-4">
404
<Layout title="Not in the catalogue" description="The work you're looking for is not on view.">
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center">
<div class="numeral text-[var(--mauve)] text-[8rem] md:text-[12rem] leading-none mb-4">
CDIV
</div>
<div class="section-rule max-w-sm mx-auto mb-8">
<span class="ornament">✦</span>
<span>Pardon — the gallery has misplaced this work</span>
<span class="ornament">✦</span>
</div>
<h1 class="font-display italic text-3xl md:text-5xl text-[var(--text)] mb-6 leading-tight">
This piece is not on view.
</h1>
<p class="text-[var(--subtext1)] font-sans text-lg mb-10 leading-relaxed">
The room you reached for has either been re-hung, withdrawn,<br class="hidden md:block" />
or never made it to the wall in the first place.
</p>
<h1 class="text-2xl md:text-3xl font-semibold text-text mb-3">Page not found</h1>
<p class="text-subtext1 mb-8">
The page you're looking for has moved, been deleted, or never existed.
</p>
<a
href="/"
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-5 py-2.5 rounded-lg hover:bg-pink transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
Back to home
</a>
<a href="/" class="btn-stamp">↶ Return to the catalogue</a>
</div>
</Layout>
+1 -1
View File
@@ -41,7 +41,7 @@ export const GET: APIRoute = async ({ site }) => {
const origin = site?.toString().replace(/\/$/, '') || '';
let posts: PostInfo[] = [];
let config: SiteConfig = { title: 'Narlblog', subtitle: 'A clean, modern blog' };
let config: SiteConfig = { title: "Ela's Atelier", subtitle: 'Works on paper, canvas, and elsewhere' };
try {
const [pr, cr] = await Promise.all([
fetch(`${API_URL}/api/posts`),
+112 -98
View File
@@ -6,125 +6,139 @@ import AssetsButton from '../components/react/AssetsButton';
const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
interface CoverImage {
url: string;
alt: string;
}
interface Post {
slug: string;
date: string;
title?: string;
excerpt?: string;
tags: string[];
draft: boolean;
reading_time: number;
slug: string;
date: string;
title?: string;
summary?: string;
excerpt?: string;
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
let posts: Post[] = [];
let error = '';
let siteConfig = {
welcome_title: "Welcome to my blog",
welcome_subtitle: "Thoughts on software, design, and building things with Rust and Astro."
welcome_title: "Works on view",
welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations."
};
try {
const [postsRes, configRes] = await Promise.all([
fetch(`${API_URL}/api/posts`),
fetch(`${API_URL}/api/config`)
]);
const [postsRes, configRes] = await Promise.all([
fetch(`${API_URL}/api/posts`),
fetch(`${API_URL}/api/config`)
]);
if (postsRes.ok) {
posts = await postsRes.json();
} else {
error = 'Failed to fetch posts';
}
if (postsRes.ok) {
posts = await postsRes.json();
} else {
error = 'Failed to fetch works';
}
if (configRes.ok) {
siteConfig = await configRes.json();
}
if (configRes.ok) {
siteConfig = await configRes.json();
}
} 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 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 isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const total = posts.length;
---
<Layout title="Home" description={siteConfig.welcome_subtitle}>
<div class="space-y-6 md:space-y-8">
<section class="text-center py-6 md:py-12">
<h1 class="text-3xl md:text-5xl font-extrabold mb-3 md:mb-4 pb-2 md:pb-4 leading-tight">
{isAdmin ? (
<EditableText
client:load
initial={siteConfig.welcome_title}
fieldKey="welcome_title"
isAdmin
ariaLabel="welcome title"
className="bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal"
/>
) : (
<span class="bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal">
{siteConfig.welcome_title}
</span>
<Layout title="Catalogue" description={siteConfig.welcome_subtitle}>
<section class="relative mb-16 md:mb-24">
<div class="flex flex-col md:flex-row md:items-end gap-8 md:gap-12">
<div class="flex-1 max-w-2xl">
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">
Currently arranged — {new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
</div>
<h1 class="font-display italic font-semibold text-[var(--text)] text-5xl md:text-7xl lg:text-8xl leading-[0.95] tracking-tight mb-6">
{isAdmin ? (
<EditableText
client:load
initial={siteConfig.welcome_title}
fieldKey="welcome_title"
isAdmin
ariaLabel="welcome title"
className="inline"
/>
) : siteConfig.welcome_title}
</h1>
<p class="font-sans text-lg md:text-xl text-[var(--subtext1)] leading-relaxed max-w-xl">
{isAdmin ? (
<EditableText
client:load
initial={siteConfig.welcome_subtitle}
fieldKey="welcome_subtitle"
isAdmin
ariaLabel="welcome subtitle"
multiline
className="inline"
/>
) : siteConfig.welcome_subtitle}
</p>
{isAdmin && (
<div class="mt-8 flex flex-wrap items-center gap-3">
<a href="/admin/editor" class="btn-stamp">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
Hang new work
</a>
<AssetsButton client:load />
<a href="/admin/settings" 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" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
Settings
</a>
</div>
)}
</h1>
<p class="text-subtext1 text-base md:text-lg max-w-2xl mx-auto px-4 md:px-0">
{isAdmin ? (
<EditableText
client:load
initial={siteConfig.welcome_subtitle}
fieldKey="welcome_subtitle"
isAdmin
ariaLabel="welcome subtitle"
multiline
className="inline"
/>
) : siteConfig.welcome_subtitle}
</p>
</div>
{isAdmin && (
<div class="mt-6 md:mt-8 flex flex-wrap items-center justify-center gap-2 md:gap-3">
<a
href="/admin/editor"
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-4 py-2 rounded-lg hover:bg-pink transition-colors text-sm shadow-lg shadow-mauve/20"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
New Post
</a>
<AssetsButton client:load />
<a
href="/admin/settings"
class="inline-flex items-center gap-2 bg-surface0 hover:bg-surface1 text-subtext1 hover:text-text px-3 py-2 rounded-lg border border-surface1 transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
Settings
</a>
<aside class="md:w-64 lg:w-80 shrink-0 md:pb-2">
<div class="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-2">Index</div>
<div class="numeral text-5xl md:text-6xl text-[var(--mauve)] leading-none mb-3">
{String(total).padStart(2, '0')}
</div>
)}
</section>
<div class="flex flex-col space-y-6">
{error && (
<div class="glass p-4 md:p-6 text-red text-center border-red/20 text-sm md:text-base">
{error}
<div class="font-display italic text-[var(--subtext1)] text-base leading-snug">
{total === 1 ? 'work hanging' : 'works hanging'},
<span class="font-hand text-[var(--mauve)] text-xl ml-1">arranged below</span>
</div>
)}
{posts.length === 0 && !error && !isAdmin && (
<div class="glass p-8 md:p-12 text-center text-sm md:text-base text-subtext1">
<p>No posts yet — check back soon.</p>
<div class="mt-4 h-px bg-[var(--surface2)]"></div>
<div class="mt-3 text-[var(--subtext0)] font-display italic text-sm">
Scroll the room — no two visits the same.
</div>
)}
{posts.length === 0 && !error && isAdmin && (
<div class="glass p-8 md:p-12 text-center text-sm md:text-base">
<p class="text-subtext1 mb-4">No posts yet. Write your first one.</p>
<a href="/admin/editor" class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-4 py-2 rounded-lg hover:bg-pink transition-colors text-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
New Post
</a>
</div>
)}
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:load />}
</aside>
</div>
</div>
</section>
{error && (
<div class="glass p-6 md:p-8 text-center mb-12 border-[var(--red)]/40">
<p class="font-display italic text-[var(--red)] text-lg">{error}</p>
</div>
)}
{posts.length === 0 && !error && (
<div class="glass p-12 md:p-20 text-center max-w-2xl mx-auto">
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">Notice</div>
<p class="font-display italic text-[var(--text)] text-2xl md:text-3xl leading-snug mb-2">
The exhibition is currently being arranged.
</p>
<p class="font-sans text-[var(--subtext1)] mt-4">Please return shortly.</p>
{isAdmin && (
<a href="/admin/editor" class="btn-stamp mt-8">Hang the first work</a>
)}
</div>
)}
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:load />}
</Layout>
+137 -73
View File
@@ -6,6 +6,7 @@ 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 }
interface PostDetail {
slug: string;
date: string;
@@ -15,28 +16,29 @@ interface PostDetail {
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
interface PostInfo {
slug: string;
title?: string;
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
let post: PostDetail | null = null;
let html = '';
let error = '';
try {
const response = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
if (response.ok) {
post = await response.json();
html = renderMarkdown(post!.content);
} else {
error = 'Post not found';
function toRoman(n: number): string {
const map: [number, string][] = [
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];
let out = '';
for (const [val, sym] of map) {
while (n >= val) { out += sym; n -= val; }
}
} 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);
return out;
}
function formatSlug(s: string) {
@@ -44,52 +46,74 @@ function formatSlug(s: string) {
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
let post: PostDetail | null = null;
let html = '';
let error = '';
let neighbors: { prev?: PostInfo; next?: PostInfo; index: number; total: number } = { index: -1, total: 0 };
try {
const [postRes, listRes] = await Promise.all([
fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`),
fetch(`${API_URL}/api/posts`),
]);
if (postRes.ok) {
post = await postRes.json();
html = renderMarkdown(post!.content);
} else {
error = 'Work not found in the catalogue';
}
if (listRes.ok) {
const list: PostInfo[] = await listRes.json();
const i = list.findIndex(p => p.slug === slug);
if (i >= 0) {
neighbors = {
index: i,
total: list.length,
prev: i > 0 ? list[i - 1] : undefined,
next: i < list.length - 1 ? list[i + 1] : undefined,
};
}
}
} 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 isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
const exhibitNumber = neighbors.index >= 0 ? toRoman(neighbors.index + 1) : '';
---
<Layout
title={displayTitle}
description={post?.summary}
image={post?.cover_image?.url}
type="article"
>
{/* Reading progress bar */}
<div
id="reading-progress"
class="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-mauve via-blue to-teal z-[150] origin-left"
style="transform: scaleX(0); transition: transform 80ms linear;"
aria-hidden="true"
></div>
<div id="reading-progress" class="reading-progress" aria-hidden="true"></div>
{error && (
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center">
<h2 class="text-2xl md:text-3xl font-bold text-red mb-4">{error}</h2>
<a href="/" class="inline-flex items-center gap-2 text-blue hover:text-sky transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back home
</a>
<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="animate-in fade-in slide-in-from-bottom-2 duration-500">
{/* Toolbar: Back to list + admin actions */}
<div class="flex items-center justify-between gap-3 mb-8 md:mb-12">
<a
href="/"
class="inline-flex items-center gap-2 text-subtext0 hover:text-text transition-colors text-sm group"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back to list
<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="inline-flex items-center gap-2 bg-surface0 hover:bg-blue/15 text-subtext1 hover:text-blue px-3 py-1.5 md:px-4 md:py-2 rounded-md border border-surface1 hover:border-blue/30 transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
<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:load />
@@ -97,50 +121,90 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post';
)}
</div>
{/* Hero header — centered title + meta */}
<header class="max-w-3xl mx-auto text-center mb-10 md:mb-16">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 mb-4 text-xs md:text-sm text-subtext0">
<time datetime={post.date}>{formatDate(post.date)}</time>
<span class="opacity-50">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<>
<span class="opacity-50">·</span>
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
</>
)}
</div>
<h1 class="text-3xl md:text-5xl lg:text-6xl font-extrabold text-mauve leading-[1.1] mb-6 md:mb-8 tracking-tight">
{/* Plaque header */}
<header class="max-w-3xl mx-auto text-center mb-12 md:mb-16">
{exhibitNumber && (
<div class="font-display italic text-[var(--mauve)] tracking-[0.3em] text-sm mb-5">
№ {exhibitNumber} <span class="text-[var(--subtext0)] not-italic">/ {neighbors.total}</span>
</div>
)}
<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>
<span class="ornament">·</span>
<span>{post.reading_time} min</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="text-base md:text-xl text-subtext1 leading-relaxed max-w-2xl mx-auto">
<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="text-[10px] uppercase tracking-wider px-2.5 py-1 rounded-full bg-surface0 text-subtext0 border border-surface1">{tag}</span>
))}
{post.tags.map(tag => <span class="chip">{tag}</span>)}
</div>
)}
</header>
<div class="w-full max-w-3xl mx-auto h-px bg-gradient-to-r from-transparent via-surface2 to-transparent mb-10 md:mb-16"></div>
{/* Body — works on paper */}
<div id="post-content" class="prose" set:html={html} />
{/* Body */}
<div id="post-content" class="prose px-1" 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>
{/* Footer separator + back link */}
<div class="max-w-3xl mx-auto mt-16 md:mt-24 pt-8 md:pt-12 border-t border-surface1/60 text-center">
<a
href="/"
class="inline-flex items-center gap-2 text-subtext0 hover:text-mauve transition-colors text-sm group"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back to all posts
</a>
<div class="mt-12 text-center">
<a href="/" class="btn-ghost">↶ Return to catalogue</a>
</div>
</div>
</article>
)}