Files
narlblog/frontend/src/components/react/PostList.tsx
T
2026-05-14 11:36:36 +02:00

228 lines
8.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { deletePost } from '../../lib/api';
const PAGE_SIZE = 9;
interface CoverImage {
url: string;
alt: string;
}
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;
if (!window.confirm(`Take "${title}" off the wall? This cannot be undone.`)) return;
setDeleting(slug);
try {
await deletePost(slug);
setPosts(p => p.filter(x => x.slug !== slug));
} catch (e) {
window.alert(`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}
loading={idx < 3 ? 'eager' : 'lazy'}
/>
) : (
<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="p-1.5 bg-[var(--mantle)] text-[var(--rosewater)] hover:bg-[var(--blue)] border border-[var(--surface2)] transition-colors"
style={{ borderRadius: 1 }}
>
<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="p-1.5 bg-[var(--mantle)] text-[var(--rosewater)] hover:bg-[var(--red)] border border-[var(--surface2)] transition-colors disabled:opacity-50"
style={{ borderRadius: 1 }}
>
<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>
);
})}
</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>
)}
</>
);
}