init elas atelier #1
@@ -23,11 +23,16 @@
|
|||||||
<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;
|
||||||
|
const fine = window.matchMedia('(pointer: fine)').matches;
|
||||||
|
|
||||||
/* ─── Generated sigil growths in the four corners ─── */
|
/* ─── Generated sigil growths in the four corners ─── */
|
||||||
/* One sigil per page load; the four corner transforms in global.css
|
/* One sigil per page load; the four corner transforms in global.css
|
||||||
@@ -42,10 +47,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 +78,60 @@
|
|||||||
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, scroll + pointer
|
||||||
|
* parallax. Parallax is gated on fine pointers and reduced-motion;
|
||||||
|
* the depth fade is just opacity tied to scroll position. ─── */
|
||||||
|
if (teardown) teardown();
|
||||||
|
const off: Array<() => void> = [];
|
||||||
|
let depth = 0, px = 0, py = 0, raf = 0;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
raf = 0;
|
||||||
|
fx.style.opacity = String(1 - 0.5 * depth);
|
||||||
|
if (!reduced) {
|
||||||
|
fx.style.setProperty('--cs-px', px.toFixed(1) + 'px');
|
||||||
|
fx.style.setProperty('--cs-py', (py + depth * 24).toFixed(1) + 'px');
|
||||||
|
fx.style.setProperty('--cs-cx', (-px * 0.42).toFixed(1) + 'px');
|
||||||
|
fx.style.setProperty('--cs-cy', (-py * 0.42).toFixed(1) + 'px');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!reduced && fine) {
|
||||||
|
const onPointer = (e: PointerEvent | MouseEvent) => {
|
||||||
|
const w = window.innerWidth || 1, h = window.innerHeight || 1;
|
||||||
|
px = (e.clientX / w - 0.5) * 26; // ≈ ±13px drift
|
||||||
|
py = (e.clientY / h - 0.5) * 26;
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onPointer, { passive: true });
|
||||||
|
off.push(() => window.removeEventListener('pointermove', onPointer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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,11 +1,15 @@
|
|||||||
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';
|
||||||
|
|
||||||
// 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()); }, []);
|
||||||
@@ -62,25 +66,89 @@ 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.
|
// ── Justified-gallery layout ────────────────────────────────────────────────
|
||||||
// The cycle is chosen so the room reads asymmetric but balanced.
|
// Every cover keeps its true aspect ratio (no crop). Tiles are packed left to
|
||||||
const LAYOUT_CYCLE: Array<{ col: number; aspect: string; tilt: number }> = [
|
// right; once a tentative row is at least as wide as the container it is
|
||||||
{ col: 7, aspect: '4 / 3', tilt: -0.4 },
|
// finalized and its height solved so the row fills the width exactly. The
|
||||||
{ col: 5, aspect: '3 / 4', tilt: 0.3 },
|
// trailing partial row stays at the target height (left-aligned), only shrunk
|
||||||
{ col: 4, aspect: '4 / 5', tilt: -0.2 },
|
// if a lone wide image would overflow. Result: variable widths, one shared
|
||||||
{ col: 4, aspect: '1 / 1', tilt: 0.5 },
|
// image height per visual row, no wasted space.
|
||||||
{ col: 4, aspect: '4 / 5', tilt: -0.6 },
|
|
||||||
{ col: 5, aspect: '1 / 1', tilt: 0.2 },
|
const GAP = 18; // horizontal gap between tiles in a row (px)
|
||||||
{ col: 7, aspect: '5 / 4', tilt: -0.3 },
|
const ROW_GAP = 40; // vertical gap between rows (px)
|
||||||
{ col: 8, aspect: '16 / 10', tilt: 0.4 },
|
const MIN_H = 150; // floor so a wide-only row never collapses
|
||||||
{ col: 4, aspect: '3 / 4', tilt: -0.5 },
|
// 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 }: Props) {
|
export default function PostList({ posts: initialPosts, isAdmin = false }: Props) {
|
||||||
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;
|
||||||
@@ -122,136 +190,188 @@ 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;
|
||||||
|
|
||||||
|
// Measure the container + plate chrome and (re)compute the justified rows.
|
||||||
|
// Re-runs on resize and whenever the shown set changes (infinite scroll).
|
||||||
|
useIsoLayoutEffect(() => {
|
||||||
|
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 isDeleting = deleting === post.slug;
|
||||||
|
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 (
|
||||||
|
<article
|
||||||
|
key={post.slug}
|
||||||
|
className={`relative plate-enter ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
|
||||||
|
style={articleStyle}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/posts/${encodeURIComponent(post.slug)}`}
|
||||||
|
className="block plate group"
|
||||||
|
aria-label={`View ${displayTitle}`}
|
||||||
|
>
|
||||||
|
<div className="plate-image" style={imageStyle}>
|
||||||
|
{hasCover ? (
|
||||||
|
<img
|
||||||
|
src={post.cover_image!.url}
|
||||||
|
alt={post.cover_image!.alt || displayTitle}
|
||||||
|
width={post.cover_image!.w}
|
||||||
|
height={post.cover_image!.h}
|
||||||
|
loading={idx < 3 ? 'eager' : 'lazy'}
|
||||||
|
decoding={idx === 0 ? 'sync' : 'async'}
|
||||||
|
fetchPriority={idx === 0 ? 'high' : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center text-[var(--rosewater)]"
|
||||||
|
style={{ background: `linear-gradient(135deg, var(--mauve), var(--mantle))` }}
|
||||||
|
>
|
||||||
|
<span className="font-display italic text-3xl opacity-70">untitled</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{post.image_count > 1 && (
|
||||||
|
<span className="plate-tag-mini">{post.image_count} plates</span>
|
||||||
|
)}
|
||||||
|
{post.draft && (
|
||||||
|
<span className="plate-tag-mini plate-tag-mini--draft">Sketch</span>
|
||||||
|
)}
|
||||||
|
<PlateSigil />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plate-caption">
|
||||||
|
<div className="plate-caption-title">{displayTitle}</div>
|
||||||
|
{post.summary && (
|
||||||
|
<div className="plate-caption-summary">{post.summary}</div>
|
||||||
|
)}
|
||||||
|
<div className="plate-caption-meta">
|
||||||
|
<span>{formatMonth(post.date)}</span>
|
||||||
|
<span className="plate-caption-sep" aria-hidden="true">·</span>
|
||||||
|
<span>{formatYear(post.date)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
|
||||||
|
{post.tags.slice(0, 4).map(tag => (
|
||||||
|
<span key={tag} className="chip">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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="Remove"
|
||||||
|
aria-label={`Remove ${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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16">
|
<div ref={containerRef} className="just-gallery">
|
||||||
{shown.map((post, idx) => {
|
{rows
|
||||||
const displayTitle = post.title || formatSlug(post.slug);
|
? rows.map((row, r) => (
|
||||||
const isDeleting = deleting === post.slug;
|
|
||||||
const layout = LAYOUT_CYCLE[idx % LAYOUT_CYCLE.length];
|
|
||||||
const hasCover = !!post.cover_image?.url;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={post.slug}
|
|
||||||
className={`relative plate-enter md-col-span ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
|
|
||||||
style={{
|
|
||||||
animationDelay: `${Math.min(idx * 80, 480)}ms`,
|
|
||||||
['--col-span' as any]: layout.col,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`/posts/${encodeURIComponent(post.slug)}`}
|
|
||||||
className="block plate group"
|
|
||||||
style={{ transform: `rotate(${layout.tilt}deg)` }}
|
|
||||||
aria-label={`View ${displayTitle}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`plate-image ${hasCover ? 'is-natural' : ''}`}
|
className="just-row"
|
||||||
style={hasCover ? undefined : { aspectRatio: layout.aspect }}
|
key={r}
|
||||||
|
style={{ marginBottom: r === rows.length - 1 ? 0 : ROW_GAP }}
|
||||||
>
|
>
|
||||||
{hasCover ? (
|
{row.map(cell => renderCell(cell, cell.post, cell.idx))}
|
||||||
<img
|
|
||||||
src={post.cover_image!.url}
|
|
||||||
alt={post.cover_image!.alt || displayTitle}
|
|
||||||
width={post.cover_image!.w}
|
|
||||||
height={post.cover_image!.h}
|
|
||||||
loading={idx < 3 ? 'eager' : 'lazy'}
|
|
||||||
decoding={idx === 0 ? 'sync' : 'async'}
|
|
||||||
fetchPriority={idx === 0 ? 'high' : undefined}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="w-full h-full flex items-center justify-center text-[var(--rosewater)]"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(135deg, var(--mauve), var(--mantle))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-display italic text-3xl opacity-70">
|
|
||||||
untitled
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{post.image_count > 1 && (
|
|
||||||
<span className="plate-tag-mini">
|
|
||||||
{post.image_count} plates
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{post.draft && (
|
|
||||||
<span className="plate-tag-mini plate-tag-mini--draft">
|
|
||||||
Sketch
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
<div className="plate-caption">
|
: (
|
||||||
<div className="plate-caption-title">{displayTitle}</div>
|
<div className="just-row just-row--fallback">
|
||||||
{post.summary && (
|
{shown.map((post, idx) => renderCell(null, post, idx))}
|
||||||
<div className="plate-caption-summary">{post.summary}</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="plate-caption-meta">
|
|
||||||
<span>{formatMonth(post.date)}</span>
|
|
||||||
<span className="plate-caption-sep" aria-hidden="true">·</span>
|
|
||||||
<span>{formatYear(post.date)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{post.tags && post.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
|
|
||||||
{post.tags.slice(0, 4).map(tag => (
|
|
||||||
<span key={tag} className="chip">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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="Remove"
|
|
||||||
aria-label={`Remove ${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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlateSigil />
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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">arranging more…</span>
|
|
||||||
</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">arranging more…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,11 +386,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user