init elas atelier

This commit is contained in:
2026-05-14 08:24:41 +02:00
parent 3b704a24a7
commit 38f33cacb1
19 changed files with 1436 additions and 657 deletions
+122 -98
View File
@@ -1,14 +1,22 @@
import { useState } from 'react';
import { deletePost } from '../../lib/api';
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 {
@@ -24,101 +32,144 @@ function formatSlug(slug: string) {
.join(' ');
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
function formatYear(date: string) {
return new Date(date).getFullYear();
}
function formatMonth(date: string) {
return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
}
function toRoman(n: number): string {
const map: [number, string][] = [
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];
let out = '';
for (const [val, sym] of map) {
while (n >= val) { out += sym; n -= val; }
}
return out;
}
// 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);
async function handleDelete(slug: string, title: string) {
if (deleting) return;
if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) 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 delete: ${e instanceof Error ? e.message : 'unknown error'}`);
window.alert(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`);
} finally {
setDeleting(null);
}
}
if (posts.length === 0) {
return (
<div className="glass p-8 md:p-12 text-center text-sm md:text-base text-subtext1">
No posts yet.
</div>
);
return null;
}
return (
<div className="flex flex-col space-y-6">
{posts.map(post => {
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16">
{posts.map((post, idx) => {
const displayTitle = post.title || formatSlug(post.slug);
const isDeleting = deleting === post.slug;
const layout = LAYOUT_CYCLE[idx % LAYOUT_CYCLE.length];
const exhibitNumber = toRoman(idx + 1);
const hasCover = !!post.cover_image?.url;
return (
<article
key={post.slug}
className={`glass p-5 md:p-8 transition-all hover:bg-surface0/80 group relative ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
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">
<div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-6">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
<time dateTime={post.date}>{formatDate(post.date)}</time>
<span className="opacity-50">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<>
<span className="opacity-50">·</span>
<span className="text-peach uppercase tracking-wide font-semibold">
Draft
</span>
</>
)}
</div>
<h2 className="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2 pr-20">
{displayTitle}
</h2>
<p className="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
{post.excerpt || `Read more about ${displayTitle}...`}
</p>
</div>
<div className="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end md:self-auto shrink-0 hidden md:block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<a
href={`/posts/${encodeURIComponent(post.slug)}`}
className="block plate group"
style={{ transform: `rotate(${layout.tilt}deg)` }}
aria-label={`View ${displayTitle}`}
>
<span className="plate-tag"> {exhibitNumber}</span>
<div
className="plate-image"
style={{ 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))`,
}}
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
<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="min-w-0">
<div className="plate-caption-title truncate">{displayTitle}</div>
{post.summary && (
<div className="mt-1 text-xs text-[var(--subtext0)] font-sans italic line-clamp-1">
{post.summary}
</div>
)}
</div>
<div className="plate-caption-meta">
<span>{formatMonth(post.date)}</span>
<span className="opacity-50 mx-1">·</span>
<span>{formatYear(post.date)}</span>
</div>
</div>
</a>
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{post.tags.map(tag => (
<span
key={tag}
className="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1"
>
<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>
))}
@@ -126,54 +177,27 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
)}
{isAdmin && (
<div className="absolute top-4 right-4 md:top-5 md:right-5 flex items-center gap-1.5">
<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 post"
title="Edit"
aria-label={`Edit ${displayTitle}`}
className="p-1.5 rounded-md bg-surface0/80 hover:bg-blue/20 text-subtext0 hover:text-blue border border-surface1 transition-colors"
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>
<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 post"
aria-label={`Delete ${displayTitle}`}
className="p-1.5 rounded-md bg-surface0/80 hover:bg-red/20 text-subtext0 hover:text-red border border-surface1 transition-colors disabled:opacity-50"
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>
<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>
)}