From 86f855493b78579d1c640a16f5ee1c4d68698f25 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 18 May 2026 13:51:33 +0200 Subject: [PATCH] added site modes --- .env.example | 7 + docker-compose.yml | 1 + .../src/components/react/DeletePostButton.tsx | 9 +- frontend/src/components/react/PostList.tsx | 128 ++++++++++- frontend/src/components/react/Search.tsx | 16 +- .../src/components/react/admin/Editor.tsx | 19 +- frontend/src/components/react/admin/Login.tsx | 6 +- .../react/admin/editor/usePostMeta.ts | 11 +- frontend/src/layouts/AdminLayout.astro | 6 +- frontend/src/layouts/Layout.astro | 10 +- frontend/src/lib/siteMode.ts | 99 +++++++++ frontend/src/pages/404.astro | 16 +- frontend/src/pages/admin/editor.astro | 4 +- frontend/src/pages/admin/login.astro | 5 +- frontend/src/pages/index.astro | 8 +- frontend/src/pages/posts/[slug].astro | 36 ++- frontend/src/styles/global.css | 1 + frontend/src/styles/partials/45-blog.css | 208 ++++++++++++++++++ 18 files changed, 538 insertions(+), 52 deletions(-) create mode 100644 frontend/src/lib/siteMode.ts create mode 100644 frontend/src/styles/partials/45-blog.css diff --git a/.env.example b/.env.example index b38199c..df9df68 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d790233..0604d21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/components/react/DeletePostButton.tsx b/frontend/src/components/react/DeletePostButton.tsx index 17a0aa7..df21b1b 100644 --- a/frontend/src/components/react/DeletePostButton.tsx +++ b/frontend/src/components/react/DeletePostButton.tsx @@ -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', }); diff --git a/frontend/src/components/react/PostList.tsx b/frontend/src/components/react/PostList.tsx index 6de58f8..a639a0d 100644 --- a/frontend/src/components/react/PostList.tsx +++ b/frontend/src/components/react/PostList.tsx @@ -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(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 ( +
+ +
+

{displayTitle}

+
+ {formatLongDate(post.date)} + + {post.reading_time} min read + {post.draft && ( + {c.draftShort} + )} +
+ {blurb &&

{blurb}

} + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.slice(0, 4).map(tag => ( + {tag} + ))} +
+ )} +
+ + {hasCover && ( +
+ {post.cover_image!.alt + +
+ )} +
+ + {isAdmin && ( +
+ e.stopPropagation()} + title="Edit" + aria-label={`Edit ${displayTitle}`} + className="btn btn--ghost btn--icon btn--sm" + > + + + +
+ )} +
+ ); + }; + + if (isBlog) { + return ( + <> +
+ {shown.map((post, idx) => renderRow(post, idx))} +
+ {hasMore && ( + + )} + + ); + } + return ( <>
@@ -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" > - arranging more… + {c.loadingMore}
)} diff --git a/frontend/src/components/react/Search.tsx b/frontend/src/components/react/Search.tsx index fabdf0b..ece8111 100644 --- a/frontend/src/components/react/Search.tsx +++ b/frontend/src/components/react/Search.tsx @@ -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(null); @@ -128,7 +130,7 @@ export default function Search() {