From 38f33cacb19af24ae6c63e34f7dec1e85fc55b03 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 14 May 2026 08:24:41 +0200 Subject: [PATCH 01/82] init elas atelier --- README.md | 56 +- backend/src/handlers/posts.rs | 72 +- backend/src/models.rs | 25 +- data/posts/another-post.md | 24 +- data/posts/hello-world.md | 17 +- frontend/package-lock.json | 30 + frontend/package.json | 3 + frontend/src/components/react/PostList.tsx | 220 ++--- .../src/components/react/ThemeSwitcher.tsx | 12 +- .../src/components/react/admin/Editor.tsx | 28 +- frontend/src/components/react/admin/Login.tsx | 32 +- frontend/src/layouts/AdminLayout.astro | 13 +- frontend/src/layouts/Layout.astro | 244 ++--- frontend/src/lib/markdown.ts | 30 +- frontend/src/pages/404.astro | 32 +- frontend/src/pages/feed.xml.ts | 2 +- frontend/src/pages/index.astro | 210 ++--- frontend/src/pages/posts/[slug].astro | 210 +++-- frontend/src/styles/global.css | 833 ++++++++++++++---- 19 files changed, 1436 insertions(+), 657 deletions(-) diff --git a/README.md b/README.md index c7f3968..fdd8174 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Narlblog +# Ela's Atelier -A single-author blog. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a glass-effect Catppuccin theme. KaTeX math, GFM tables, server-side syntax highlighting, draft posts, RSS feed. +A single-curator art portfolio. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a parchment "Salon Hang" aesthetic. Each entry is a markdown post that must contain at least one image; the first image becomes the cover plate in the catalogue. ``` backend/ Rust + Axum API (filesystem-backed) @@ -28,15 +28,15 @@ sudo chown -R 1000:1000 data/ ## Environment -| Variable | Required | Default | Notes | -| ----------------- | -------- | ----------------- | --------------------------------------------------------------------------- | -| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | -| `PORT` | no | `3000` | Backend port. | -| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | -| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | -| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | -| `RUST_LOG` | no | `info` | tracing-subscriber filter. | -| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | +| Variable | Required | Default | Notes | +| ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- | +| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | +| `PORT` | no | `3000` | Backend port. | +| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | +| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | +| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | +| `RUST_LOG` | no | `info` | tracing-subscriber filter. | +| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | ## Local development @@ -58,27 +58,31 @@ npm run dev For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`. -## Authoring posts +## Authoring a work -Posts are markdown files at `data/posts/.md` with YAML frontmatter: +Each work is a markdown file at `data/posts/.md` with YAML frontmatter. Every work **must** contain at least one markdown image — the first image becomes the cover plate on the gallery index. Image alt text becomes the figure caption on the work page. ```markdown --- date: 2026-05-09 -summary: Optional summary; falls back to auto-extracted excerpt. +summary: Optional short caption shown beneath the plate on the index. tags: - - rust - - astro + - oil + - 2026 draft: false --- -# My post +# Untitled (charcoal on paper) -Body in **markdown**, with $LaTeX$ via `$…$` / `$$…$$`, GFM tables, and fenced code blocks (```rust, ```ts, …). +![A view of the cliff at dawn](/uploads/cliff-dawn.jpg "Plate I — graphite, A3") + +Notes on the piece: materials, references, what worked, what didn't. + +![Detail of the foreground](/uploads/cliff-detail.jpg "Plate II — detail") ``` -Posts with `draft: true` are hidden from the public list and 404 when accessed by anyone without an admin session. Posts are sorted by `date` descending on the frontpage. - -The web editor at `/admin/editor` writes the same format and updates atomically (write to `.tmp`, rename over target). +- `draft: true` hides a work from the public catalogue and 404s for non-curators. +- Works are sorted by `date` descending on the index. +- The web editor at `/admin/editor` writes the same format and updates atomically. ## Uploads @@ -86,12 +90,18 @@ Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, Max upload size: 50 MB. +## Theme + +The default theme is **Salon** — aged parchment, oxblood ink, Fraunces/EB Garamond/Caveat typography. A **Salon Noir** variant (black gallery wall) is available via the theme switcher in the header. + +Influences: Friedrich, Goya, Kahlo, Tillmans, Basquiat, Sherman, Matisse, Dix, Abramović. + ## Backups -The deployed `data/` directory is the entire blog. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in. +The deployed `data/` directory is the entire gallery. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in. ```sh -rsync -av data/ backup-host:/path/to/narlblog-data/ +rsync -av data/ backup-host:/path/to/gallery-data/ ``` ## Stack diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index 95b2539..0a090dd 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -11,7 +11,7 @@ use crate::{ AppState, auth::is_authed, error::AppError, - models::{CreatePostRequest, PostDetail, PostInfo, PostMeta}, + models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta}, }; const WORDS_PER_MINUTE: u32 = 200; @@ -114,6 +114,59 @@ fn reading_time(body: &str) -> u32 { (words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1) } +/// Scan markdown for `![alt](url)` images. Returns (alt, url) pairs in order. +/// Skips inside fenced code blocks. Tolerates titles like `![alt](url "title")`. +fn extract_images(body: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut in_fence = false; + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + let bytes = line.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'!' && bytes[i + 1] == b'[' { + if let Some(rel_close) = line[i + 2..].find(']') { + let close = i + 2 + rel_close; + if close + 1 < line.len() && bytes[close + 1] == b'(' { + if let Some(rel_paren) = line[close + 2..].find(')') { + let paren_end = close + 2 + rel_paren; + let alt = line[i + 2..close].to_string(); + let url_field = line[close + 2..paren_end].trim(); + let url = url_field + .split_once(|c: char| c.is_whitespace()) + .map(|(u, _)| u) + .unwrap_or(url_field) + .trim_matches(|c| c == '<' || c == '>') + .to_string(); + if !url.is_empty() { + out.push((alt, url)); + } + i = paren_end + 1; + continue; + } + } + } + } + i += 1; + } + } + out +} + +fn cover_from(images: &[(String, String)]) -> Option { + images.first().map(|(alt, url)| CoverImage { + url: url.clone(), + alt: alt.clone(), + }) +} + fn excerpt_from(meta: &PostMeta, body: &str) -> String { if let Some(s) = meta.summary.as_ref() { if !s.trim().is_empty() { @@ -191,6 +244,13 @@ pub async fn create_post( } } + let images = extract_images(&payload.content); + if images.is_empty() { + return Err(AppError::BadRequest( + "A gallery entry must include at least one image (![](url) in the markdown body).".to_string(), + )); + } + let meta = PostMeta { date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()), @@ -202,6 +262,8 @@ pub async fn create_post( write_post_atomic(&state, &slug, &contents).await?; info!("Post saved: {}", slug); + let image_count = images.len() as u32; + let cover = cover_from(&images); Ok(Json(PostDetail { slug, date: meta.date, @@ -211,6 +273,8 @@ pub async fn create_post( draft: meta.draft, reading_time: reading_time(&payload.content), content: payload.content, + cover_image: cover, + image_count, })) } @@ -271,6 +335,7 @@ pub async fn list_posts( if meta.draft && !admin { continue; } + let images = extract_images(&body); posts.push(PostInfo { slug: slug.to_string(), date: meta.date, @@ -280,6 +345,8 @@ pub async fn list_posts( draft: meta.draft, reading_time: reading_time(&body), excerpt: excerpt_from(&meta, &body), + cover_image: cover_from(&images), + image_count: images.len() as u32, }); } } @@ -305,6 +372,7 @@ pub async fn get_post( return Err(AppError::NotFound("Post not found".to_string())); } + let images = extract_images(&body); Ok(Json(PostDetail { slug, date: meta.date, @@ -314,5 +382,7 @@ pub async fn get_post( draft: meta.draft, reading_time: reading_time(&body), content: body, + cover_image: cover_from(&images), + image_count: images.len() as u32, })) } diff --git a/backend/src/models.rs b/backend/src/models.rs index df201c0..5dc2a51 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -16,14 +16,15 @@ pub struct SiteConfig { impl Default for SiteConfig { fn default() -> Self { Self { - title: "Narlblog".to_string(), - subtitle: "A clean, modern blog".to_string(), - welcome_title: "Welcome to my blog".to_string(), + title: "Ela's Atelier".to_string(), + subtitle: "Works on paper, canvas, and elsewhere".to_string(), + welcome_title: "Works on view".to_string(), welcome_subtitle: - "Thoughts on software, design, and building things with Rust and Astro.".to_string(), - footer: "Built with Rust & Astro".to_string(), + "An ongoing arrangement of pieces, sketches, and stray observations." + .to_string(), + footer: "Hand-arranged with care".to_string(), favicon: "/favicon.svg".to_string(), - theme: "mocha".to_string(), + theme: "salon".to_string(), custom_css: "".to_string(), } } @@ -62,6 +63,12 @@ pub struct PostMeta { pub draft: bool, } +#[derive(Serialize, Clone)] +pub struct CoverImage { + pub url: String, + pub alt: String, +} + #[derive(Serialize)] pub struct PostInfo { pub slug: String, @@ -74,6 +81,9 @@ pub struct PostInfo { pub draft: bool, pub reading_time: u32, pub excerpt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + pub image_count: u32, } #[derive(Serialize)] @@ -88,6 +98,9 @@ pub struct PostDetail { pub draft: bool, pub reading_time: u32, pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + pub image_count: u32, } #[derive(Deserialize)] diff --git a/data/posts/another-post.md b/data/posts/another-post.md index 7ef0f07..7cc40b9 100644 --- a/data/posts/another-post.md +++ b/data/posts/another-post.md @@ -1,26 +1,14 @@ --- date: 2026-05-09 -summary: Markdown smoke test — bold, italic, links, blockquotes, fenced code. +summary: A second placeholder — layout smoke test. tags: - - meta + - intro draft: false --- -# My Second Blog Post +# Second placeholder -Adding some more content to test the layout and the glassy look! +A second placeholder so the salon-hang layout has room to breathe with more than one plate. Remove or replace from `/admin`. -### Markdown Testing +![Placeholder plate](/uploads/placeholder.jpg "replace me") -- **Bold text** -- *Italic text* -- [A link to GitHub](https://github.com) - -> "The only way to do great work is to love what you do." - Steve Jobs - -```rust -fn main() { - println!("Hello from Rust!"); -} -``` - -Enjoy reading! +> "The painter constructs, the photographer discloses." — Susan Sontag diff --git a/data/posts/hello-world.md b/data/posts/hello-world.md index 0b04412..f6b830e 100644 --- a/data/posts/hello-world.md +++ b/data/posts/hello-world.md @@ -1,21 +1,14 @@ --- date: 2026-05-09 -summary: First post — modern stack, glassy aesthetic, Catppuccin theme. +summary: Opening note for the gallery — what's on the walls, why these pieces. tags: - - meta - intro draft: false --- -# Welcome to Narlblog +# Welcome to the gallery -This is my very first blog post! Built with a modern, glassy aesthetic and the beautiful Catppuccin color palette. +This room collects work made on paper, canvas, and elsewhere — finished pieces alongside the studies that didn't make it. -## Technical Stack +![Placeholder plate](/uploads/placeholder.jpg "replace with a real plate from /admin/assets") -The blog is powered by: -- **Rust (Axum)**: Fast and reliable backend. -- **Astro**: Modern frontend framework for performance. -- **Tailwind CSS**: Glassy UI with Catppuccin theme. -- **Docker**: Simple containerized deployment. - -Feel free to explore and add your own posts by creating `.md` files in the `data/posts` directory! +Replace this entry with your first real work, or remove it from the catalogue via the admin dashboard. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 397fac3..b87141f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,8 +17,11 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@fontsource-variable/eb-garamond": "^5.2.7", + "@fontsource-variable/fraunces": "^5.2.9", "@fontsource-variable/inter": "^5.2.5", "@fontsource-variable/jetbrains-mono": "^5.2.5", + "@fontsource/caveat": "^5.2.8", "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", @@ -1684,6 +1687,24 @@ } } }, + "node_modules/@fontsource-variable/eb-garamond": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource-variable/eb-garamond/-/eb-garamond-5.2.7.tgz", + "integrity": "sha512-TbR+Pun4k5g85lgdxRNSbWka5vJgi9pzdFzFswgf7/7NRhKssvoumft1+ZGz3n91YYet96JcnjWrgPGGj6JTzg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/fraunces": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.2.9.tgz", + "integrity": "sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource-variable/inter": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", @@ -1702,6 +1723,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/caveat": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.2.8.tgz", + "integrity": "sha512-9fUUfFE2IQFKbx+xOcaeQxxmh8iJguEb8z+j1PeueO4UUx+XfT4pRm/B04ZDvFA794/iRxY/IibmP8ZKtIf4rw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1cbdc57..67a88f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,8 +21,11 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@fontsource-variable/eb-garamond": "^5.2.7", + "@fontsource-variable/fraunces": "^5.2.9", "@fontsource-variable/inter": "^5.2.5", "@fontsource-variable/jetbrains-mono": "^5.2.5", + "@fontsource/caveat": "^5.2.8", "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", diff --git a/frontend/src/components/react/PostList.tsx b/frontend/src/components/react/PostList.tsx index 580735d..100491b 100644 --- a/frontend/src/components/react/PostList.tsx +++ b/frontend/src/components/react/PostList.tsx @@ -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(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 ( -
- No posts yet. -
- ); + return null; } return ( -
- {posts.map(post => { +
+ {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 (
- -
-
-
- - · - {post.reading_time} min read - {post.draft && ( - <> - · - - Draft - - - )} -
-

- {displayTitle} -

-

- {post.excerpt || `Read more about ${displayTitle}...`} -

-
-
- + № {exhibitNumber} + +
+ {hasCover ? ( + {post.cover_image!.alt + ) : ( +
- - - + + untitled + +
+ )} + {post.image_count > 1 && ( + + {post.image_count} plates + + )} + {post.draft && ( + + Sketch + + )} +
+ +
+
+
{displayTitle}
+ {post.summary && ( +
+ {post.summary} +
+ )} +
+
+ {formatMonth(post.date)} + · + {formatYear(post.date)}
{post.tags && post.tags.length > 0 && ( -
- {post.tags.map(tag => ( - +
+ {post.tags.slice(0, 4).map(tag => ( + {tag} ))} @@ -126,54 +177,27 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props )} {isAdmin && ( -
+
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 }} > - - - - +
)} diff --git a/frontend/src/components/react/ThemeSwitcher.tsx b/frontend/src/components/react/ThemeSwitcher.tsx index 5b39de2..be34a2c 100644 --- a/frontend/src/components/react/ThemeSwitcher.tsx +++ b/frontend/src/components/react/ThemeSwitcher.tsx @@ -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 => ( ))} -
+
{toast &&
{toast}
} diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 7b65b02..50b8b21 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -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 )} - {originalSlug && ( - View Post + View work )}
@@ -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" />
@@ -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" />
@@ -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" />
@@ -387,7 +391,7 @@ export default function Editor({ editSlug }: Props) { {/* Editor Toolbar */}
- +
diff --git a/frontend/src/layouts/AdminLayout.astro b/frontend/src/layouts/AdminLayout.astro index 05a19b8..f1b235e 100644 --- a/frontend/src/layouts/AdminLayout.astro +++ b/frontend/src/layouts/AdminLayout.astro @@ -14,14 +14,15 @@ if (Astro.cookies.get('admin_session')?.value !== '1') { --- -
-
+
+
- - - Back to site + + + Back to the catalogue -

+
Curator's desk
+

{title}

diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro index 6b73b3d..dfed768 100644 --- a/frontend/src/layouts/Layout.astro +++ b/frontend/src/layouts/Layout.astro @@ -2,7 +2,9 @@ import '../styles/global.css'; import 'katex/dist/katex.min.css'; import 'highlight.js/styles/atom-one-dark.css'; -import '@fontsource-variable/inter'; +import '@fontsource-variable/fraunces'; +import '@fontsource-variable/eb-garamond'; +import '@fontsource/caveat'; import '@fontsource-variable/jetbrains-mono'; import ThemeSwitcher from '../components/react/ThemeSwitcher'; import Search from '../components/react/Search'; @@ -23,136 +25,138 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000'; const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; let siteConfig = { - title: "Narlblog", - subtitle: "A clean, modern blog", - footer: "Built with Rust & Astro", - favicon: "/favicon.svg", - theme: "mocha", - custom_css: "" + title: "Ela's Atelier", + subtitle: "Works on paper, canvas, and elsewhere", + footer: "Hand-arranged with care", + favicon: "/favicon.svg", + theme: "salon", + custom_css: "" }; try { - const res = await fetch(`${API_URL}/api/config`); - if (res.ok) { - siteConfig = await res.json(); - } + const res = await fetch(`${API_URL}/api/config`); + if (res.ok) { + siteConfig = await res.json(); + } } catch (e) { - console.error("Failed to fetch config:", e); + console.error("Failed to fetch config:", e); } -const fullTitle = `${title} | ${siteConfig.title}`; +const fullTitle = `${title} · ${siteConfig.title}`; +const year = new Date().getFullYear(); --- - - - - - - {fullTitle} - {description && } - - {description && } - - - - {image && } - - - {description && } - {image && } - - {siteConfig.custom_css &&