added site modes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user