258 lines
9.1 KiB
TypeScript
258 lines
9.1 KiB
TypeScript
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 (
|
|
<div
|
|
className="cs-plate-sig"
|
|
aria-hidden="true"
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length));
|
|
const sentinelRef = useRef<HTMLDivElement>(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 (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16">
|
|
{shown.map((post, idx) => {
|
|
const displayTitle = post.title || formatSlug(post.slug);
|
|
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
|
|
className={`plate-image ${hasCover ? 'is-natural' : ''}`}
|
|
style={hasCover ? undefined : { aspectRatio: layout.aspect }}
|
|
>
|
|
{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" style={{ left: 18, right: 'auto', background: 'var(--peach)', color: 'var(--crust)' }}>
|
|
Sketch
|
|
</span>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
<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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|