Compare commits
11 Commits
3530055f59
..
ela
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bd27dd7ef | |||
| 2cfd3ff779 | |||
| 86f855493b | |||
| c3aa52ddfd | |||
| 5985f172a1 | |||
| a1e3c2329e | |||
| b38b86e5ab | |||
| 2651b29d02 | |||
| b64cd2e85a | |||
| 04733eb00a | |||
| dc8e3d55b1 |
@@ -18,3 +18,10 @@ FRONTEND_ORIGIN=
|
|||||||
# Frontend Configuration
|
# Frontend Configuration
|
||||||
# URL of the backend API accessible from the frontend container.
|
# URL of the backend API accessible from the frontend container.
|
||||||
PUBLIC_API_URL=http://backend:3000
|
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
|
||||||
|
|||||||
+2
-1
@@ -41,7 +41,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4322:4321"
|
- "4322:4321"
|
||||||
environment:
|
environment:
|
||||||
- PUBLIC_API_URL=http://backend:3000
|
- PUBLIC_API_URL=${PUBLIC_API_URL:-}
|
||||||
|
- SITE_MODE=${SITE_MODE:-atelier}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -23,9 +23,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { buildCybersigil } from '../lib/cybersigil';
|
import { buildCybersigil } from '../lib/cybersigil';
|
||||||
|
|
||||||
|
let teardown: (() => void) | null = null;
|
||||||
|
|
||||||
function initCyberFx() {
|
function initCyberFx() {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (!root.classList.contains('cybersigil')) return;
|
if (!root.classList.contains('cybersigil')) return;
|
||||||
|
const fx = document.querySelector<HTMLElement>('.cs-fx');
|
||||||
|
if (!fx) return;
|
||||||
|
|
||||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
@@ -42,10 +46,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── One slow-spinning sigil filling the background ─── */
|
/* ─── One sigil filling the background. count:6 (was 9) — fewer branch
|
||||||
|
* nodes ⇒ far fewer perpetually-animating strokes, same silhouette. ─── */
|
||||||
const wire = document.querySelector<HTMLElement>('.cs-fx-wire');
|
const wire = document.querySelector<HTMLElement>('.cs-fx-wire');
|
||||||
if (wire && !wire.classList.contains('cs-fx-wire--sig')) {
|
if (wire && !wire.classList.contains('cs-fx-wire--sig')) {
|
||||||
wire.innerHTML = buildCybersigil({ count: 9 });
|
wire.innerHTML = buildCybersigil({ count: 6 });
|
||||||
wire.classList.add('cs-fx-wire--sig');
|
wire.classList.add('cs-fx-wire--sig');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +77,44 @@
|
|||||||
targets.forEach((t) => io.observe(t));
|
targets.forEach((t) => io.observe(t));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Ambient: entrance fade-in (opacity:0 → target via the CSS
|
||||||
|
* transition on first apply) + scroll-depth recede. Parallax is
|
||||||
|
* disabled for now — the --cs-px/py/cx/cy vars default to 0px so the
|
||||||
|
* wire/corner transforms stay put; re-enable by driving those vars
|
||||||
|
* from scroll/pointer here again. ─── */
|
||||||
|
if (teardown) teardown();
|
||||||
|
const off: Array<() => void> = [];
|
||||||
|
let depth = 0, raf = 0;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
raf = 0;
|
||||||
|
fx.style.opacity = String(1 - 0.5 * depth);
|
||||||
|
};
|
||||||
|
const schedule = () => { if (!raf) raf = requestAnimationFrame(apply); };
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const vh = window.innerHeight || 1;
|
||||||
|
depth = Math.max(0, Math.min(1, window.scrollY / vh));
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
off.push(() => window.removeEventListener('scroll', onScroll));
|
||||||
|
off.push(() => window.removeEventListener('resize', onScroll));
|
||||||
|
onScroll();
|
||||||
|
|
||||||
|
/* Freeze every loop while the tab is hidden — idle-battery win. */
|
||||||
|
const onVis = () => fx.classList.toggle('is-paused', document.hidden);
|
||||||
|
document.addEventListener('visibilitychange', onVis);
|
||||||
|
off.push(() => document.removeEventListener('visibilitychange', onVis));
|
||||||
|
onVis();
|
||||||
|
|
||||||
|
teardown = () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
off.forEach((fn) => fn());
|
||||||
|
teardown = null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
initCyberFx();
|
initCyberFx();
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { deletePost } from '../../lib/api';
|
import { deletePost } from '../../lib/api';
|
||||||
import { confirmDialog, notify } from '../../lib/confirm';
|
import { confirmDialog, notify } from '../../lib/confirm';
|
||||||
|
import { type SiteMode, copy } from '../../lib/siteMode';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
variant?: 'icon' | 'full';
|
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 [busy, setBusy] = useState(false);
|
||||||
|
const c = copy(mode);
|
||||||
|
|
||||||
async function handleClick() {
|
async function handleClick() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: 'Delete this work?',
|
title: c.deletePostTitle,
|
||||||
message: `“${title}” will be permanently removed. This cannot be undone.`,
|
message: c.deletePostMsg(title),
|
||||||
confirmLabel: 'Delete',
|
confirmLabel: 'Delete',
|
||||||
cancelLabel: 'Cancel',
|
cancelLabel: 'Cancel',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// useLayoutEffect warns when React renders this island on the server; the
|
||||||
|
// measurement only matters on the client anyway.
|
||||||
|
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
import { deletePost } from '../../lib/api';
|
import { deletePost } from '../../lib/api';
|
||||||
import { confirmDialog, notify } from '../../lib/confirm';
|
import { confirmDialog, notify } from '../../lib/confirm';
|
||||||
import { buildCybersigil } from '../../lib/cybersigil';
|
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
|
// 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
|
// markup never differs between SSR and hydration. Inert/display:none off the
|
||||||
// cybersigil theme; carves in on plate hover/focus via global.css.
|
// cybersigil theme; carves over the image on plate hover/focus via global.css.
|
||||||
function PlateSigil() {
|
function PlateSigil() {
|
||||||
const [html, setHtml] = useState('');
|
const [html, setHtml] = useState('');
|
||||||
useEffect(() => { setHtml(buildCybersigil()); }, []);
|
useEffect(() => { setHtml(buildCybersigil()); }, []);
|
||||||
@@ -44,6 +49,7 @@ interface Post {
|
|||||||
interface Props {
|
interface Props {
|
||||||
posts: Post[];
|
posts: Post[];
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
mode?: SiteMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSlug(slug: string) {
|
function formatSlug(slug: string) {
|
||||||
@@ -62,33 +68,108 @@ function formatMonth(date: string) {
|
|||||||
return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
|
return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic salon-hang layout. Each tile gets a col-span (out of 12) and an aspect ratio.
|
// Blog rows want a human date, not the gallery's terse MONTH / YEAR split.
|
||||||
// The cycle is chosen so the room reads asymmetric but balanced.
|
function formatLongDate(date: string) {
|
||||||
const LAYOUT_CYCLE: Array<{ col: number; aspect: string; tilt: number }> = [
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
{ col: 7, aspect: '4 / 3', tilt: -0.4 },
|
day: 'numeric',
|
||||||
{ col: 5, aspect: '3 / 4', tilt: 0.3 },
|
month: 'long',
|
||||||
{ col: 4, aspect: '4 / 5', tilt: -0.2 },
|
year: 'numeric',
|
||||||
{ col: 4, aspect: '1 / 1', tilt: 0.5 },
|
});
|
||||||
{ col: 4, aspect: '4 / 5', tilt: -0.6 },
|
}
|
||||||
{ col: 5, aspect: '1 / 1', tilt: 0.2 },
|
|
||||||
{ col: 7, aspect: '5 / 4', tilt: -0.3 },
|
|
||||||
{ col: 8, aspect: '16 / 10', tilt: 0.4 },
|
|
||||||
{ col: 4, aspect: '3 / 4', tilt: -0.5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PostList({ posts: initialPosts, isAdmin = false }: Props) {
|
// ── 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
|
||||||
|
// finalized and its height solved so the row fills the width exactly. The
|
||||||
|
// trailing partial row stays at the target height (left-aligned), only shrunk
|
||||||
|
// if a lone wide image would overflow. Result: variable widths, one shared
|
||||||
|
// image height per visual row, no wasted space.
|
||||||
|
|
||||||
|
const GAP = 18; // horizontal gap between tiles in a row (px)
|
||||||
|
const ROW_GAP = 40; // vertical gap between rows (px)
|
||||||
|
const MIN_H = 150; // floor so a wide-only row never collapses
|
||||||
|
// Covers with no usable dimensions (or no cover at all) cycle through a set of
|
||||||
|
// pleasant ratios so the placeholder tiles still read as an arranged hang.
|
||||||
|
const FALLBACK_RATIOS = [1.5, 0.78, 1, 1.33, 0.8, 1.6, 0.9];
|
||||||
|
|
||||||
|
function aspectOf(post: Post, idx: number): number {
|
||||||
|
const ci = post.cover_image;
|
||||||
|
if (ci && ci.w && ci.h && ci.w > 0 && ci.h > 0) {
|
||||||
|
const r = ci.w / ci.h;
|
||||||
|
if (Number.isFinite(r) && r > 0) return r;
|
||||||
|
}
|
||||||
|
return FALLBACK_RATIOS[idx % FALLBACK_RATIOS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetRowHeight(cw: number): number {
|
||||||
|
if (cw < 560) return Math.round(cw * 0.72);
|
||||||
|
if (cw < 900) return 260;
|
||||||
|
if (cw < 1280) return 300;
|
||||||
|
return 340;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cell { post: Post; idx: number; aspect: number; w: number; h: number }
|
||||||
|
|
||||||
|
function buildRows(
|
||||||
|
tiles: Array<{ post: Post; idx: number; aspect: number }>,
|
||||||
|
cw: number,
|
||||||
|
targetH: number,
|
||||||
|
chromeX: number,
|
||||||
|
): Cell[][] {
|
||||||
|
const rows: Cell[][] = [];
|
||||||
|
let cur: typeof tiles = [];
|
||||||
|
let aspSum = 0;
|
||||||
|
|
||||||
|
const finalize = (items: typeof tiles, h: number) => {
|
||||||
|
const rh = Math.round(Math.max(MIN_H, h));
|
||||||
|
rows.push(
|
||||||
|
items.map(t => ({ ...t, w: Math.round(t.aspect * rh), h: rh })),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const t of tiles) {
|
||||||
|
cur = [...cur, t];
|
||||||
|
aspSum += t.aspect;
|
||||||
|
const k = cur.length;
|
||||||
|
const projected = targetH * aspSum + k * chromeX + (k - 1) * GAP;
|
||||||
|
if (projected >= cw) {
|
||||||
|
const avail = cw - k * chromeX - (k - 1) * GAP;
|
||||||
|
const h = Math.min(avail / aspSum, targetH * 1.5);
|
||||||
|
finalize(cur, h);
|
||||||
|
cur = [];
|
||||||
|
aspSum = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.length) {
|
||||||
|
const k = cur.length;
|
||||||
|
const projected = targetH * aspSum + k * chromeX + (k - 1) * GAP;
|
||||||
|
let h = targetH;
|
||||||
|
if (projected > cw) h = (cw - k * chromeX - (k - 1) * GAP) / aspSum;
|
||||||
|
finalize(cur, Math.min(h, targetH * 1.5));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [posts, setPosts] = useState(initialPosts);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length));
|
const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length));
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// null until measured on the client — keeps SSR and first hydration render
|
||||||
|
// identical (CSS-only fallback), then the precise layout swaps in.
|
||||||
|
const [rows, setRows] = useState<Cell[][] | null>(null);
|
||||||
|
const [chrome, setChrome] = useState(30); // plate mat+border width (px)
|
||||||
|
|
||||||
async function handleDelete(slug: string, title: string) {
|
async function handleDelete(slug: string, title: string) {
|
||||||
if (deleting) return;
|
if (deleting) return;
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: 'Take this off the wall?',
|
title: c.deleteListTitle,
|
||||||
message: `“${title}” will be removed from the catalogue. This cannot be undone.`,
|
message: c.deleteListMsg(title),
|
||||||
confirmLabel: 'Remove',
|
confirmLabel: c.deleteListConfirm,
|
||||||
cancelLabel: 'Keep',
|
cancelLabel: c.deleteListCancel,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setDeleting(slug);
|
setDeleting(slug);
|
||||||
@@ -96,7 +177,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
|
|||||||
await deletePost(slug);
|
await deletePost(slug);
|
||||||
setPosts(p => p.filter(x => x.slug !== slug));
|
setPosts(p => p.filter(x => x.slug !== slug));
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
@@ -122,41 +203,86 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [visible, posts.length]);
|
}, [visible, posts.length]);
|
||||||
|
|
||||||
if (posts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shown = posts.slice(0, visible);
|
const shown = posts.slice(0, visible);
|
||||||
const hasMore = visible < posts.length;
|
const hasMore = visible < posts.length;
|
||||||
|
|
||||||
return (
|
// Measure the container + plate chrome and (re)compute the justified rows.
|
||||||
<>
|
// Re-runs on resize and whenever the shown set changes (infinite scroll).
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16">
|
useIsoLayoutEffect(() => {
|
||||||
{shown.map((post, idx) => {
|
if (isBlog) return; // blog mode renders a plain stack — no justified math
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const measure = () => {
|
||||||
|
const cw = container.clientWidth;
|
||||||
|
if (cw <= 0) return;
|
||||||
|
// Mat + border are fixed px regardless of tile size — read once from a
|
||||||
|
// live plate so the row-fill math is exact for whatever theme is on.
|
||||||
|
let chromeX = 30;
|
||||||
|
const plate = container.querySelector<HTMLElement>('.plate');
|
||||||
|
if (plate) {
|
||||||
|
const cs = getComputedStyle(plate);
|
||||||
|
chromeX =
|
||||||
|
parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) +
|
||||||
|
parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
|
||||||
|
if (!Number.isFinite(chromeX)) chromeX = 30;
|
||||||
|
}
|
||||||
|
setChrome(chromeX);
|
||||||
|
const tiles = shown.map((post, idx) => ({ post, idx, aspect: aspectOf(post, idx) }));
|
||||||
|
setRows(buildRows(tiles, cw, targetRowHeight(cw), chromeX));
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(measure);
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(schedule);
|
||||||
|
ro.observe(container);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible, posts]);
|
||||||
|
|
||||||
|
if (posts.length === 0) return null;
|
||||||
|
|
||||||
|
let cellIdx = 0; // running stagger index across all rows
|
||||||
|
|
||||||
|
const renderCell = (cell: Cell | null, post: Post, idx: number) => {
|
||||||
const displayTitle = post.title || formatSlug(post.slug);
|
const displayTitle = post.title || formatSlug(post.slug);
|
||||||
const isDeleting = deleting === post.slug;
|
const isDeleting = deleting === post.slug;
|
||||||
const layout = LAYOUT_CYCLE[idx % LAYOUT_CYCLE.length];
|
|
||||||
const hasCover = !!post.cover_image?.url;
|
const hasCover = !!post.cover_image?.url;
|
||||||
|
const stagger = Math.min(cellIdx++ * 70, 480);
|
||||||
|
|
||||||
|
// Precise layout: tile sized to outer (image + mat) width with flex-grow so
|
||||||
|
// sub-pixel rounding redistributes and the row fills the width exactly —
|
||||||
|
// no crop, no wrap. Fallback (pre-measure / no JS): grow proportional to
|
||||||
|
// aspect so it still reads as a justified hang.
|
||||||
|
const aspect = cell ? cell.aspect : aspectOf(post, idx);
|
||||||
|
const ow = cell ? cell.w + chrome : 0;
|
||||||
|
const articleStyle: React.CSSProperties = cell
|
||||||
|
? { flex: `${ow} ${ow} ${ow}px`, minWidth: 0, animationDelay: `${stagger}ms` }
|
||||||
|
: { flex: `${aspect.toFixed(4)} 1 ${Math.round(aspect * 220)}px`, animationDelay: `${stagger}ms` };
|
||||||
|
const imageStyle: React.CSSProperties = cell
|
||||||
|
? { height: `${cell.h}px` }
|
||||||
|
: { aspectRatio: `${aspect}` };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
className={`relative plate-enter md-col-span ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
|
className={`relative plate-enter ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
|
||||||
style={{
|
style={articleStyle}
|
||||||
animationDelay: `${Math.min(idx * 80, 480)}ms`,
|
|
||||||
['--col-span' as any]: layout.col,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`/posts/${encodeURIComponent(post.slug)}`}
|
href={`/posts/${encodeURIComponent(post.slug)}`}
|
||||||
className="block plate group"
|
className="block plate group"
|
||||||
style={{ transform: `rotate(${layout.tilt}deg)` }}
|
|
||||||
aria-label={`View ${displayTitle}`}
|
aria-label={`View ${displayTitle}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className="plate-image" style={imageStyle}>
|
||||||
className={`plate-image ${hasCover ? 'is-natural' : ''}`}
|
|
||||||
style={hasCover ? undefined : { aspectRatio: layout.aspect }}
|
|
||||||
>
|
|
||||||
{hasCover ? (
|
{hasCover ? (
|
||||||
<img
|
<img
|
||||||
src={post.cover_image!.url}
|
src={post.cover_image!.url}
|
||||||
@@ -170,25 +296,18 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-full h-full flex items-center justify-center text-[var(--rosewater)]"
|
className="w-full h-full flex items-center justify-center text-[var(--rosewater)]"
|
||||||
style={{
|
style={{ background: `linear-gradient(135deg, var(--mauve), var(--mantle))` }}
|
||||||
background: `linear-gradient(135deg, var(--mauve), var(--mantle))`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="font-display italic text-3xl opacity-70">
|
<span className="font-display italic text-3xl opacity-70">untitled</span>
|
||||||
untitled
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{post.image_count > 1 && (
|
{post.image_count > 1 && (
|
||||||
<span className="plate-tag-mini">
|
<span className="plate-tag-mini">{post.image_count} plates</span>
|
||||||
{post.image_count} plates
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{post.draft && (
|
{post.draft && (
|
||||||
<span className="plate-tag-mini plate-tag-mini--draft">
|
<span className="plate-tag-mini plate-tag-mini--draft">Sketch</span>
|
||||||
Sketch
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
<PlateSigil />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="plate-caption">
|
<div className="plate-caption">
|
||||||
@@ -207,9 +326,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
|
|||||||
{post.tags && post.tags.length > 0 && (
|
{post.tags && post.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
|
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
|
||||||
{post.tags.slice(0, 4).map(tag => (
|
{post.tags.slice(0, 4).map(tag => (
|
||||||
<span key={tag} className="chip">
|
<span key={tag} className="chip">{tag}</span>
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -237,11 +354,96 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlateSigil />
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
|
|
||||||
|
// ── 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>
|
</div>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div
|
<div
|
||||||
@@ -249,7 +451,39 @@ 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"
|
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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span className="opacity-60">arranging more…</span>
|
<span className="opacity-60">{c.loadingMore}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={containerRef} className="just-gallery">
|
||||||
|
{rows
|
||||||
|
? rows.map((row, r) => (
|
||||||
|
<div
|
||||||
|
className="just-row"
|
||||||
|
key={r}
|
||||||
|
style={{ marginBottom: r === rows.length - 1 ? 0 : ROW_GAP }}
|
||||||
|
>
|
||||||
|
{row.map(cell => renderCell(cell, cell.post, cell.idx))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<div className="just-row just-row--fallback">
|
||||||
|
{shown.map((post, idx) => renderCell(null, post, idx))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getPosts } from '../../lib/api';
|
import { getPosts } from '../../lib/api';
|
||||||
|
import { type SiteMode, copy } from '../../lib/siteMode';
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
slug: string;
|
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 [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [posts, setPosts] = useState<Post[] | null>(null);
|
const [posts, setPosts] = useState<Post[] | null>(null);
|
||||||
@@ -128,7 +130,7 @@ export default function Search() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
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"
|
className="topbar-control tc-collapse-md kbd-tip-host"
|
||||||
>
|
>
|
||||||
<svg
|
<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"
|
className="search-overlay fixed inset-0 z-[200] flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Search the catalogue"
|
aria-label={c.searchAria}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -194,7 +196,7 @@ export default function Search() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onKeyDown={onInputKey}
|
onKeyDown={onInputKey}
|
||||||
placeholder="Search the catalogue…"
|
placeholder={`${c.searchPlaceholder}`}
|
||||||
aria-label="Search query"
|
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"
|
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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && (
|
{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 && (
|
{error && (
|
||||||
<div className="px-4 py-8 text-center text-sm text-[var(--red)] font-display italic">{error}</div>
|
<div className="px-4 py-8 text-center text-sm text-[var(--red)] font-display italic">{error}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && posts && results.length === 0 && (
|
{!loading && !error && posts && results.length === 0 && (
|
||||||
<div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && results.length > 0 && (
|
{!loading && !error && results.length > 0 && (
|
||||||
@@ -236,7 +238,7 @@ export default function Search() {
|
|||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
{p.draft && (
|
{p.draft && (
|
||||||
<span className="chip chip-draft shrink-0 !text-[0.62rem] !py-0">
|
<span className="chip chip-draft shrink-0 !text-[0.62rem] !py-0">
|
||||||
Sketch
|
{c.draftShort}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ import { useLivePreview } from './editor/useLivePreview';
|
|||||||
import { useImageUpload } from './editor/useImageUpload';
|
import { useImageUpload } from './editor/useImageUpload';
|
||||||
import { useAssetAutocomplete } from './editor/useAssetAutocomplete';
|
import { useAssetAutocomplete } from './editor/useAssetAutocomplete';
|
||||||
import { usePostMeta } from './editor/usePostMeta';
|
import { usePostMeta } from './editor/usePostMeta';
|
||||||
|
import { type SiteMode, copy } from '../../../lib/siteMode';
|
||||||
|
|
||||||
const AssetManager = lazy(() => import('./AssetManager'));
|
const AssetManager = lazy(() => import('./AssetManager'));
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editSlug?: string;
|
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 editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
const [vimEnabled, setVimEnabled] = useState(false);
|
const [vimEnabled, setVimEnabled] = useState(false);
|
||||||
@@ -41,7 +44,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
editorRef,
|
editorRef,
|
||||||
getCachedAssets: assetCache.getCachedAssets,
|
getCachedAssets: assetCache.getCachedAssets,
|
||||||
});
|
});
|
||||||
const meta = usePostMeta({ editSlug, getContent, setContent });
|
const meta = usePostMeta({ editSlug, getContent, setContent, mode });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title, setTitle, slug, setSlug, setSlugTouched, date, setDate,
|
title, setTitle, slug, setSlug, setSlugTouched, date, setDate,
|
||||||
@@ -215,7 +218,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="Untitled (charcoal on paper)"
|
placeholder={c.editorTitlePh}
|
||||||
className="field-input"
|
className="field-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +243,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
value={slug}
|
value={slug}
|
||||||
onChange={e => { setSlug(e.target.value); setSlugTouched(true); }}
|
onChange={e => { setSlug(e.target.value); setSlugTouched(true); }}
|
||||||
required
|
required
|
||||||
placeholder="untitled-charcoal-on-paper"
|
placeholder={c.editorSlugPh}
|
||||||
className="field-input font-mono"
|
className="field-input font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +256,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={tagsInput}
|
value={tagsInput}
|
||||||
onChange={e => setTagsInput(e.target.value)}
|
onChange={e => setTagsInput(e.target.value)}
|
||||||
placeholder="oil, paper, 2026, study"
|
placeholder={c.editorTagsPh}
|
||||||
className="field-input"
|
className="field-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,18 +267,18 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
onChange={e => setDraft(e.target.checked)}
|
onChange={e => setDraft(e.target.checked)}
|
||||||
className="accent-[var(--peach)]"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Caption (optional)</label>
|
<label className="field-label">{c.editorSummaryLabel}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={e => setSummary(e.target.value)}
|
onChange={e => setSummary(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="A short caption for the catalogue index..."
|
placeholder={c.editorSummaryPh}
|
||||||
className="field-input resize-none"
|
className="field-input resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,10 +294,11 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVimEnabled(v => !v)}
|
onClick={() => setVimEnabled(v => !v)}
|
||||||
className={`btn btn--ghost btn--sm${vimEnabled ? ' is-active' : ''}`}
|
className={`btn btn--ghost btn--sm vim-toggle${vimEnabled ? ' is-active' : ''}`}
|
||||||
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'}
|
aria-pressed={vimEnabled}
|
||||||
|
title={vimEnabled ? 'Vim mode ON — click to disable' : 'Vim mode OFF — click to enable'}
|
||||||
>
|
>
|
||||||
{vimEnabled ? 'VIM' : 'vim'}
|
{vimEnabled ? 'Vim on' : 'Vim off'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { login, ApiError } from '../../../lib/api';
|
import { login, ApiError } from '../../../lib/api';
|
||||||
import { useAuth } from '../../../stores/auth';
|
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 [value, setValue] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -80,7 +82,7 @@ export default function Login() {
|
|||||||
<div className="text-center mt-6">
|
<div className="text-center mt-6">
|
||||||
<a href="/" className="back-link">
|
<a href="/" className="back-link">
|
||||||
<span className="bl-arrow" aria-hidden="true">←</span>
|
<span className="bl-arrow" aria-hidden="true">←</span>
|
||||||
Back to the catalogue
|
{c.adminBack}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ApiError, deletePost, getPost, savePost } from '../../../../lib/api';
|
import { ApiError, deletePost, getPost, savePost } from '../../../../lib/api';
|
||||||
import { confirmDialog, notify } from '../../../../lib/confirm';
|
import { confirmDialog, notify } from '../../../../lib/confirm';
|
||||||
|
import { type SiteMode, copy } from '../../../../lib/siteMode';
|
||||||
import { clientSlugify } from './codemirror';
|
import { clientSlugify } from './codemirror';
|
||||||
|
|
||||||
interface Opts {
|
interface Opts {
|
||||||
editSlug?: string;
|
editSlug?: string;
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
setContent: (s: string) => void;
|
setContent: (s: string) => void;
|
||||||
|
mode?: SiteMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Post metadata form + slug derivation + load/save/delete. */
|
/** 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 today = new Date().toISOString().slice(0, 10);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [slug, setSlug] = useState(editSlug || '');
|
const [slug, setSlug] = useState(editSlug || '');
|
||||||
@@ -87,9 +90,9 @@ export function usePostMeta({ editSlug, getContent, setContent }: Opts) {
|
|||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
const target = originalSlug || slug;
|
const target = originalSlug || slug;
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: 'Remove from catalogue?',
|
title: c.deletePostTitle,
|
||||||
message: `“${target}” will be permanently removed. This cannot be undone.`,
|
message: c.deletePostMsg(target),
|
||||||
confirmLabel: 'Remove',
|
confirmLabel: c.deleteListConfirm,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from './Layout.astro';
|
import Layout from './Layout.astro';
|
||||||
|
import { getSiteMode, copy } from '../lib/siteMode';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,6 +8,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { title, wide = false } = Astro.props;
|
const { title, wide = false } = Astro.props;
|
||||||
|
const c = copy(getSiteMode());
|
||||||
|
|
||||||
if (Astro.cookies.get('admin_session')?.value !== '1') {
|
if (Astro.cookies.get('admin_session')?.value !== '1') {
|
||||||
return Astro.redirect('/admin/login');
|
return Astro.redirect('/admin/login');
|
||||||
@@ -19,9 +21,9 @@ if (Astro.cookies.get('admin_session')?.value !== '1') {
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<a href="/" class="back-link mb-3">
|
<a href="/" class="back-link mb-3">
|
||||||
<span class="bl-arrow" aria-hidden="true">←</span>
|
<span class="bl-arrow" aria-hidden="true">←</span>
|
||||||
Back to the catalogue
|
{c.adminBack}
|
||||||
</a>
|
</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]">
|
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-5xl tracking-tight leading-[1.05]">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import CyberFx from '../components/CyberFx.astro';
|
|||||||
import Search from '../components/react/Search';
|
import Search from '../components/react/Search';
|
||||||
import LogoutButton from '../components/react/LogoutButton';
|
import LogoutButton from '../components/react/LogoutButton';
|
||||||
import EditableText from '../components/react/EditableText';
|
import EditableText from '../components/react/EditableText';
|
||||||
|
import { getSiteMode, copy } from '../lib/siteMode';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,6 +25,9 @@ interface Props {
|
|||||||
|
|
||||||
const { title, wide = false, description, image, type = 'website', minimal = false } = Astro.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 API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
||||||
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||||
|
|
||||||
@@ -60,7 +64,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class={`mode-${siteMode}`}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -133,7 +137,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
|
|||||||
{hasContact && (
|
{hasContact && (
|
||||||
<a href="/contact" class="topbar-control">Contact</a>
|
<a href="/contact" class="topbar-control">Contact</a>
|
||||||
)}
|
)}
|
||||||
<Search client:idle />
|
<Search client:idle mode={siteMode} />
|
||||||
<span class="topbar-divider" aria-hidden="true"></span>
|
<span class="topbar-divider" aria-hidden="true"></span>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<LogoutButton client:idle />
|
<LogoutButton client:idle />
|
||||||
@@ -162,7 +166,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
|
|||||||
<>
|
<>
|
||||||
<div class="section-rule mb-6">
|
<div class="section-rule mb-6">
|
||||||
<span class="ornament">✦</span>
|
<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>
|
<span class="ornament">✦</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-display italic text-base text-[var(--subtext1)] mb-2">
|
<p class="font-display italic text-base text-[var(--subtext1)] mb-2">
|
||||||
|
|||||||
@@ -198,20 +198,6 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
|
|||||||
}
|
}
|
||||||
emit(spline(spinePts), 'cs-sig-main');
|
emit(spline(spinePts), 'cs-sig-main');
|
||||||
|
|
||||||
// ── Terminal spike crown: long, straight, downward-converging needles
|
|
||||||
// off the spine tail — the dripping barbed point that ends the growth.
|
|
||||||
const tip = spinePts[spinePts.length - 1];
|
|
||||||
const fan = 4 + Math.floor(rng() * 3); // 4–6
|
|
||||||
for (let s = 0; s < fan; s++) {
|
|
||||||
const spread = (s / (fan - 1) - 0.5) * 1.0; // ~±0.5 rad — tighter dagger
|
|
||||||
const a = Math.PI / 2 + spread; // π/2 = +y (down)
|
|
||||||
const len = rnd(64, 104) * (1 - Math.abs(spread) * 0.35); // center longest
|
|
||||||
const base: Pt = [tip[0], tip[1] - rnd(0, 8)];
|
|
||||||
const tt: Pt = [base[0] + Math.cos(a) * len, base[1] + Math.sin(a) * len];
|
|
||||||
emit(`M${n(base[0])} ${n(base[1])}L${n(tt[0])} ${n(tt[1])}`, 'cs-sig-barb');
|
|
||||||
track(tt[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Branch nodes ride the spine and throw limbs outward. Nodes are
|
// ── Branch nodes ride the spine and throw limbs outward. Nodes are
|
||||||
// inset from the very ends and spread the full height so growth flows
|
// inset from the very ends and spread the full height so growth flows
|
||||||
// down the whole trunk rather than clumping at the top.
|
// down the whole trunk rather than clumping at the top.
|
||||||
@@ -226,15 +212,13 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
|
|||||||
const node: Pt = [a[0] + (b[0] - a[0]) * sf, a[1] + (b[1] - a[1]) * sf];
|
const node: Pt = [a[0] + (b[0] - a[0]) * sf, a[1] + (b[1] - a[1]) * sf];
|
||||||
// later nodes lean downward so the lower trunk fills out
|
// later nodes lean downward so the lower trunk fills out
|
||||||
const bias = -0.25 + tc * 0.7;
|
const bias = -0.25 + tc * 0.7;
|
||||||
// spike gradient: lower trunk grows longer, sharper barbs
|
|
||||||
const spike = 1 + tc * 1.4; // 1× top → ~2.3× bottom
|
|
||||||
const limbs = 1 + Math.floor(rng() * 2);
|
const limbs = 1 + Math.floor(rng() * 2);
|
||||||
for (let l = 0; l < limbs; l++) {
|
for (let l = 0; l < limbs; l++) {
|
||||||
const ang = bias + rnd(-0.55, 0.55);
|
const ang = bias + rnd(-0.55, 0.55);
|
||||||
limb(node[0], node[1], ang, rnd(0.65, 1.05) * (0.8 + tc * 0.5), 1);
|
limb(node[0], node[1], ang, rnd(0.65, 1.05), 1);
|
||||||
}
|
}
|
||||||
// the odd bare barb straight off the spine keeps the trunk prickly
|
// the odd bare barb straight off the spine keeps the trunk prickly
|
||||||
if (rng() < 0.55) barb(node, bias + rnd(-0.3, 0.3), rnd(7, 14) * spike);
|
if (rng() < 0.55) barb(node, bias + rnd(-0.3, 0.3), rnd(7, 14));
|
||||||
}
|
}
|
||||||
|
|
||||||
const half = parts.join('');
|
const half = parts.join('');
|
||||||
|
|||||||
@@ -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 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="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">
|
<div class="numeral text-[var(--mauve)] text-[8rem] md:text-[12rem] leading-none mb-4">
|
||||||
CDIV
|
CDIV
|
||||||
</div>
|
</div>
|
||||||
<div class="section-rule max-w-sm mx-auto mb-8">
|
<div class="section-rule max-w-sm mx-auto mb-8">
|
||||||
<span class="ornament">✦</span>
|
<span class="ornament">✦</span>
|
||||||
<span>Pardon — the gallery has misplaced this work</span>
|
<span>{c.notFoundRule}</span>
|
||||||
<span class="ornament">✦</span>
|
<span class="ornament">✦</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-display italic text-3xl md:text-5xl text-[var(--text)] mb-6 leading-tight">
|
<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>
|
</h1>
|
||||||
<p class="text-[var(--subtext1)] font-sans text-lg mb-10 leading-relaxed">
|
<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" />
|
{bodyA}<br class="hidden md:block" />
|
||||||
or never made it to the wall in the first place.
|
{bodyB}
|
||||||
</p>
|
</p>
|
||||||
<a href="/" class="btn btn--primary">↶ Return to the catalogue</a>
|
<a href="/" class="btn btn--primary">↶ {c.returnHome}</a>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||||
import Editor from '../../components/react/admin/Editor';
|
import Editor from '../../components/react/admin/Editor';
|
||||||
|
import { getSiteMode } from '../../lib/siteMode';
|
||||||
|
|
||||||
const editSlug = Astro.url.searchParams.get('edit') || undefined;
|
const editSlug = Astro.url.searchParams.get('edit') || undefined;
|
||||||
|
const siteMode = getSiteMode();
|
||||||
---
|
---
|
||||||
|
|
||||||
<AdminLayout title="Write Post" wide>
|
<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>
|
<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>
|
</AdminLayout>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import Login from '../../components/react/admin/Login';
|
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>
|
<Layout title="Admin Login" description="Sign in to the back room." minimal>
|
||||||
<Login client:only="react" />
|
<Login client:only="react" mode={siteMode} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
import PostList from '../components/react/PostList';
|
import PostList from '../components/react/PostList';
|
||||||
import EditableText from '../components/react/EditableText';
|
import EditableText from '../components/react/EditableText';
|
||||||
import AssetsButton from '../components/react/AssetsButton';
|
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';
|
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';
|
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 && (
|
{posts[0]?.cover_image?.url && (
|
||||||
<Fragment slot="head">
|
<Fragment slot="head">
|
||||||
<link rel="preload" as="image" href={posts[0].cover_image.url} fetchpriority="high" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:idle />}
|
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} mode={siteMode} client:idle />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import 'katex/dist/katex.min.css';
|
|||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import DeletePostButton from '../../components/react/DeletePostButton';
|
import DeletePostButton from '../../components/react/DeletePostButton';
|
||||||
import { renderMarkdown } from '../../lib/markdown';
|
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 { slug } = Astro.params;
|
||||||
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
|
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();
|
post = await postRes.json();
|
||||||
html = renderMarkdown(post!.content, post!.dimensions);
|
html = renderMarkdown(post!.content, post!.dimensions);
|
||||||
} else {
|
} else {
|
||||||
error = 'Work not found in the catalogue';
|
error = c.postNotFound;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const cause = (e as any)?.cause;
|
const cause = (e as any)?.cause;
|
||||||
@@ -73,7 +78,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
|
|||||||
image={post?.cover_image?.url}
|
image={post?.cover_image?.url}
|
||||||
type="article"
|
type="article"
|
||||||
>
|
>
|
||||||
{post?.cover_image?.url && (
|
{!isBlog && post?.cover_image?.url && (
|
||||||
<Fragment slot="head">
|
<Fragment slot="head">
|
||||||
<link rel="preload" as="image" href={post.cover_image.url} fetchpriority="high" />
|
<link rel="preload" as="image" href={post.cover_image.url} fetchpriority="high" />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -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="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>
|
<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>
|
<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>
|
</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)]">
|
<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">
|
<a href="/" class="back-link">
|
||||||
<span class="bl-arrow" aria-hidden="true">←</span>
|
<span class="bl-arrow" aria-hidden="true">←</span>
|
||||||
Back to catalogue
|
{c.backHome}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -103,7 +108,7 @@ 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>
|
<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
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<DeletePostButton slug={post.slug} title={displayTitle} client:idle />
|
<DeletePostButton slug={post.slug} title={displayTitle} mode={siteMode} client:idle />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +122,12 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
|
|||||||
<div class="section-rule max-w-md mx-auto mb-6">
|
<div class="section-rule max-w-md mx-auto mb-6">
|
||||||
<span class="ornament">✦</span>
|
<span class="ornament">✦</span>
|
||||||
<span>{formatDate(post.date)}</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 class="ornament">·</span>
|
||||||
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
|
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
|
||||||
@@ -135,7 +145,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
|
|||||||
{post.draft && (
|
{post.draft && (
|
||||||
<div class="mt-6 inline-block">
|
<div class="mt-6 inline-block">
|
||||||
<span class="chip chip-draft">
|
<span class="chip chip-draft">
|
||||||
Sketch · unpublished
|
{c.draftLong}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -150,7 +160,11 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
|
|||||||
{/* Body — works on paper */}
|
{/* Body — works on paper */}
|
||||||
<div id="post-content" class="prose" set:html={html} />
|
<div id="post-content" class="prose" set:html={html} />
|
||||||
|
|
||||||
{(neighbors.prev || neighbors.next) && (
|
{isBlog ? (
|
||||||
|
<div class="max-w-3xl mx-auto mt-20 md:mt-28 text-center">
|
||||||
|
<a href="/" class="btn btn--ghost">← {c.backHome}</a>
|
||||||
|
</div>
|
||||||
|
) : (neighbors.prev || neighbors.next) && (
|
||||||
<nav class="post-nav max-w-3xl mx-auto mt-20 md:mt-28 grid grid-cols-1 md:grid-cols-2 gap-6" aria-label="Post navigation">
|
<nav class="post-nav max-w-3xl mx-auto mt-20 md:mt-28 grid grid-cols-1 md:grid-cols-2 gap-6" aria-label="Post navigation">
|
||||||
{neighbors.prev && (
|
{neighbors.prev && (
|
||||||
<a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left">
|
<a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@import "./partials/20-atmosphere.css";
|
@import "./partials/20-atmosphere.css";
|
||||||
@import "./partials/30-prose.css";
|
@import "./partials/30-prose.css";
|
||||||
@import "./partials/40-components.css";
|
@import "./partials/40-components.css";
|
||||||
|
@import "./partials/45-blog.css";
|
||||||
@import "./partials/50-controls.css";
|
@import "./partials/50-controls.css";
|
||||||
@import "./partials/60-breakcore.css";
|
@import "./partials/60-breakcore.css";
|
||||||
@import "./partials/70-cybersigil.css";
|
@import "./partials/70-cybersigil.css";
|
||||||
|
|||||||
@@ -216,6 +216,11 @@
|
|||||||
.prose img {
|
.prose img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
/* A tall single image must never overrun the viewport: cap its height and
|
||||||
|
* let width track aspect so it scales down whole, centred. (figure-row
|
||||||
|
* images opt out below — their height is already bounded by --row-h.) */
|
||||||
|
max-height: 85vh;
|
||||||
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border: 1px solid var(--surface2);
|
border: 1px solid var(--surface2);
|
||||||
@@ -288,6 +293,7 @@
|
|||||||
.prose .figure-row figure img {
|
.prose .figure-row figure img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
max-height: none;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,11 +75,26 @@
|
|||||||
background: var(--red);
|
background: var(--red);
|
||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
}
|
}
|
||||||
/* Pressed/selected state for toggle & tab buttons */
|
/* Pressed/selected state for toggle & tab buttons. Solid accent fill so an
|
||||||
.btn.is-active {
|
* engaged toggle is unmistakable (label alone is too subtle). The
|
||||||
color: var(--mauve);
|
* `.btn--ghost.is-active` selector carries enough specificity (0,3,0) to beat
|
||||||
border-color: color-mix(in srgb, var(--mauve) 55%, var(--surface2));
|
* the theme `.cybersigil/.breakcore .btn--ghost` rules (0,2,0) that load in
|
||||||
background: color-mix(in srgb, var(--mauve) 14%, transparent);
|
* later partials — without it the active state is invisible on those themes. */
|
||||||
|
.btn.is-active,
|
||||||
|
.btn.btn--ghost.is-active {
|
||||||
|
color: var(--rosewater);
|
||||||
|
background: var(--mauve);
|
||||||
|
border-color: var(--mauve);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--crust) 35%, transparent),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--mauve) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The vim toggle is desktop-only — vim mode auto-disables below 768px, so the
|
||||||
|
* button is a no-op there. Unlayered media query (Tailwind `hidden` would
|
||||||
|
* lose the cascade to `.btn { display: inline-flex }`). */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.btn.vim-toggle { display: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sizes */
|
/* Sizes */
|
||||||
@@ -386,11 +401,24 @@ select.topbar-control.theme-select {
|
|||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Salon grid spans driven by --col-span custom prop (avoids Tailwind dynamic class issue). */
|
/* Justified gallery — JS solves each row's height so widths fill the line
|
||||||
@media (min-width: 768px) {
|
* exactly (PostList.tsx). flex-grow on the tiles absorbs sub-pixel rounding;
|
||||||
.md-col-span {
|
* the pre-measure / no-JS fallback wraps and grows by aspect instead. */
|
||||||
grid-column: span var(--col-span, 6) / span var(--col-span, 6);
|
.just-gallery { width: 100%; }
|
||||||
|
.just-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
.just-row--fallback {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 40px;
|
||||||
|
}
|
||||||
|
.just-row > .plate-enter { min-width: 0; }
|
||||||
|
/* Very narrow viewports: let even the fallback stack cleanly. */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.just-row--fallback > .plate-enter { flex-basis: 100% !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle page enter animation for gallery / plaque */
|
/* Subtle page enter animation for gallery / plaque */
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ html.cybersigil body::after {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
/* Lit by CyberFx.astro after init (no hard pop on first paint / theme
|
||||||
|
* swap). The same opacity is eased down with scroll depth so the ambient
|
||||||
|
* recedes behind content as you read, then returns near the top. */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
/* Tab hidden → freeze every looping sigil/flicker/tear. Big idle-battery
|
||||||
|
* win; the layer is purely decorative so a frozen frame is fine. */
|
||||||
|
.cybersigil .cs-fx.is-paused .cs-fx-corner,
|
||||||
|
.cybersigil .cs-fx.is-paused .cs-fx-tear,
|
||||||
|
.cybersigil .cs-fx.is-paused .cs-sigil path,
|
||||||
|
.cybersigil .cs-fx.is-paused .cs-sigil line {
|
||||||
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
.cybersigil .cs-fx-halftone,
|
.cybersigil .cs-fx-halftone,
|
||||||
.cybersigil .cs-fx-wire,
|
.cybersigil .cs-fx-wire,
|
||||||
@@ -103,7 +116,12 @@ html.cybersigil body::after {
|
|||||||
width: 92vmin;
|
width: 92vmin;
|
||||||
height: 92vmin;
|
height: 92vmin;
|
||||||
opacity: 0.14;
|
opacity: 0.14;
|
||||||
transform: translate(-50%, -50%);
|
/* --cs-px/--cs-py: scroll + pointer parallax drift (CyberFx.astro). */
|
||||||
|
transform: translate(
|
||||||
|
calc(-50% + var(--cs-px, 0px)),
|
||||||
|
calc(-50% + var(--cs-py, 0px))
|
||||||
|
);
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.cybersigil .cs-fx-wire .cs-sigil {
|
.cybersigil .cs-fx-wire .cs-sigil {
|
||||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--sky) 35%, transparent));
|
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--sky) 35%, transparent));
|
||||||
@@ -156,6 +174,10 @@ html.cybersigil body::after {
|
|||||||
mask: var(--cs-corner) center / contain no-repeat;
|
mask: var(--cs-corner) center / contain no-repeat;
|
||||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 45%, transparent));
|
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 45%, transparent));
|
||||||
animation: cs-flicker 7s linear infinite;
|
animation: cs-flicker 7s linear infinite;
|
||||||
|
/* Pointer parallax via the independent `translate` longhand so it composes
|
||||||
|
* with the per-corner mirror in `transform` (and the flicker, which only
|
||||||
|
* touches opacity) without any of them clobbering the others. */
|
||||||
|
translate: var(--cs-cx, 0px) var(--cs-cy, 0px);
|
||||||
}
|
}
|
||||||
.cybersigil .cs-fx-corner--tl { top: 0; left: 0; }
|
.cybersigil .cs-fx-corner--tl { top: 0; left: 0; }
|
||||||
.cybersigil .cs-fx-corner--tr { top: 0; right: 0; transform: scaleX(-1); animation-delay: -1.7s; }
|
.cybersigil .cs-fx-corner--tr { top: 0; right: 0; transform: scaleX(-1); animation-delay: -1.7s; }
|
||||||
@@ -405,18 +427,16 @@ html.cybersigil body::after {
|
|||||||
animation: cs-scan 0.78s cubic-bezier(0.4, 0, 0.2, 1) 1;
|
animation: cs-scan 0.78s cubic-bezier(0.4, 0, 0.2, 1) 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-plate sigil — spiky bruised-magenta growth that carves over the tile
|
/* Per-plate sigil — spiky bruised-magenta growth that carves over the image
|
||||||
* on contact, then fades back out. Screen-blended so it etches into the
|
* on contact, then fades back out. Screen-blended so it etches into the
|
||||||
* panel rather than masking it. */
|
* photo rather than masking it. Pinned to the image box itself (inset:0)
|
||||||
|
* so it always centres on the actual picture, never the stretched tile/row;
|
||||||
|
* the box's overflow:hidden trims the bleed so it reads as carved-in. */
|
||||||
.cs-plate-sig { display: none; }
|
.cs-plate-sig { display: none; }
|
||||||
.cybersigil .cs-plate-sig {
|
.cybersigil .cs-plate-sig {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -9%;
|
inset: 0;
|
||||||
left: 50%;
|
|
||||||
width: 46%;
|
|
||||||
height: 118%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 14;
|
z-index: 14;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -774,24 +794,61 @@ html.cybersigil body::after {
|
|||||||
color: var(--mauve);
|
color: var(--mauve);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
/* Section break — corrupted-terminal sigil rule. A thin ice→magenta scan
|
||||||
|
* that tears open at center for a glowing barb-sigil glyph, doubled by the
|
||||||
|
* hard cold offset echo the buttons/plates carry. */
|
||||||
.cybersigil .prose hr {
|
.cybersigil .prose hr {
|
||||||
height: 3px;
|
height: 1px;
|
||||||
opacity: 0.9;
|
margin: 3.75rem auto;
|
||||||
background: repeating-linear-gradient(
|
opacity: 1;
|
||||||
|
overflow: visible;
|
||||||
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--sky) 0 12px,
|
transparent 0,
|
||||||
var(--mauve) 12px 24px
|
color-mix(in srgb, var(--sky) 12%, transparent) 5%,
|
||||||
|
color-mix(in srgb, var(--sky) 72%, transparent) 24%,
|
||||||
|
color-mix(in srgb, var(--sky) 72%, transparent) 42%,
|
||||||
|
transparent 46%,
|
||||||
|
transparent 54%,
|
||||||
|
color-mix(in srgb, var(--mauve) 72%, transparent) 58%,
|
||||||
|
color-mix(in srgb, var(--mauve) 72%, transparent) 76%,
|
||||||
|
color-mix(in srgb, var(--mauve) 12%, transparent) 95%,
|
||||||
|
transparent 100%
|
||||||
);
|
);
|
||||||
box-shadow: 0 0 12px color-mix(in srgb, var(--sky) 42%, transparent);
|
box-shadow: 0 0 16px -3px color-mix(in srgb, var(--sky) 32%, transparent);
|
||||||
}
|
}
|
||||||
|
/* Hard cold offset echo — the signature drop the rest of the theme carries. */
|
||||||
|
.cybersigil .prose hr::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(50% + 3px);
|
||||||
|
height: 1px;
|
||||||
|
background: inherit;
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
/* Center glyph — the barb sigil, glowing, seated over the gap. Overrides
|
||||||
|
* the base rotated-diamond pip entirely. */
|
||||||
.cybersigil .prose hr::before {
|
.cybersigil .prose hr::before {
|
||||||
|
width: 3.4em;
|
||||||
|
height: 1.5em;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
background: var(--sky);
|
background: var(--sky);
|
||||||
box-shadow: 0 0 8px var(--sky);
|
border-radius: 0;
|
||||||
|
-webkit-mask: var(--cs-barb) center / contain no-repeat;
|
||||||
|
mask: var(--cs-barb) center / contain no-repeat;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 55%, transparent))
|
||||||
|
drop-shadow(0 0 2px color-mix(in srgb, var(--mauve) 50%, transparent));
|
||||||
}
|
}
|
||||||
.cybersigil .prose h3,
|
.cybersigil .prose h3,
|
||||||
.cybersigil .prose h4,
|
.cybersigil .prose h4,
|
||||||
.cybersigil .prose h5 {
|
.cybersigil .prose h5,
|
||||||
|
.cybersigil .prose h6 {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
font-style: normal;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
@@ -801,6 +858,7 @@ html.cybersigil body::after {
|
|||||||
.cybersigil .prose h4::before { content: "### "; color: var(--sky); opacity: 0.6; }
|
.cybersigil .prose h4::before { content: "### "; color: var(--sky); opacity: 0.6; }
|
||||||
.cybersigil .prose h5 { color: var(--green); }
|
.cybersigil .prose h5 { color: var(--green); }
|
||||||
.cybersigil .prose h5::before { content: "#### "; color: var(--sky); opacity: 0.55; }
|
.cybersigil .prose h5::before { content: "#### "; color: var(--sky); opacity: 0.55; }
|
||||||
|
.cybersigil .prose h6::before { content: "##### "; color: var(--sky); opacity: 0.5; }
|
||||||
.cybersigil .prose a {
|
.cybersigil .prose a {
|
||||||
text-decoration-style: dotted;
|
text-decoration-style: dotted;
|
||||||
text-decoration-color: color-mix(in srgb, var(--sky) 60%, transparent);
|
text-decoration-color: color-mix(in srgb, var(--sky) 60%, transparent);
|
||||||
@@ -811,6 +869,82 @@ html.cybersigil body::after {
|
|||||||
text-shadow: -1px 0 0 var(--sky), 1px 0 0 var(--mauve);
|
text-shadow: -1px 0 0 var(--sky), 1px 0 0 var(--mauve);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emphasis — VT323 has no real italic, so the base font-display em renders
|
||||||
|
* as mangled bitmap. Drop to the terminal body and re-encode emphasis as
|
||||||
|
* colour: magenta em, ice strong with a faint bloom, red-struck del. */
|
||||||
|
.cybersigil .prose em {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
|
.cybersigil .prose strong {
|
||||||
|
color: var(--sky);
|
||||||
|
text-shadow: 0 0 6px color-mix(in srgb, var(--sky) 30%, transparent);
|
||||||
|
}
|
||||||
|
.cybersigil .prose del {
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--red) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists — terminal nodes. The salon diamond becomes a square ice cell with
|
||||||
|
* the hard magenta offset; ordered markers go cold mono, no faux-italic. */
|
||||||
|
.cybersigil .prose ul > li::before {
|
||||||
|
width: 0.4em;
|
||||||
|
height: 0.4em;
|
||||||
|
top: 0.66em;
|
||||||
|
transform: none;
|
||||||
|
background: var(--sky);
|
||||||
|
box-shadow: 2px 2px 0 0 color-mix(in srgb, var(--mauve) 70%, transparent);
|
||||||
|
}
|
||||||
|
.cybersigil .prose ol > li::marker {
|
||||||
|
color: var(--sky);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.cybersigil .prose li > input[type="checkbox"] { accent-color: var(--mauve); }
|
||||||
|
|
||||||
|
/* Figure caption — a `// ` log comment, not a salon plaque dash. */
|
||||||
|
.cybersigil .prose figure figcaption {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-style: normal;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--subtext0);
|
||||||
|
}
|
||||||
|
.cybersigil .prose figure figcaption::before {
|
||||||
|
content: "// ";
|
||||||
|
color: var(--sky);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables — a sector readout: square ice grid, scanline header, hard offset,
|
||||||
|
* magenta hover sweep. */
|
||||||
|
.cybersigil .prose table {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
border-color: color-mix(in srgb, var(--sky) 40%, var(--surface2));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--sky) 14%, transparent),
|
||||||
|
4px 4px 0 0 color-mix(in srgb, var(--mauve) 22%, var(--crust));
|
||||||
|
}
|
||||||
|
.cybersigil .prose thead {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0) 0 2px,
|
||||||
|
color-mix(in srgb, var(--sky) 8%, transparent) 2px 3px,
|
||||||
|
rgba(0, 0, 0, 0) 3px 4px
|
||||||
|
),
|
||||||
|
color-mix(in srgb, var(--crust) 80%, transparent);
|
||||||
|
}
|
||||||
|
.cybersigil .prose th {
|
||||||
|
color: var(--sky);
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--mauve) 50%, transparent);
|
||||||
|
}
|
||||||
|
.cybersigil .prose td {
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--sky) 20%, transparent);
|
||||||
|
}
|
||||||
|
.cybersigil .prose tbody tr:hover {
|
||||||
|
background: color-mix(in srgb, var(--sky) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar + caret — hard cold chrome. */
|
/* Scrollbar + caret — hard cold chrome. */
|
||||||
.cybersigil {
|
.cybersigil {
|
||||||
scrollbar-color: var(--sky) var(--crust);
|
scrollbar-color: var(--sky) var(--crust);
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":"4.1.6","results":[[":frontend/src/lib/cybersigil.test.ts",{"duration":66.06918499999995,"failed":false}]]}
|
||||||
Reference in New Issue
Block a user