added site modes

This commit is contained in:
2026-05-18 13:51:33 +02:00
parent c3aa52ddfd
commit 86f855493b
18 changed files with 538 additions and 52 deletions
+7
View File
@@ -18,3 +18,10 @@ FRONTEND_ORIGIN=
# Frontend Configuration
# URL of the backend API accessible from the frontend container.
PUBLIC_API_URL=http://backend:3000
# Presentation focus. Same skin either way (fonts, cybersigil/breakcore,
# paper grain, CyberFx). `atelier` = image-first gallery (justified plates,
# "plates" count). `blog` = writing-first (stacked rows, excerpt, reading
# time). Read server-side at render — no rebuild needed to switch.
# Anything other than `blog` falls back to atelier.
SITE_MODE=atelier
+1
View File
@@ -42,6 +42,7 @@ services:
- "4322:4321"
environment:
- PUBLIC_API_URL=http://backend:3000
- SITE_MODE=${SITE_MODE:-atelier}
depends_on:
backend:
condition: service_healthy
@@ -1,21 +1,24 @@
import { useState } from 'react';
import { deletePost } from '../../lib/api';
import { confirmDialog, notify } from '../../lib/confirm';
import { type SiteMode, copy } from '../../lib/siteMode';
interface Props {
slug: string;
title: string;
variant?: 'icon' | 'full';
mode?: SiteMode;
}
export default function DeletePostButton({ slug, title, variant = 'full' }: Props) {
export default function DeletePostButton({ slug, title, variant = 'full', mode = 'atelier' }: Props) {
const [busy, setBusy] = useState(false);
const c = copy(mode);
async function handleClick() {
if (busy) return;
const ok = await confirmDialog({
title: 'Delete this work?',
message: `${title}” will be permanently removed. This cannot be undone.`,
title: c.deletePostTitle,
message: c.deletePostMsg(title),
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
});
+121 -7
View File
@@ -6,6 +6,7 @@ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : use
import { deletePost } from '../../lib/api';
import { confirmDialog, notify } from '../../lib/confirm';
import { buildCybersigil } from '../../lib/cybersigil';
import { type SiteMode, copy } from '../../lib/siteMode';
// Per-plate sigil accent. Built post-mount (not during render) so the random
// markup never differs between SSR and hydration. Inert/display:none off the
@@ -48,6 +49,7 @@ interface Post {
interface Props {
posts: Post[];
isAdmin?: boolean;
mode?: SiteMode;
}
function formatSlug(slug: string) {
@@ -66,6 +68,15 @@ function formatMonth(date: string) {
return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
}
// Blog rows want a human date, not the gallery's terse MONTH / YEAR split.
function formatLongDate(date: string) {
return new Date(date).toLocaleDateString('en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
// ── Justified-gallery layout ────────────────────────────────────────────────
// Every cover keeps its true aspect ratio (no crop). Tiles are packed left to
// right; once a tentative row is at least as wide as the container it is
@@ -139,7 +150,9 @@ function buildRows(
return rows;
}
export default function PostList({ posts: initialPosts, isAdmin = false }: Props) {
export default function PostList({ posts: initialPosts, isAdmin = false, mode = 'atelier' }: Props) {
const isBlog = mode === 'blog';
const c = copy(mode);
const [posts, setPosts] = useState(initialPosts);
const [deleting, setDeleting] = useState<string | null>(null);
const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length));
@@ -153,10 +166,10 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
async function handleDelete(slug: string, title: string) {
if (deleting) return;
const ok = await confirmDialog({
title: 'Take this off the wall?',
message: `${title}” will be removed from the catalogue. This cannot be undone.`,
confirmLabel: 'Remove',
cancelLabel: 'Keep',
title: c.deleteListTitle,
message: c.deleteListMsg(title),
confirmLabel: c.deleteListConfirm,
cancelLabel: c.deleteListCancel,
});
if (!ok) return;
setDeleting(slug);
@@ -164,7 +177,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
await deletePost(slug);
setPosts(p => p.filter(x => x.slug !== slug));
} catch (e) {
notify(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`);
notify(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`);
} finally {
setDeleting(null);
}
@@ -196,6 +209,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
// Measure the container + plate chrome and (re)compute the justified rows.
// Re-runs on resize and whenever the shown set changes (infinite scroll).
useIsoLayoutEffect(() => {
if (isBlog) return; // blog mode renders a plain stack — no justified math
const container = containerRef.current;
if (!container) return;
@@ -344,6 +358,106 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
);
};
// ── Blog mode: one writing-first row per post ──────────────────────────
const renderRow = (post: Post, idx: number) => {
const displayTitle = post.title || formatSlug(post.slug);
const isDeleting = deleting === post.slug;
const hasCover = !!post.cover_image?.url;
const blurb = post.summary || post.excerpt;
const stagger = Math.min(idx * 55, 420);
return (
<article
key={post.slug}
className={`post-row plate-enter ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
style={{ animationDelay: `${stagger}ms` }}
>
<a
href={`/posts/${encodeURIComponent(post.slug)}`}
className="post-row-link group"
aria-label={`Read ${displayTitle}`}
>
<div className="post-row-body">
<h2 className="post-row-title">{displayTitle}</h2>
<div className="post-row-meta">
<span>{formatLongDate(post.date)}</span>
<span className="sep" aria-hidden="true">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<span className="chip chip-draft post-row-draft">{c.draftShort}</span>
)}
</div>
{blurb && <p className="post-row-excerpt">{blurb}</p>}
{post.tags && post.tags.length > 0 && (
<div className="post-row-tags">
{post.tags.slice(0, 4).map(tag => (
<span key={tag} className="chip">{tag}</span>
))}
</div>
)}
</div>
{hasCover && (
<div className="post-row-thumb">
<img
src={post.cover_image!.url}
alt={post.cover_image!.alt || displayTitle}
width={post.cover_image!.w}
height={post.cover_image!.h}
loading={idx < 4 ? 'eager' : 'lazy'}
decoding="async"
/>
<PlateSigil />
</div>
)}
</a>
{isAdmin && (
<div className="absolute top-3 right-3 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<a
href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
onClick={e => e.stopPropagation()}
title="Edit"
aria-label={`Edit ${displayTitle}`}
className="btn btn--ghost btn--icon btn--sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="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>
</a>
<button
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDelete(post.slug, displayTitle); }}
disabled={isDeleting}
title="Delete"
aria-label={`Delete ${displayTitle}`}
className="btn btn--danger btn--icon btn--sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" /></svg>
</button>
</div>
)}
</article>
);
};
if (isBlog) {
return (
<>
<div className="post-list">
{shown.map((post, idx) => renderRow(post, idx))}
</div>
{hasMore && (
<div
ref={sentinelRef}
className="mt-12 md:mt-16 flex items-center justify-center text-[var(--subtext0)] font-display italic text-sm tracking-[0.2em] uppercase"
aria-hidden="true"
>
<span className="opacity-60">{c.loadingMore}</span>
</div>
)}
</>
);
}
return (
<>
<div ref={containerRef} className="just-gallery">
@@ -369,7 +483,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
className="mt-12 md:mt-16 flex items-center justify-center text-[var(--subtext0)] font-display italic text-sm tracking-[0.2em] uppercase"
aria-hidden="true"
>
<span className="opacity-60">arranging more</span>
<span className="opacity-60">{c.loadingMore}</span>
</div>
)}
</>
+9 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { getPosts } from '../../lib/api';
import { type SiteMode, copy } from '../../lib/siteMode';
interface Post {
slug: string;
@@ -27,7 +28,8 @@ function formatDate(date: string) {
});
}
export default function Search() {
export default function Search({ mode = 'atelier' }: { mode?: SiteMode }) {
const c = copy(mode);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [posts, setPosts] = useState<Post[] | null>(null);
@@ -128,7 +130,7 @@ export default function Search() {
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Search the catalogue (${isMac ? '⌘' : 'Ctrl'}+K)`}
aria-label={`${c.searchAria} (${isMac ? '⌘' : 'Ctrl'}+K)`}
className="topbar-control tc-collapse-md kbd-tip-host"
>
<svg
@@ -156,7 +158,7 @@ export default function Search() {
className="search-overlay fixed inset-0 z-[200] flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4"
role="dialog"
aria-modal="true"
aria-label="Search the catalogue"
aria-label={c.searchAria}
onClick={() => setOpen(false)}
>
<div
@@ -194,7 +196,7 @@ export default function Search() {
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="Search the catalogue…"
placeholder={`${c.searchPlaceholder}`}
aria-label="Search query"
className="search-input flex-1 bg-transparent outline-none text-base text-[var(--text)] placeholder:text-[var(--subtext0)] font-display italic"
/>
@@ -205,14 +207,14 @@ export default function Search() {
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">Fetching the catalogue</div>
<div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">{c.searchFetching}</div>
)}
{error && (
<div className="px-4 py-8 text-center text-sm text-[var(--red)] font-display italic">{error}</div>
)}
{!loading && !error && posts && results.length === 0 && (
<div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">
{query ? 'No works match.' : 'The catalogue is empty.'}
{query ? c.searchNoMatch : c.searchEmpty}
</div>
)}
{!loading && !error && results.length > 0 && (
@@ -236,7 +238,7 @@ export default function Search() {
<span className="truncate">{title}</span>
{p.draft && (
<span className="chip chip-draft shrink-0 !text-[0.62rem] !py-0">
Sketch
{c.draftShort}
</span>
)}
</div>
+11 -8
View File
@@ -13,14 +13,17 @@ import { useLivePreview } from './editor/useLivePreview';
import { useImageUpload } from './editor/useImageUpload';
import { useAssetAutocomplete } from './editor/useAssetAutocomplete';
import { usePostMeta } from './editor/usePostMeta';
import { type SiteMode, copy } from '../../../lib/siteMode';
const AssetManager = lazy(() => import('./AssetManager'));
interface Props {
editSlug?: string;
mode?: SiteMode;
}
export default function Editor({ editSlug }: Props) {
export default function Editor({ editSlug, mode = 'atelier' }: Props) {
const c = copy(mode);
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [vimEnabled, setVimEnabled] = useState(false);
@@ -41,7 +44,7 @@ export default function Editor({ editSlug }: Props) {
editorRef,
getCachedAssets: assetCache.getCachedAssets,
});
const meta = usePostMeta({ editSlug, getContent, setContent });
const meta = usePostMeta({ editSlug, getContent, setContent, mode });
const {
title, setTitle, slug, setSlug, setSlugTouched, date, setDate,
@@ -215,7 +218,7 @@ export default function Editor({ editSlug }: Props) {
value={title}
onChange={e => setTitle(e.target.value)}
required
placeholder="Untitled (charcoal on paper)"
placeholder={c.editorTitlePh}
className="field-input"
/>
</div>
@@ -240,7 +243,7 @@ export default function Editor({ editSlug }: Props) {
value={slug}
onChange={e => { setSlug(e.target.value); setSlugTouched(true); }}
required
placeholder="untitled-charcoal-on-paper"
placeholder={c.editorSlugPh}
className="field-input font-mono"
/>
</div>
@@ -253,7 +256,7 @@ export default function Editor({ editSlug }: Props) {
type="text"
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
placeholder="oil, paper, 2026, study"
placeholder={c.editorTagsPh}
className="field-input"
/>
</div>
@@ -264,18 +267,18 @@ export default function Editor({ editSlug }: Props) {
onChange={e => setDraft(e.target.checked)}
className="accent-[var(--peach)]"
/>
<span className="text-sm font-display italic text-[var(--subtext1)]">Sketch (draft)</span>
<span className="text-sm font-display italic text-[var(--subtext1)]">{c.editorDraftLabel}</span>
</label>
</div>
{/* Summary */}
<div>
<label className="field-label">Caption (optional)</label>
<label className="field-label">{c.editorSummaryLabel}</label>
<textarea
value={summary}
onChange={e => setSummary(e.target.value)}
rows={2}
placeholder="A short caption for the catalogue index..."
placeholder={c.editorSummaryPh}
className="field-input resize-none"
/>
</div>
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { login, ApiError } from '../../../lib/api';
import { useAuth } from '../../../stores/auth';
import { type SiteMode, copy } from '../../../lib/siteMode';
export default function Login() {
export default function Login({ mode = 'atelier' }: { mode?: SiteMode }) {
const c = copy(mode);
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -80,7 +82,7 @@ export default function Login() {
<div className="text-center mt-6">
<a href="/" className="back-link">
<span className="bl-arrow" aria-hidden="true"></span>
Back to the catalogue
{c.adminBack}
</a>
</div>
</div>
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react';
import { ApiError, deletePost, getPost, savePost } from '../../../../lib/api';
import { confirmDialog, notify } from '../../../../lib/confirm';
import { type SiteMode, copy } from '../../../../lib/siteMode';
import { clientSlugify } from './codemirror';
interface Opts {
editSlug?: string;
getContent: () => string;
setContent: (s: string) => void;
mode?: SiteMode;
}
/** Post metadata form + slug derivation + load/save/delete. */
export function usePostMeta({ editSlug, getContent, setContent }: Opts) {
export function usePostMeta({ editSlug, getContent, setContent, mode = 'atelier' }: Opts) {
const c = copy(mode);
const today = new Date().toISOString().slice(0, 10);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState(editSlug || '');
@@ -87,9 +90,9 @@ export function usePostMeta({ editSlug, getContent, setContent }: Opts) {
async function handleDelete() {
const target = originalSlug || slug;
const ok = await confirmDialog({
title: 'Remove from catalogue?',
message: `${target}” will be permanently removed. This cannot be undone.`,
confirmLabel: 'Remove',
title: c.deletePostTitle,
message: c.deletePostMsg(target),
confirmLabel: c.deleteListConfirm,
});
if (!ok) return;
try {
+4 -2
View File
@@ -1,5 +1,6 @@
---
import Layout from './Layout.astro';
import { getSiteMode, copy } from '../lib/siteMode';
interface Props {
title: string;
@@ -7,6 +8,7 @@ interface Props {
}
const { title, wide = false } = Astro.props;
const c = copy(getSiteMode());
if (Astro.cookies.get('admin_session')?.value !== '1') {
return Astro.redirect('/admin/login');
@@ -19,9 +21,9 @@ if (Astro.cookies.get('admin_session')?.value !== '1') {
<div class="flex-1 min-w-0">
<a href="/" class="back-link mb-3">
<span class="bl-arrow" aria-hidden="true">←</span>
Back to the catalogue
{c.adminBack}
</a>
<div class="font-display italic text-[var(--mauve)] text-xs tracking-[0.3em] uppercase mb-2">Artist's desk</div>
<div class="font-display italic text-[var(--mauve)] text-xs tracking-[0.3em] uppercase mb-2">{c.adminEyebrow}</div>
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-5xl tracking-tight leading-[1.05]">
{title}
</h1>
+7 -3
View File
@@ -10,6 +10,7 @@ import CyberFx from '../components/CyberFx.astro';
import Search from '../components/react/Search';
import LogoutButton from '../components/react/LogoutButton';
import EditableText from '../components/react/EditableText';
import { getSiteMode, copy } from '../lib/siteMode';
interface Props {
title: string;
@@ -24,6 +25,9 @@ interface Props {
const { title, wide = false, description, image, type = 'website', minimal = false } = Astro.props;
const siteMode = getSiteMode();
const c = copy(siteMode);
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
@@ -60,7 +64,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
---
<!doctype html>
<html lang="en">
<html lang="en" class={`mode-${siteMode}`}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -133,7 +137,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
{hasContact && (
<a href="/contact" class="topbar-control">Contact</a>
)}
<Search client:idle />
<Search client:idle mode={siteMode} />
<span class="topbar-divider" aria-hidden="true"></span>
{isAdmin ? (
<LogoutButton client:idle />
@@ -162,7 +166,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
<>
<div class="section-rule mb-6">
<span class="ornament">✦</span>
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span>
<span class="font-display italic text-[var(--subtext0)] text-sm">{c.footerEnd}</span>
<span class="ornament">✦</span>
</div>
<p class="font-display italic text-base text-[var(--subtext1)] mb-2">
+99
View File
@@ -0,0 +1,99 @@
// Site presentation mode. The atelier skin (fonts, cybersigil/breakcore
// themes, paper grain, CyberFx) is identical in both modes — only the *focus*
// flips: `atelier` puts images first (justified gallery plates, "plates"
// count), `blog` puts writing first (stacked rows, excerpts, reading time).
//
// Resolved server-side from the SITE_MODE env var. React islands cannot read
// process.env in the browser, so pages pass the resolved mode down as a prop;
// islands then look up COPY by mode.
export type SiteMode = 'blog' | 'atelier';
/** Server-side only. Defaults to atelier for any unset/unknown value. */
export function getSiteMode(): SiteMode {
const v = typeof process !== 'undefined' ? process.env.SITE_MODE : undefined;
return v === 'blog' ? 'blog' : 'atelier';
}
/**
* Mode-keyed user-facing strings. Atelier keeps the gallery voice; blog
* neutralises it. Anything not voice-flavoured (e.g. "Search", "Cancel")
* stays out of here so there's no needless duplication.
*/
export const COPY = {
atelier: {
indexTitle: 'Catalogue',
backHome: 'Back to catalogue',
adminBack: 'Back to the catalogue',
adminEyebrow: "Artist's desk",
footerEnd: 'end of catalogue',
loadingMore: 'arranging more…',
draftShort: 'Sketch',
draftLong: 'Sketch · unpublished',
searchPlaceholder: 'Search the catalogue…',
searchAria: 'Search the catalogue',
searchFetching: 'Fetching the catalogue…',
searchEmpty: 'The catalogue is empty.',
searchNoMatch: 'No works match.',
deleteListTitle: 'Take this off the wall?',
deleteListMsg: (t: string) =>
`${t}” will be removed from the catalogue. This cannot be undone.`,
deleteListConfirm: 'Remove',
deleteListCancel: 'Keep',
deletePostTitle: 'Delete this work?',
deletePostMsg: (t: string) => `${t}” will be permanently removed. This cannot be undone.`,
postNotFound: 'Work not found in the catalogue',
returnHome: 'Return to the catalogue',
notFoundTitle: 'Not in the catalogue',
notFoundDesc: "The work you're looking for is not on view.",
notFoundRule: 'Pardon — the gallery has misplaced this work',
notFoundHead: 'This piece is not on view.',
notFoundBody:
'The room you reached for has either been re-hung, withdrawn,|or never made it to the wall in the first place.',
editorTitlePh: 'Untitled (charcoal on paper)',
editorSlugPh: 'untitled-charcoal-on-paper',
editorDraftLabel: 'Sketch (draft)',
editorSummaryPh: 'A short caption for the catalogue index...',
editorSummaryLabel: 'Caption (optional)',
editorTagsPh: 'oil, paper, 2026, study',
},
blog: {
indexTitle: 'Posts',
backHome: 'Back to posts',
adminBack: 'Back to posts',
adminEyebrow: 'Dashboard',
footerEnd: 'end of posts',
loadingMore: 'loading more…',
draftShort: 'Draft',
draftLong: 'Draft · unpublished',
searchPlaceholder: 'Search posts…',
searchAria: 'Search posts',
searchFetching: 'Loading posts…',
searchEmpty: 'No posts yet.',
searchNoMatch: 'No posts match.',
deleteListTitle: 'Delete this post?',
deleteListMsg: (t: string) => `${t}” will be permanently deleted. This cannot be undone.`,
deleteListConfirm: 'Delete',
deleteListCancel: 'Cancel',
deletePostTitle: 'Delete this post?',
deletePostMsg: (t: string) => `${t}” will be permanently deleted. This cannot be undone.`,
postNotFound: 'Post not found',
returnHome: 'Return to posts',
notFoundTitle: 'Post not found',
notFoundDesc: "The post you're looking for doesn't exist.",
notFoundRule: 'This page could not be found',
notFoundHead: 'Nothing here.',
notFoundBody:
'The post you reached for has either moved, been unpublished,|or never existed in the first place.',
editorTitlePh: 'Post title',
editorSlugPh: 'post-slug',
editorDraftLabel: 'Draft',
editorSummaryPh: 'A short summary for the index...',
editorSummaryLabel: 'Summary (optional)',
editorTagsPh: 'essay, notes, 2026',
},
} as const satisfies Record<SiteMode, Record<string, unknown>>;
export function copy(mode: SiteMode) {
return COPY[mode];
}
+10 -6
View File
@@ -1,24 +1,28 @@
---
import Layout from '../layouts/Layout.astro';
import { getSiteMode, copy } from '../lib/siteMode';
const c = copy(getSiteMode());
const [bodyA, bodyB] = c.notFoundBody.split('|');
---
<Layout title="Not in the catalogue" description="The work you're looking for is not on view.">
<Layout title={c.notFoundTitle} description={c.notFoundDesc}>
<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>{c.notFoundRule}</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.
{c.notFoundHead}
</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.
{bodyA}<br class="hidden md:block" />
{bodyB}
</p>
<a href="/" class="btn btn--primary">↶ Return to the catalogue</a>
<a href="/" class="btn btn--primary">↶ {c.returnHome}</a>
</div>
</Layout>
+3 -1
View File
@@ -2,11 +2,13 @@
import 'katex/dist/katex.min.css';
import AdminLayout from '../../layouts/AdminLayout.astro';
import Editor from '../../components/react/admin/Editor';
import { getSiteMode } from '../../lib/siteMode';
const editSlug = Astro.url.searchParams.get('edit') || undefined;
const siteMode = getSiteMode();
---
<AdminLayout title="Write Post" wide>
<p slot="header-subtitle" class="mt-2 text-sm md:text-base" style="color: var(--text) !important;">Create/Edit post.</p>
<Editor client:only="react" editSlug={editSlug} />
<Editor client:only="react" editSlug={editSlug} mode={siteMode} />
</AdminLayout>
+4 -1
View File
@@ -1,8 +1,11 @@
---
import Layout from '../../layouts/Layout.astro';
import Login from '../../components/react/admin/Login';
import { getSiteMode } from '../../lib/siteMode';
const siteMode = getSiteMode();
---
<Layout title="Admin Login" description="Sign in to the back room." minimal>
<Login client:only="react" />
<Login client:only="react" mode={siteMode} />
</Layout>
+6 -2
View File
@@ -3,6 +3,10 @@ import Layout from '../layouts/Layout.astro';
import PostList from '../components/react/PostList';
import EditableText from '../components/react/EditableText';
import AssetsButton from '../components/react/AssetsButton';
import { getSiteMode, copy } from '../lib/siteMode';
const siteMode = getSiteMode();
const c = copy(siteMode);
const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
@@ -60,7 +64,7 @@ try {
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
---
<Layout title="Catalogue" description={siteConfig.welcome_subtitle}>
<Layout title={c.indexTitle} description={siteConfig.welcome_subtitle}>
{posts[0]?.cover_image?.url && (
<Fragment slot="head">
<link rel="preload" as="image" href={posts[0].cover_image.url} fetchpriority="high" />
@@ -133,5 +137,5 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
</div>
)}
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:idle />}
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} mode={siteMode} client:idle />}
</Layout>
+30 -6
View File
@@ -3,6 +3,11 @@ import 'katex/dist/katex.min.css';
import Layout from '../../layouts/Layout.astro';
import DeletePostButton from '../../components/react/DeletePostButton';
import { renderMarkdown } from '../../lib/markdown';
import { getSiteMode, copy } from '../../lib/siteMode';
const siteMode = getSiteMode();
const isBlog = siteMode === 'blog';
const c = copy(siteMode);
const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
@@ -50,7 +55,7 @@ try {
post = await postRes.json();
html = renderMarkdown(post!.content, post!.dimensions);
} else {
error = 'Work not found in the catalogue';
error = c.postNotFound;
}
} catch (e) {
const cause = (e as any)?.cause;
@@ -84,7 +89,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<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 btn--ghost">← Return to the catalogue</a>
<a href="/" class="btn btn--ghost">← {c.returnHome}</a>
</div>
)}
@@ -94,7 +99,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<div class="flex flex-wrap items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]">
<a href="/" class="back-link">
<span class="bl-arrow" aria-hidden="true">←</span>
Back to catalogue
{c.backHome}
</a>
{isAdmin && (
@@ -103,11 +108,25 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<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 />
<DeletePostButton slug={post.slug} title={displayTitle} mode={siteMode} client:idle />
</div>
)}
</div>
{/* Blog mode: cover rides above the plaque as a lead image. In atelier
the cover is the index plate's job, not the post page's. */}
{isBlog && post.cover_image?.url && (
<div class="post-lead">
<img
src={post.cover_image.url}
alt={post.cover_image.alt || displayTitle}
width={post.cover_image.w}
height={post.cover_image.h}
fetchpriority="high"
/>
</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-[1.08] tracking-tight mb-6">
@@ -117,7 +136,12 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<div class="section-rule max-w-md mx-auto mb-6">
<span class="ornament">✦</span>
<span>{formatDate(post.date)}</span>
{post.image_count > 0 && (
{isBlog ? (
<>
<span class="ornament">·</span>
<span>{post.reading_time} min read</span>
</>
) : post.image_count > 0 && (
<>
<span class="ornament">·</span>
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
@@ -135,7 +159,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
{post.draft && (
<div class="mt-6 inline-block">
<span class="chip chip-draft">
Sketch · unpublished
{c.draftLong}
</span>
</div>
)}
+1
View File
@@ -13,6 +13,7 @@
@import "./partials/20-atmosphere.css";
@import "./partials/30-prose.css";
@import "./partials/40-components.css";
@import "./partials/45-blog.css";
@import "./partials/50-controls.css";
@import "./partials/60-breakcore.css";
@import "./partials/70-cybersigil.css";
+208
View File
@@ -0,0 +1,208 @@
/*
* BLOG MODE — writing-first stacked rows.
*
* Same skin as the gallery (display font, palette, theme variants, paper
* grain, CyberFx) — only the *focus* flips: the post's words lead, the cover
* shrinks to a side thumbnail. Everything here is scoped under
* `html.mode-blog`; atelier (the default) never sees these rules, and the
* justified-gallery markup simply isn't emitted in blog mode.
*/
html.mode-blog .post-list {
width: 100%;
max-width: 52rem;
}
/* One post = one row, separated by a hairline like a printed contents page. */
html.mode-blog .post-row {
position: relative;
padding: 1.9rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--surface2) 55%, transparent);
}
html.mode-blog .post-row:first-child {
padding-top: 0;
}
html.mode-blog .post-row:last-of-type {
border-bottom: none;
}
html.breakcore.mode-blog .post-row {
border-bottom-color: color-mix(in srgb, var(--mauve) 30%, transparent);
}
html.mode-blog .post-row-link {
display: flex;
align-items: flex-start;
gap: 1.75rem;
outline: none;
}
html.mode-blog .post-row-body {
flex: 1 1 auto;
min-width: 0;
}
html.mode-blog .post-row-title {
font-family: var(--font-display);
font-style: italic;
font-weight: 600;
font-size: clamp(1.55rem, 1.1rem + 1.8vw, 2.4rem);
line-height: 1.12;
letter-spacing: -0.012em;
color: var(--text);
/* clip-box padding so italic Fraunces descenders survive (same trick as
* .plate-caption-title) */
padding-bottom: 0.08em;
margin-bottom: -0.08em;
transition: color 0.3s ease;
}
html.mode-blog .post-row-link:hover .post-row-title,
html.mode-blog .post-row-link:focus-visible .post-row-title {
color: var(--mauve);
}
html.mode-blog .post-row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.7rem;
font-family: var(--font-sans);
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--subtext0);
}
html.mode-blog .post-row-meta .sep {
color: var(--overlay0);
}
html.mode-blog .post-row-excerpt {
margin-top: 0.85rem;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.65;
color: var(--subtext1);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
html.mode-blog .post-row-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 1rem;
}
/* Side thumbnail — framed like a small plate so the salon material carries
* over. position:relative + overflow:hidden so the cybersigil hover sigil
* (.cs-plate-sig, inset:0) pins to the image box, never the row. */
html.mode-blog .post-row-thumb {
position: relative;
flex: 0 0 auto;
width: clamp(96px, 22vw, 184px);
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--mantle);
border: 1px solid var(--surface2);
border-radius: 2px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 14px 30px -22px rgba(20, 16, 12, 0.5);
transition: box-shadow 0.4s cubic-bezier(0.2, 0.6, 0.2, 1);
}
html.salon-noir.mode-blog .post-row-thumb,
html.gothic.mode-blog .post-row-thumb {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 14px 30px -22px rgba(0, 0, 0, 0.75);
}
html.breakcore.mode-blog .post-row-thumb {
border-color: color-mix(in srgb, var(--mauve) 40%, var(--surface2));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mauve) 28%, transparent),
0 0 22px -10px color-mix(in srgb, var(--mauve) 45%, transparent);
}
html.mode-blog .post-row-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(0.94) contrast(1.02);
transition: transform 0.7s cubic-bezier(0.2, 0.6, 0.2, 1), filter 0.4s ease;
}
html.mode-blog .post-row-link:hover .post-row-thumb img,
html.mode-blog .post-row-link:focus-visible .post-row-thumb img {
transform: scale(1.04);
filter: saturate(1.05) contrast(1.04);
}
html.mode-blog .post-row-link:hover .post-row-thumb,
html.mode-blog .post-row-link:focus-visible .post-row-thumb {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 62%, transparent),
0 20px 38px -22px rgba(20, 16, 12, 0.55);
}
html.breakcore.mode-blog .post-row-link:hover .post-row-thumb,
html.breakcore.mode-blog .post-row-link:focus-visible .post-row-thumb {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mauve) 42%, transparent),
0 0 30px -8px color-mix(in srgb, var(--mauve) 55%, transparent);
}
/* Keyboard focus — inset salon ring on the whole row link. */
html.mode-blog .post-row-link:focus-visible {
box-shadow:
inset 0 0 0 2px var(--mauve),
0 0 0 3px color-mix(in srgb, var(--mauve) 35%, transparent);
border-radius: 2px;
}
html.mode-blog .post-row-draft {
font-family: var(--font-display);
font-style: italic;
letter-spacing: 0.04em;
}
/* Tighter stack on phones; thumbnail drops below the text. */
@media (max-width: 600px) {
html.mode-blog .post-row-link {
flex-direction: column-reverse;
gap: 1rem;
}
html.mode-blog .post-row-thumb {
width: 100%;
aspect-ratio: 16 / 9;
}
}
/* ── Post page (blog mode): cover as a lead image above the plaque ── */
html.mode-blog .post-lead {
position: relative;
max-width: 48rem;
margin: 0 auto 2.5rem;
overflow: hidden;
background: var(--mantle);
border: 1px solid var(--surface2);
border-radius: 2px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 26px 50px -30px rgba(20, 16, 12, 0.55);
}
html.salon-noir.mode-blog .post-lead,
html.gothic.mode-blog .post-lead {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 26px 50px -30px rgba(0, 0, 0, 0.8);
}
html.breakcore.mode-blog .post-lead {
border-color: color-mix(in srgb, var(--mauve) 40%, var(--surface2));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mauve) 28%, transparent),
0 0 34px -12px color-mix(in srgb, var(--mauve) 45%, transparent);
}
html.mode-blog .post-lead img {
display: block;
width: 100%;
height: auto;
filter: saturate(0.95) contrast(1.02);
}