import { useEffect, useRef, useState } from 'react';
import { deletePost } from '../../lib/api';
import { confirmDialog, notify } from '../../lib/confirm';
import { buildCybersigil } from '../../lib/cybersigil';
// 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
// cybersigil theme; carves in on plate hover/focus via global.css.
function PlateSigil() {
const [html, setHtml] = useState('');
useEffect(() => { setHtml(buildCybersigil()); }, []);
if (!html) return null;
return (
);
}
const PAGE_SIZE = 9;
interface CoverImage {
url: string;
alt: string;
w?: number;
h?: number;
}
interface Post {
slug: string;
date: string;
title?: string;
excerpt?: string;
summary?: string;
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
interface Props {
posts: Post[];
isAdmin?: boolean;
}
function formatSlug(slug: string) {
if (!slug) return '';
return slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function formatYear(date: string) {
return new Date(date).getFullYear();
}
function formatMonth(date: string) {
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.
// The cycle is chosen so the room reads asymmetric but balanced.
const LAYOUT_CYCLE: Array<{ col: number; aspect: string; tilt: number }> = [
{ col: 7, aspect: '4 / 3', tilt: -0.4 },
{ col: 5, aspect: '3 / 4', tilt: 0.3 },
{ col: 4, aspect: '4 / 5', tilt: -0.2 },
{ 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) {
const [posts, setPosts] = useState(initialPosts);
const [deleting, setDeleting] = useState(null);
const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length));
const sentinelRef = useRef(null);
async function handleDelete(slug: string, title: string) {
if (deleting) return;
const ok = await confirmDialog({
title: 'Take this off the wall?',
message: `“${title}” will be removed from the catalogue. This cannot be undone.`,
confirmLabel: 'Remove',
cancelLabel: 'Keep',
});
if (!ok) return;
setDeleting(slug);
try {
await deletePost(slug);
setPosts(p => p.filter(x => x.slug !== slug));
} catch (e) {
notify(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`);
} finally {
setDeleting(null);
}
}
useEffect(() => {
setVisible(v => Math.min(Math.max(v, PAGE_SIZE), posts.length));
}, [posts.length]);
useEffect(() => {
if (visible >= posts.length) return;
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
entries => {
if (entries.some(e => e.isIntersecting)) {
setVisible(v => Math.min(v + PAGE_SIZE, posts.length));
}
},
{ rootMargin: '600px 0px' },
);
io.observe(el);
return () => io.disconnect();
}, [visible, posts.length]);
if (posts.length === 0) {
return null;
}
const shown = posts.slice(0, visible);
const hasMore = visible < posts.length;
return (
<>
{hasMore && (
arranging more…
)}
>
);
}