init elas atelier
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user