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>
)}
@@ -1,11 +1,10 @@
import { useState, useEffect, useRef } from 'react';
const THEMES = [
{ value: 'mocha', label: 'Mocha' },
{ value: 'macchiato', label: 'Macchiato' },
{ value: 'frappe', label: 'Frappe' },
{ value: 'salon', label: 'Salon' },
{ value: 'salon-noir', label: 'Salon Noir' },
{ value: 'latte', label: 'Latte' },
{ value: 'scaled-and-icy', label: 'Scaled and Icy' },
{ value: 'mocha', label: 'Mocha' },
];
interface Props {
@@ -44,13 +43,14 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
value={theme}
onChange={(e) => setTheme(e.target.value)}
aria-label="Theme"
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-colors cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
className="appearance-none bg-[var(--surface0)]/60 text-[var(--text)] border border-[var(--surface2)] px-3 py-1.5 text-xs uppercase tracking-[0.18em] focus:outline-none focus:border-[var(--mauve)] transition-colors cursor-pointer hover:bg-[var(--surface0)] pr-8 font-display italic"
style={{ borderRadius: 1 }}
>
{THEMES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-subtext0">
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-[var(--subtext0)]">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
{toast && <div className="toast" role="status">{toast}</div>}
+16 -12
View File
@@ -16,7 +16,7 @@ interface Props {
editSlug?: string;
}
const narlblogTheme = EditorView.theme({
const salonTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--crust)',
color: 'var(--text)',
@@ -121,8 +121,8 @@ export default function Editor({ editSlug }: Props) {
closeBrackets(),
markdown({ base: markdownLanguage, codeLanguages: languages }),
EditorView.lineWrapping,
narlblogTheme,
cmPlaceholder('# Hello World\nWrite your markdown here...'),
salonTheme,
cmPlaceholder('# A title for the work\n\n![alt text](/uploads/your-image.jpg "optional caption")\n\nNotes, context, materials...'),
EditorView.updateListener.of(update => {
if (!update.docChanged) return;
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
@@ -229,7 +229,11 @@ export default function Editor({ editSlug }: Props) {
async function handleSave() {
const content = viewRef.current?.state.doc.toString() || '';
if (!title.trim() || !slug || !content) {
showAlertMsg('Title, slug, and content are required.', 'error');
showAlertMsg('Title, slug, and body are required.', 'error');
return;
}
if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) {
showAlertMsg('A gallery work must include at least one image — insert via the Assets panel or type "!".', 'error');
return;
}
const tags = tagsInput
@@ -260,7 +264,7 @@ export default function Editor({ editSlug }: Props) {
async function handleDelete() {
const target = originalSlug || slug;
if (!confirm(`Delete post "${target}" permanently?`)) return;
if (!confirm(`Remove work "${target}" from the catalogue permanently?`)) return;
try {
await deletePost(target);
window.location.href = '/admin';
@@ -293,8 +297,8 @@ export default function Editor({ editSlug }: Props) {
Delete
</button>
)}
<button onClick={handleSave} className="bg-mauve text-crust font-bold py-3 px-8 rounded-lg hover:bg-pink transition-all transform hover:scale-105 whitespace-nowrap">
Save Post
<button onClick={handleSave} className="bg-mauve text-rosewater font-bold py-3 px-8 rounded-lg hover:bg-red transition-all transform hover:scale-105 whitespace-nowrap">
Save work
</button>
{originalSlug && (
<a
@@ -304,7 +308,7 @@ export default function Editor({ editSlug }: Props) {
className="bg-blue text-crust font-bold py-3 px-8 rounded-lg hover:bg-sky transition-all transform hover:scale-105 whitespace-nowrap inline-flex items-center justify-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
View Post
View work
</a>
)}
</div>
@@ -319,7 +323,7 @@ export default function Editor({ editSlug }: Props) {
value={title}
onChange={e => setTitle(e.target.value)}
required
placeholder="My Awesome Post"
placeholder="Untitled (charcoal on paper)"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
/>
</div>
@@ -357,7 +361,7 @@ export default function Editor({ editSlug }: Props) {
type="text"
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
placeholder="rust, astro, design"
placeholder="oil, paper, 2026, study"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
/>
</div>
@@ -379,7 +383,7 @@ export default function Editor({ editSlug }: Props) {
value={summary}
onChange={e => setSummary(e.target.value)}
rows={2}
placeholder="A brief description of this post for the frontpage..."
placeholder="A short caption for the catalogue index..."
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors resize-none"
/>
</div>
@@ -387,7 +391,7 @@ export default function Editor({ editSlug }: Props) {
{/* Editor Toolbar */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse assets</label>
<label className="block text-sm font-medium text-subtext1 italic">Type '/' or '!' to insert an image · at least one image is required</label>
</div>
<div className="flex items-center gap-2">
<button
+21 -11
View File
@@ -20,22 +20,32 @@ export default function Login() {
window.location.href = '/admin';
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
setError('Invalid token.');
setError('That key does not open this door.');
} else {
setError('Login failed. Try again.');
setError('Could not reach the door. Try again.');
}
setBusy(false);
}
}
return (
<div className="max-w-md mx-auto mt-20">
<div className="glass p-12">
<h1 className="text-3xl font-bold mb-6 text-mauve">Admin Login</h1>
<p className="text-subtext0 mb-8">Enter your admin token to access the dashboard.</p>
<div className="max-w-md mx-auto mt-16">
<div className="glass p-10">
<div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3 text-center">
Curator's entrance
</div>
<h1 className="font-display italic text-3xl md:text-4xl font-semibold text-[var(--text)] mb-2 text-center leading-tight">
Sign in
</h1>
<div className="section-rule max-w-[180px] mx-auto mb-6">
<span className="ornament">✦</span>
</div>
<p className="text-[var(--subtext1)] mb-8 text-center font-display italic">
Present your token to enter the back room.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="token" className="block text-sm font-medium text-subtext1 mb-2">Admin Token</label>
<label htmlFor="token" className="field-label">Admin token</label>
<input
type="password"
id="token"
@@ -43,21 +53,21 @@ export default function Login() {
value={value}
onChange={e => setValue(e.target.value)}
autoComplete="current-password"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
className="field-input font-mono tracking-widest"
placeholder="••••••••••••"
/>
</div>
{error && (
<p className="text-sm text-red bg-red/10 border border-red/20 rounded-lg px-3 py-2">
<p className="text-sm text-[var(--red)] bg-[var(--red)]/10 border border-[var(--red)]/30 px-3 py-2 font-display italic">
{error}
</p>
)}
<button
type="submit"
disabled={busy}
className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="btn-stamp w-full justify-center disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? 'Logging in...' : 'Login'}
{busy ? 'Unlocking' : 'Enter'}
</button>
</form>
</div>