init elas atelier
This commit is contained in:
@@ -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)
|
backend/ Rust + Axum API (filesystem-backed)
|
||||||
@@ -29,7 +29,7 @@ sudo chown -R 1000:1000 data/
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
| Variable | Required | Default | Notes |
|
| Variable | Required | Default | Notes |
|
||||||
| ----------------- | -------- | ----------------- | --------------------------------------------------------------------------- |
|
| ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- |
|
||||||
| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. |
|
| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. |
|
||||||
| `PORT` | no | `3000` | Backend port. |
|
| `PORT` | no | `3000` | Backend port. |
|
||||||
| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. |
|
| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. |
|
||||||
@@ -58,27 +58,31 @@ npm run dev
|
|||||||
|
|
||||||
For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`.
|
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/<slug>.md` with YAML frontmatter:
|
Each work is a markdown file at `data/posts/<slug>.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
|
```markdown
|
||||||
---
|
---
|
||||||
date: 2026-05-09
|
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:
|
tags:
|
||||||
- rust
|
- oil
|
||||||
- astro
|
- 2026
|
||||||
draft: false
|
draft: false
|
||||||
---
|
---
|
||||||
# My post
|
# Untitled (charcoal on paper)
|
||||||
|
|
||||||
Body in **markdown**, with $LaTeX$ via `$…$` / `$$…$$`, GFM tables, and fenced code blocks (```rust, ```ts, …).
|

|
||||||
|
|
||||||
|
Notes on the piece: materials, references, what worked, what didn't.
|
||||||
|
|
||||||
|

|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
- `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 (write to `.tmp`, rename over target).
|
- The web editor at `/admin/editor` writes the same format and updates atomically.
|
||||||
|
|
||||||
## Uploads
|
## Uploads
|
||||||
|
|
||||||
@@ -86,12 +90,18 @@ Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav,
|
|||||||
|
|
||||||
Max upload size: 50 MB.
|
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
|
## 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
|
```sh
|
||||||
rsync -av data/ backup-host:/path/to/narlblog-data/
|
rsync -av data/ backup-host:/path/to/gallery-data/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
auth::is_authed,
|
auth::is_authed,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
|
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta},
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORDS_PER_MINUTE: u32 = 200;
|
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)
|
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scan markdown for `` images. Returns (alt, url) pairs in order.
|
||||||
|
/// Skips inside fenced code blocks. Tolerates titles like ``.
|
||||||
|
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<CoverImage> {
|
||||||
|
images.first().map(|(alt, url)| CoverImage {
|
||||||
|
url: url.clone(),
|
||||||
|
alt: alt.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
||||||
if let Some(s) = meta.summary.as_ref() {
|
if let Some(s) = meta.summary.as_ref() {
|
||||||
if !s.trim().is_empty() {
|
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 ( in the markdown body).".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let meta = PostMeta {
|
let meta = PostMeta {
|
||||||
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
|
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
|
||||||
title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
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?;
|
write_post_atomic(&state, &slug, &contents).await?;
|
||||||
|
|
||||||
info!("Post saved: {}", slug);
|
info!("Post saved: {}", slug);
|
||||||
|
let image_count = images.len() as u32;
|
||||||
|
let cover = cover_from(&images);
|
||||||
Ok(Json(PostDetail {
|
Ok(Json(PostDetail {
|
||||||
slug,
|
slug,
|
||||||
date: meta.date,
|
date: meta.date,
|
||||||
@@ -211,6 +273,8 @@ pub async fn create_post(
|
|||||||
draft: meta.draft,
|
draft: meta.draft,
|
||||||
reading_time: reading_time(&payload.content),
|
reading_time: reading_time(&payload.content),
|
||||||
content: payload.content,
|
content: payload.content,
|
||||||
|
cover_image: cover,
|
||||||
|
image_count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +335,7 @@ pub async fn list_posts(
|
|||||||
if meta.draft && !admin {
|
if meta.draft && !admin {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let images = extract_images(&body);
|
||||||
posts.push(PostInfo {
|
posts.push(PostInfo {
|
||||||
slug: slug.to_string(),
|
slug: slug.to_string(),
|
||||||
date: meta.date,
|
date: meta.date,
|
||||||
@@ -280,6 +345,8 @@ pub async fn list_posts(
|
|||||||
draft: meta.draft,
|
draft: meta.draft,
|
||||||
reading_time: reading_time(&body),
|
reading_time: reading_time(&body),
|
||||||
excerpt: excerpt_from(&meta, &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()));
|
return Err(AppError::NotFound("Post not found".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let images = extract_images(&body);
|
||||||
Ok(Json(PostDetail {
|
Ok(Json(PostDetail {
|
||||||
slug,
|
slug,
|
||||||
date: meta.date,
|
date: meta.date,
|
||||||
@@ -314,5 +382,7 @@ pub async fn get_post(
|
|||||||
draft: meta.draft,
|
draft: meta.draft,
|
||||||
reading_time: reading_time(&body),
|
reading_time: reading_time(&body),
|
||||||
content: body,
|
content: body,
|
||||||
|
cover_image: cover_from(&images),
|
||||||
|
image_count: images.len() as u32,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-6
@@ -16,14 +16,15 @@ pub struct SiteConfig {
|
|||||||
impl Default for SiteConfig {
|
impl Default for SiteConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: "Narlblog".to_string(),
|
title: "Ela's Atelier".to_string(),
|
||||||
subtitle: "A clean, modern blog".to_string(),
|
subtitle: "Works on paper, canvas, and elsewhere".to_string(),
|
||||||
welcome_title: "Welcome to my blog".to_string(),
|
welcome_title: "Works on view".to_string(),
|
||||||
welcome_subtitle:
|
welcome_subtitle:
|
||||||
"Thoughts on software, design, and building things with Rust and Astro.".to_string(),
|
"An ongoing arrangement of pieces, sketches, and stray observations."
|
||||||
footer: "Built with Rust & Astro".to_string(),
|
.to_string(),
|
||||||
|
footer: "Hand-arranged with care".to_string(),
|
||||||
favicon: "/favicon.svg".to_string(),
|
favicon: "/favicon.svg".to_string(),
|
||||||
theme: "mocha".to_string(),
|
theme: "salon".to_string(),
|
||||||
custom_css: "".to_string(),
|
custom_css: "".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,12 @@ pub struct PostMeta {
|
|||||||
pub draft: bool,
|
pub draft: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct CoverImage {
|
||||||
|
pub url: String,
|
||||||
|
pub alt: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct PostInfo {
|
pub struct PostInfo {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
@@ -74,6 +81,9 @@ pub struct PostInfo {
|
|||||||
pub draft: bool,
|
pub draft: bool,
|
||||||
pub reading_time: u32,
|
pub reading_time: u32,
|
||||||
pub excerpt: String,
|
pub excerpt: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cover_image: Option<CoverImage>,
|
||||||
|
pub image_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -88,6 +98,9 @@ pub struct PostDetail {
|
|||||||
pub draft: bool,
|
pub draft: bool,
|
||||||
pub reading_time: u32,
|
pub reading_time: u32,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cover_image: Option<CoverImage>,
|
||||||
|
pub image_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
---
|
---
|
||||||
date: 2026-05-09
|
date: 2026-05-09
|
||||||
summary: Markdown smoke test — bold, italic, links, blockquotes, fenced code.
|
summary: A second placeholder — layout smoke test.
|
||||||
tags:
|
tags:
|
||||||
- meta
|
- intro
|
||||||
draft: false
|
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
|

|
||||||
|
|
||||||
- **Bold text**
|
> "The painter constructs, the photographer discloses." — Susan Sontag
|
||||||
- *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!
|
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
---
|
---
|
||||||
date: 2026-05-09
|
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:
|
tags:
|
||||||
- meta
|
|
||||||
- intro
|
- intro
|
||||||
draft: false
|
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
|

|
||||||
|
|
||||||
The blog is powered by:
|
Replace this entry with your first real work, or remove it from the catalogue via the admin dashboard.
|
||||||
- **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!
|
|
||||||
|
|||||||
Generated
+30
@@ -17,8 +17,11 @@
|
|||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.40.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/inter": "^5.2.5",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
||||||
|
"@fontsource/caveat": "^5.2.8",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"astro": "^6.0.8",
|
"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": {
|
"node_modules/@fontsource-variable/inter": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||||
@@ -1702,6 +1723,15 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
|||||||
@@ -21,8 +21,11 @@
|
|||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.40.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/inter": "^5.2.5",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
||||||
|
"@fontsource/caveat": "^5.2.8",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"astro": "^6.0.8",
|
"astro": "^6.0.8",
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { deletePost } from '../../lib/api';
|
import { deletePost } from '../../lib/api';
|
||||||
|
|
||||||
|
interface CoverImage {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
slug: string;
|
slug: string;
|
||||||
date: string;
|
date: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
|
summary?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
reading_time: number;
|
reading_time: number;
|
||||||
|
cover_image?: CoverImage;
|
||||||
|
image_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,101 +32,144 @@ function formatSlug(slug: string) {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: string) {
|
function formatYear(date: string) {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
return new Date(date).getFullYear();
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export default function PostList({ posts: initialPosts, isAdmin = false }: Props) {
|
||||||
const [posts, setPosts] = useState(initialPosts);
|
const [posts, setPosts] = useState(initialPosts);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleDelete(slug: string, title: string) {
|
async function handleDelete(slug: string, title: string) {
|
||||||
if (deleting) return;
|
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);
|
setDeleting(slug);
|
||||||
try {
|
try {
|
||||||
await deletePost(slug);
|
await deletePost(slug);
|
||||||
setPosts(p => p.filter(x => x.slug !== slug));
|
setPosts(p => p.filter(x => x.slug !== slug));
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
return (
|
return null;
|
||||||
<div className="glass p-8 md:p-12 text-center text-sm md:text-base text-subtext1">
|
|
||||||
No posts yet.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-6">
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16">
|
||||||
{posts.map(post => {
|
{posts.map((post, idx) => {
|
||||||
const displayTitle = post.title || formatSlug(post.slug);
|
const displayTitle = post.title || formatSlug(post.slug);
|
||||||
const isDeleting = deleting === 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 (
|
return (
|
||||||
<article
|
<article
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
className={`glass p-5 md:p-8 transition-all hover:bg-surface0/80 group relative ${
|
className={`relative plate-enter md-col-span ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
|
||||||
isDeleting ? 'opacity-50 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">
|
<a
|
||||||
<div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-6">
|
href={`/posts/${encodeURIComponent(post.slug)}`}
|
||||||
<div className="flex-1 min-w-0">
|
className="block plate group"
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
|
style={{ transform: `rotate(${layout.tilt}deg)` }}
|
||||||
<time dateTime={post.date}>{formatDate(post.date)}</time>
|
aria-label={`View ${displayTitle}`}
|
||||||
<span className="opacity-50">·</span>
|
>
|
||||||
<span>{post.reading_time} min read</span>
|
<span className="plate-tag">№ {exhibitNumber}</span>
|
||||||
{post.draft && (
|
|
||||||
<>
|
<div
|
||||||
<span className="opacity-50">·</span>
|
className="plate-image"
|
||||||
<span className="text-peach uppercase tracking-wide font-semibold">
|
style={{ aspectRatio: layout.aspect }}
|
||||||
Draft
|
>
|
||||||
|
{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>
|
</span>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2 pr-20">
|
|
||||||
{displayTitle}
|
<div className="plate-caption">
|
||||||
</h2>
|
<div className="min-w-0">
|
||||||
<p className="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
|
<div className="plate-caption-title truncate">{displayTitle}</div>
|
||||||
{post.excerpt || `Read more about ${displayTitle}...`}
|
{post.summary && (
|
||||||
</p>
|
<div className="mt-1 text-xs text-[var(--subtext0)] font-sans italic line-clamp-1">
|
||||||
|
{post.summary}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end md:self-auto shrink-0 hidden md:block">
|
)}
|
||||||
<svg
|
</div>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div className="plate-caption-meta">
|
||||||
width="20"
|
<span>{formatMonth(post.date)}</span>
|
||||||
height="20"
|
<span className="opacity-50 mx-1">·</span>
|
||||||
viewBox="0 0 24 24"
|
<span>{formatYear(post.date)}</span>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M5 12h14" />
|
|
||||||
<path d="m12 5 7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{post.tags && post.tags.length > 0 && (
|
{post.tags && post.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
|
||||||
{post.tags.map(tag => (
|
{post.tags.slice(0, 4).map(tag => (
|
||||||
<span
|
<span key={tag} className="chip">
|
||||||
key={tag}
|
|
||||||
className="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -126,54 +177,27 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{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
|
<a
|
||||||
href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
|
href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
title="Edit post"
|
title="Edit"
|
||||||
aria-label={`Edit ${displayTitle}`}
|
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
|
<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>
|
||||||
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>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDelete(post.slug, displayTitle); }}
|
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDelete(post.slug, displayTitle); }}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
title="Delete post"
|
title="Remove"
|
||||||
aria-label={`Delete ${displayTitle}`}
|
aria-label={`Remove ${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"
|
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
|
<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>
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ value: 'mocha', label: 'Mocha' },
|
{ value: 'salon', label: 'Salon' },
|
||||||
{ value: 'macchiato', label: 'Macchiato' },
|
{ value: 'salon-noir', label: 'Salon Noir' },
|
||||||
{ value: 'frappe', label: 'Frappe' },
|
|
||||||
{ value: 'latte', label: 'Latte' },
|
{ value: 'latte', label: 'Latte' },
|
||||||
{ value: 'scaled-and-icy', label: 'Scaled and Icy' },
|
{ value: 'mocha', label: 'Mocha' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,13 +43,14 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
|
|||||||
value={theme}
|
value={theme}
|
||||||
onChange={(e) => setTheme(e.target.value)}
|
onChange={(e) => setTheme(e.target.value)}
|
||||||
aria-label="Theme"
|
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 => (
|
{THEMES.map(t => (
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
<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>
|
</div>
|
||||||
{toast && <div className="toast" role="status">{toast}</div>}
|
{toast && <div className="toast" role="status">{toast}</div>}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
editSlug?: string;
|
editSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const narlblogTheme = EditorView.theme({
|
const salonTheme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
backgroundColor: 'var(--crust)',
|
backgroundColor: 'var(--crust)',
|
||||||
color: 'var(--text)',
|
color: 'var(--text)',
|
||||||
@@ -121,8 +121,8 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
narlblogTheme,
|
salonTheme,
|
||||||
cmPlaceholder('# Hello World\nWrite your markdown here...'),
|
cmPlaceholder('# A title for the work\n\n\n\nNotes, context, materials...'),
|
||||||
EditorView.updateListener.of(update => {
|
EditorView.updateListener.of(update => {
|
||||||
if (!update.docChanged) return;
|
if (!update.docChanged) return;
|
||||||
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||||
@@ -229,7 +229,11 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const content = viewRef.current?.state.doc.toString() || '';
|
const content = viewRef.current?.state.doc.toString() || '';
|
||||||
if (!title.trim() || !slug || !content) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const tags = tagsInput
|
const tags = tagsInput
|
||||||
@@ -260,7 +264,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
const target = originalSlug || slug;
|
const target = originalSlug || slug;
|
||||||
if (!confirm(`Delete post "${target}" permanently?`)) return;
|
if (!confirm(`Remove work "${target}" from the catalogue permanently?`)) return;
|
||||||
try {
|
try {
|
||||||
await deletePost(target);
|
await deletePost(target);
|
||||||
window.location.href = '/admin';
|
window.location.href = '/admin';
|
||||||
@@ -293,8 +297,8 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</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">
|
<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 Post
|
Save work
|
||||||
</button>
|
</button>
|
||||||
{originalSlug && (
|
{originalSlug && (
|
||||||
<a
|
<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"
|
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>
|
<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>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +323,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
required
|
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"
|
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>
|
</div>
|
||||||
@@ -357,7 +361,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={tagsInput}
|
value={tagsInput}
|
||||||
onChange={e => setTagsInput(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
@@ -379,7 +383,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
value={summary}
|
value={summary}
|
||||||
onChange={e => setSummary(e.target.value)}
|
onChange={e => setSummary(e.target.value)}
|
||||||
rows={2}
|
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"
|
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>
|
</div>
|
||||||
@@ -387,7 +391,7 @@ export default function Editor({ editSlug }: Props) {
|
|||||||
{/* Editor Toolbar */}
|
{/* Editor Toolbar */}
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,22 +20,32 @@ export default function Login() {
|
|||||||
window.location.href = '/admin';
|
window.location.href = '/admin';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
setError('Invalid token.');
|
setError('That key does not open this door.');
|
||||||
} else {
|
} else {
|
||||||
setError('Login failed. Try again.');
|
setError('Could not reach the door. Try again.');
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-20">
|
<div className="max-w-md mx-auto mt-16">
|
||||||
<div className="glass p-12">
|
<div className="glass p-10">
|
||||||
<h1 className="text-3xl font-bold mb-6 text-mauve">Admin Login</h1>
|
<div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3 text-center">
|
||||||
<p className="text-subtext0 mb-8">Enter your admin token to access the dashboard.</p>
|
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">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="token"
|
id="token"
|
||||||
@@ -43,21 +53,21 @@ export default function Login() {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
autoComplete="current-password"
|
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="••••••••••••"
|
placeholder="••••••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ if (Astro.cookies.get('admin_session')?.value !== '1') {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={title} wide={wide}>
|
<Layout title={title} wide={wide}>
|
||||||
<div class="space-y-6 md:space-y-10">
|
<div class="space-y-10 md:space-y-14">
|
||||||
<header class="flex flex-col md:flex-row md:items-end justify-between gap-4 pb-6 border-b border-surface1/40">
|
<header class="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--surface2)]/60">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<a href="/" class="inline-flex items-center gap-1.5 text-xs text-subtext0 hover:text-text transition-colors mb-2 group">
|
<a href="/" class="inline-flex items-center gap-1.5 text-xs font-display italic text-[var(--subtext0)] hover:text-[var(--mauve)] transition-colors mb-3 group">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-0.5" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
<span class="transition-transform group-hover:-translate-x-0.5">←</span>
|
||||||
Back to site
|
Back to the catalogue
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl md:text-4xl font-bold text-mauve tracking-tight">
|
<div class="font-display italic text-[var(--mauve)] text-xs tracking-[0.3em] uppercase mb-2">Curator's desk</div>
|
||||||
|
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-5xl tracking-tight leading-[1.05]">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<slot name="header-subtitle" />
|
<slot name="header-subtitle" />
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import 'highlight.js/styles/atom-one-dark.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 '@fontsource-variable/jetbrains-mono';
|
||||||
import ThemeSwitcher from '../components/react/ThemeSwitcher';
|
import ThemeSwitcher from '../components/react/ThemeSwitcher';
|
||||||
import Search from '../components/react/Search';
|
import Search from '../components/react/Search';
|
||||||
@@ -23,11 +25,11 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
|||||||
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||||
|
|
||||||
let siteConfig = {
|
let siteConfig = {
|
||||||
title: "Narlblog",
|
title: "Ela's Atelier",
|
||||||
subtitle: "A clean, modern blog",
|
subtitle: "Works on paper, canvas, and elsewhere",
|
||||||
footer: "Built with Rust & Astro",
|
footer: "Hand-arranged with care",
|
||||||
favicon: "/favicon.svg",
|
favicon: "/favicon.svg",
|
||||||
theme: "mocha",
|
theme: "salon",
|
||||||
custom_css: ""
|
custom_css: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,7 +42,8 @@ try {
|
|||||||
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();
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -65,22 +68,17 @@ const fullTitle = `${title} | ${siteConfig.title}`;
|
|||||||
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/feed.xml" />
|
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/feed.xml" />
|
||||||
{siteConfig.custom_css && <style set:html={siteConfig.custom_css} />}
|
{siteConfig.custom_css && <style set:html={siteConfig.custom_css} />}
|
||||||
<script is:inline define:vars={{ defaultTheme: siteConfig.theme }}>
|
<script is:inline define:vars={{ defaultTheme: siteConfig.theme }}>
|
||||||
const savedTheme = localStorage.getItem('user-theme') || defaultTheme;
|
const savedTheme = localStorage.getItem('user-theme') || defaultTheme || 'salon';
|
||||||
document.documentElement.classList.add(savedTheme);
|
document.documentElement.classList.add(savedTheme);
|
||||||
</script>
|
</script>
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-bg text-text selection:bg-surface2 selection:text-text">
|
<body class="text-text">
|
||||||
<!-- Static Mesh Gradient Background -->
|
<div class="salon-atmosphere" aria-hidden="true"></div>
|
||||||
<div class="fixed inset-0 z-[-1] overflow-hidden bg-bg pointer-events-none">
|
|
||||||
<div class="absolute top-[-10%] left-[-10%] w-[55%] h-[45%] rounded-full bg-mauve/10 blur-[110px] opacity-60"></div>
|
|
||||||
<div class="absolute bottom-[-10%] right-[-10%] w-[55%] h-[55%] rounded-full bg-blue/10 blur-[110px] opacity-50"></div>
|
|
||||||
<div class="absolute top-[30%] right-[10%] w-[35%] h-[35%] rounded-full bg-teal/8 blur-[90px] opacity-40"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="max-w-6xl mx-auto px-4 md:px-6 py-4 md:py-8">
|
<header class="border-b border-[var(--surface2)]/60 relative z-10">
|
||||||
<header class="glass px-4 py-3 md:px-6 md:py-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
<div class="max-w-6xl mx-auto px-6 md:px-10 py-5 md:py-7 flex flex-col md:flex-row md:items-end gap-4 md:gap-6">
|
||||||
<div class="w-full md:w-auto text-center md:text-left">
|
<a href="/" class="nameplate group" aria-label="Home">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<EditableText
|
<EditableText
|
||||||
client:load
|
client:load
|
||||||
@@ -88,54 +86,60 @@ const fullTitle = `${title} | ${siteConfig.title}`;
|
|||||||
fieldKey="title"
|
fieldKey="title"
|
||||||
isAdmin
|
isAdmin
|
||||||
ariaLabel="site title"
|
ariaLabel="site title"
|
||||||
className="text-xl md:text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-mauve to-blue inline-block"
|
className="nameplate-title"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<a href="/" class="text-xl md:text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-mauve to-blue block">
|
<span class="nameplate-title group-hover:text-[var(--mauve)] transition-colors">{siteConfig.title}</span>
|
||||||
{siteConfig.title}
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<div class="text-[8px] md:text-[10px] text-subtext0 uppercase tracking-widest">
|
|
||||||
<EditableText
|
<EditableText
|
||||||
client:load
|
client:load
|
||||||
initial={siteConfig.subtitle}
|
initial={siteConfig.subtitle}
|
||||||
fieldKey="subtitle"
|
fieldKey="subtitle"
|
||||||
isAdmin
|
isAdmin
|
||||||
ariaLabel="site subtitle"
|
ariaLabel="site subtitle"
|
||||||
className="inline"
|
className="nameplate-subtitle"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p class="text-[8px] md:text-[10px] text-subtext0 uppercase tracking-widest">{siteConfig.subtitle}</p>
|
<span class="nameplate-subtitle">{siteConfig.subtitle}</span>
|
||||||
)}
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex-1 hidden md:flex items-end justify-center pb-1">
|
||||||
|
<span class="font-display italic text-[var(--subtext0)] text-sm tracking-wider">— exhibition est. {year} —</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 md:gap-3 items-center text-sm md:text-base w-full md:w-auto justify-center md:justify-end">
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
|
||||||
<Search client:load />
|
<Search client:load />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div class="flex items-center gap-1 pl-2 ml-1 border-l border-surface1/60">
|
<div class="flex items-center gap-1 pl-3 ml-1 border-l border-[var(--surface2)]/60">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/admin"
|
||||||
class="inline-flex items-center gap-1.5 text-xs uppercase tracking-wider px-2.5 py-1 rounded-full bg-mauve/15 text-mauve border border-mauve/30 hover:bg-mauve/25 transition-colors"
|
class="chip chip-accent uppercase"
|
||||||
title="Signed in as admin"
|
title="Signed in as curator"
|
||||||
>
|
>
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-mauve animate-pulse"></span>
|
<span class="w-1.5 h-1.5 rounded-full bg-[var(--rosewater)] animate-pulse"></span>
|
||||||
Admin
|
Curator
|
||||||
</a>
|
</a>
|
||||||
<LogoutButton client:load />
|
<LogoutButton client:load />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} />
|
<ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class={`mx-auto px-4 md:px-6 py-4 md:py-8 ${wide ? 'max-w-[95vw]' : 'max-w-6xl'}`}>
|
<main class={`mx-auto px-6 md:px-10 py-10 md:py-16 relative z-10 ${wide ? 'max-w-[95vw]' : 'max-w-6xl'}`}>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="max-w-6xl mx-auto px-4 md:px-6 py-8 md:py-12 text-center text-xs md:text-sm text-subtext1 border-t border-white/5 mt-8 md:mt-12">
|
<footer class="max-w-6xl mx-auto px-6 md:px-10 py-12 md:py-16 text-center border-t border-[var(--surface2)]/40 mt-12 relative z-10">
|
||||||
<p class="mb-2">
|
<div class="section-rule mb-6">
|
||||||
|
<span class="ornament">✦</span>
|
||||||
|
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span>
|
||||||
|
<span class="ornament">✦</span>
|
||||||
|
</div>
|
||||||
|
<p class="font-display italic text-base text-[var(--subtext1)] mb-2">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<EditableText
|
<EditableText
|
||||||
client:load
|
client:load
|
||||||
@@ -147,11 +151,11 @@ const fullTitle = `${title} | ${siteConfig.title}`;
|
|||||||
/>
|
/>
|
||||||
) : siteConfig.footer}
|
) : siteConfig.footer}
|
||||||
</p>
|
</p>
|
||||||
<div class="text-xs text-subtext0 mb-2">
|
<div class="text-xs text-[var(--subtext0)] tracking-[0.2em] uppercase mb-3">
|
||||||
<a href="/feed.xml" class="hover:text-mauve transition-colors">RSS</a>
|
<a href="/feed.xml" class="hover:text-[var(--mauve)] transition-colors">RSS Feed</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-subtext0 opacity-50">
|
<div class="text-[var(--overlay0)] text-xs italic">
|
||||||
© {new Date().getFullYear()} {siteConfig.title}
|
© {year} · {siteConfig.title}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { gfmHeadingId } from 'marked-gfm-heading-id';
|
|||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
const renderer = new Marked()
|
const renderer = new Marked()
|
||||||
.setOptions({ gfm: true, breaks: false })
|
.setOptions({ gfm: true, breaks: false })
|
||||||
.use(gfmHeadingId())
|
.use(gfmHeadingId())
|
||||||
@@ -17,7 +26,22 @@ const renderer = new Marked()
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(markedKatex({ throwOnError: false, nonStandard: true }));
|
.use(markedKatex({ throwOnError: false, nonStandard: true }))
|
||||||
|
.use({
|
||||||
|
renderer: {
|
||||||
|
image({ href, title, text }: { href: string; title: string | null; text: string }) {
|
||||||
|
const safeHref = escapeHtml(href);
|
||||||
|
const safeAlt = escapeHtml(text || '');
|
||||||
|
const safeTitle = title ? escapeHtml(title) : '';
|
||||||
|
const caption = title || text || '';
|
||||||
|
const figcaption = caption.trim()
|
||||||
|
? `<figcaption>${escapeHtml(caption)}</figcaption>`
|
||||||
|
: '';
|
||||||
|
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
|
||||||
|
return `<figure><img src="${safeHref}" alt="${safeAlt}"${titleAttr} loading="lazy" />${figcaption}</figure>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const KATEX_TAGS = [
|
const KATEX_TAGS = [
|
||||||
'math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext',
|
'math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext',
|
||||||
@@ -29,7 +53,7 @@ const KATEX_TAGS = [
|
|||||||
export function renderMarkdown(src: string): string {
|
export function renderMarkdown(src: string): string {
|
||||||
const html = renderer.parse(src, { async: false }) as string;
|
const html = renderer.parse(src, { async: false }) as string;
|
||||||
return DOMPurify.sanitize(html, {
|
return DOMPurify.sanitize(html, {
|
||||||
ADD_TAGS: KATEX_TAGS,
|
ADD_TAGS: [...KATEX_TAGS, 'figure', 'figcaption'],
|
||||||
ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel'],
|
ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel', 'loading'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,23 @@
|
|||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Not found" description="The page you're looking for doesn't exist.">
|
<Layout title="Not in the catalogue" description="The work you're looking for is not on view.">
|
||||||
<div class="glass p-8 md:p-16 text-center max-w-2xl mx-auto mt-8 md:mt-16">
|
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center">
|
||||||
<p class="text-7xl md:text-8xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal mb-4">
|
<div class="numeral text-[var(--mauve)] text-[8rem] md:text-[12rem] leading-none mb-4">
|
||||||
404
|
CDIV
|
||||||
|
</div>
|
||||||
|
<div class="section-rule max-w-sm mx-auto mb-8">
|
||||||
|
<span class="ornament">✦</span>
|
||||||
|
<span>Pardon — the gallery has misplaced this work</span>
|
||||||
|
<span class="ornament">✦</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-display italic text-3xl md:text-5xl text-[var(--text)] mb-6 leading-tight">
|
||||||
|
This piece is not on view.
|
||||||
|
</h1>
|
||||||
|
<p class="text-[var(--subtext1)] font-sans text-lg mb-10 leading-relaxed">
|
||||||
|
The room you reached for has either been re-hung, withdrawn,<br class="hidden md:block" />
|
||||||
|
or never made it to the wall in the first place.
|
||||||
</p>
|
</p>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold text-text mb-3">Page not found</h1>
|
<a href="/" class="btn-stamp">↶ Return to the catalogue</a>
|
||||||
<p class="text-subtext1 mb-8">
|
|
||||||
The page you're looking for has moved, been deleted, or never existed.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-5 py-2.5 rounded-lg hover:bg-pink transition-colors"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
|
||||||
Back to home
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const GET: APIRoute = async ({ site }) => {
|
|||||||
const origin = site?.toString().replace(/\/$/, '') || '';
|
const origin = site?.toString().replace(/\/$/, '') || '';
|
||||||
|
|
||||||
let posts: PostInfo[] = [];
|
let posts: PostInfo[] = [];
|
||||||
let config: SiteConfig = { title: 'Narlblog', subtitle: 'A clean, modern blog' };
|
let config: SiteConfig = { title: "Ela's Atelier", subtitle: 'Works on paper, canvas, and elsewhere' };
|
||||||
try {
|
try {
|
||||||
const [pr, cr] = await Promise.all([
|
const [pr, cr] = await Promise.all([
|
||||||
fetch(`${API_URL}/api/posts`),
|
fetch(`${API_URL}/api/posts`),
|
||||||
|
|||||||
@@ -6,21 +6,29 @@ import AssetsButton from '../components/react/AssetsButton';
|
|||||||
|
|
||||||
const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
|
const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
interface CoverImage {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
slug: string;
|
slug: string;
|
||||||
date: string;
|
date: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
reading_time: number;
|
reading_time: number;
|
||||||
|
cover_image?: CoverImage;
|
||||||
|
image_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let posts: Post[] = [];
|
let posts: Post[] = [];
|
||||||
let error = '';
|
let error = '';
|
||||||
let siteConfig = {
|
let siteConfig = {
|
||||||
welcome_title: "Welcome to my blog",
|
welcome_title: "Works on view",
|
||||||
welcome_subtitle: "Thoughts on software, design, and building things with Rust and Astro."
|
welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations."
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +40,7 @@ try {
|
|||||||
if (postsRes.ok) {
|
if (postsRes.ok) {
|
||||||
posts = await postsRes.json();
|
posts = await postsRes.json();
|
||||||
} else {
|
} else {
|
||||||
error = 'Failed to fetch posts';
|
error = 'Failed to fetch works';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
@@ -45,12 +53,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||||
|
const total = posts.length;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Home" description={siteConfig.welcome_subtitle}>
|
<Layout title="Catalogue" description={siteConfig.welcome_subtitle}>
|
||||||
<div class="space-y-6 md:space-y-8">
|
<section class="relative mb-16 md:mb-24">
|
||||||
<section class="text-center py-6 md:py-12">
|
<div class="flex flex-col md:flex-row md:items-end gap-8 md:gap-12">
|
||||||
<h1 class="text-3xl md:text-5xl font-extrabold mb-3 md:mb-4 pb-2 md:pb-4 leading-tight">
|
<div class="flex-1 max-w-2xl">
|
||||||
|
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">
|
||||||
|
Currently arranged — {new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
<h1 class="font-display italic font-semibold text-[var(--text)] text-5xl md:text-7xl lg:text-8xl leading-[0.95] tracking-tight mb-6">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<EditableText
|
<EditableText
|
||||||
client:load
|
client:load
|
||||||
@@ -58,15 +71,11 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
|||||||
fieldKey="welcome_title"
|
fieldKey="welcome_title"
|
||||||
isAdmin
|
isAdmin
|
||||||
ariaLabel="welcome title"
|
ariaLabel="welcome title"
|
||||||
className="bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal"
|
className="inline"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : siteConfig.welcome_title}
|
||||||
<span class="bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal">
|
|
||||||
{siteConfig.welcome_title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-subtext1 text-base md:text-lg max-w-2xl mx-auto px-4 md:px-0">
|
<p class="font-sans text-lg md:text-xl text-[var(--subtext1)] leading-relaxed max-w-xl">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<EditableText
|
<EditableText
|
||||||
client:load
|
client:load
|
||||||
@@ -81,50 +90,55 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div class="mt-6 md:mt-8 flex flex-wrap items-center justify-center gap-2 md:gap-3">
|
<div class="mt-8 flex flex-wrap items-center gap-3">
|
||||||
<a
|
<a href="/admin/editor" class="btn-stamp">
|
||||||
href="/admin/editor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||||
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-4 py-2 rounded-lg hover:bg-pink transition-colors text-sm shadow-lg shadow-mauve/20"
|
Hang new work
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
|
||||||
New Post
|
|
||||||
</a>
|
</a>
|
||||||
<AssetsButton client:load />
|
<AssetsButton client:load />
|
||||||
<a
|
<a href="/admin/settings" class="btn-ghost">
|
||||||
href="/admin/settings"
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
|
||||||
class="inline-flex items-center gap-2 bg-surface0 hover:bg-surface1 text-subtext1 hover:text-text px-3 py-2 rounded-lg border border-surface1 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="md:w-64 lg:w-80 shrink-0 md:pb-2">
|
||||||
|
<div class="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-2">Index</div>
|
||||||
|
<div class="numeral text-5xl md:text-6xl text-[var(--mauve)] leading-none mb-3">
|
||||||
|
{String(total).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div class="font-display italic text-[var(--subtext1)] text-base leading-snug">
|
||||||
|
{total === 1 ? 'work hanging' : 'works hanging'},
|
||||||
|
<span class="font-hand text-[var(--mauve)] text-xl ml-1">arranged below</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 h-px bg-[var(--surface2)]"></div>
|
||||||
|
<div class="mt-3 text-[var(--subtext0)] font-display italic text-sm">
|
||||||
|
Scroll the room — no two visits the same.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="flex flex-col space-y-6">
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class="glass p-4 md:p-6 text-red text-center border-red/20 text-sm md:text-base">
|
<div class="glass p-6 md:p-8 text-center mb-12 border-[var(--red)]/40">
|
||||||
{error}
|
<p class="font-display italic text-[var(--red)] text-lg">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{posts.length === 0 && !error && !isAdmin && (
|
{posts.length === 0 && !error && (
|
||||||
<div class="glass p-8 md:p-12 text-center text-sm md:text-base text-subtext1">
|
<div class="glass p-12 md:p-20 text-center max-w-2xl mx-auto">
|
||||||
<p>No posts yet — check back soon.</p>
|
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">Notice</div>
|
||||||
</div>
|
<p class="font-display italic text-[var(--text)] text-2xl md:text-3xl leading-snug mb-2">
|
||||||
|
The exhibition is currently being arranged.
|
||||||
|
</p>
|
||||||
|
<p class="font-sans text-[var(--subtext1)] mt-4">Please return shortly.</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<a href="/admin/editor" class="btn-stamp mt-8">Hang the first work</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{posts.length === 0 && !error && isAdmin && (
|
|
||||||
<div class="glass p-8 md:p-12 text-center text-sm md:text-base">
|
|
||||||
<p class="text-subtext1 mb-4">No posts yet. Write your first one.</p>
|
|
||||||
<a href="/admin/editor" class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-4 py-2 rounded-lg hover:bg-pink transition-colors text-sm">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
|
||||||
New Post
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:load />}
|
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:load />}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { renderMarkdown } from '../../lib/markdown';
|
|||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
|
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
|
||||||
|
|
||||||
|
interface CoverImage { url: string; alt: string }
|
||||||
interface PostDetail {
|
interface PostDetail {
|
||||||
slug: string;
|
slug: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -15,28 +16,29 @@ interface PostDetail {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
reading_time: number;
|
reading_time: number;
|
||||||
|
cover_image?: CoverImage;
|
||||||
|
image_count: number;
|
||||||
|
}
|
||||||
|
interface PostInfo {
|
||||||
|
slug: string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string) {
|
function formatDate(d: string) {
|
||||||
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let post: PostDetail | null = null;
|
function toRoman(n: number): string {
|
||||||
let html = '';
|
const map: [number, string][] = [
|
||||||
let error = '';
|
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
|
||||||
|
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
|
||||||
try {
|
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
|
||||||
const response = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
|
];
|
||||||
if (response.ok) {
|
let out = '';
|
||||||
post = await response.json();
|
for (const [val, sym] of map) {
|
||||||
html = renderMarkdown(post!.content);
|
while (n >= val) { out += sym; n -= val; }
|
||||||
} else {
|
|
||||||
error = 'Post not found';
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
return out;
|
||||||
const cause = (e as any)?.cause;
|
|
||||||
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSlug(s: string) {
|
function formatSlug(s: string) {
|
||||||
@@ -44,52 +46,74 @@ function formatSlug(s: string) {
|
|||||||
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let post: PostDetail | null = null;
|
||||||
|
let html = '';
|
||||||
|
let error = '';
|
||||||
|
let neighbors: { prev?: PostInfo; next?: PostInfo; index: number; total: number } = { index: -1, total: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [postRes, listRes] = await Promise.all([
|
||||||
|
fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`),
|
||||||
|
fetch(`${API_URL}/api/posts`),
|
||||||
|
]);
|
||||||
|
if (postRes.ok) {
|
||||||
|
post = await postRes.json();
|
||||||
|
html = renderMarkdown(post!.content);
|
||||||
|
} else {
|
||||||
|
error = 'Work not found in the catalogue';
|
||||||
|
}
|
||||||
|
if (listRes.ok) {
|
||||||
|
const list: PostInfo[] = await listRes.json();
|
||||||
|
const i = list.findIndex(p => p.slug === slug);
|
||||||
|
if (i >= 0) {
|
||||||
|
neighbors = {
|
||||||
|
index: i,
|
||||||
|
total: list.length,
|
||||||
|
prev: i > 0 ? list[i - 1] : undefined,
|
||||||
|
next: i < list.length - 1 ? list[i + 1] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const cause = (e as any)?.cause;
|
||||||
|
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||||
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post';
|
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
|
||||||
|
const exhibitNumber = neighbors.index >= 0 ? toRoman(neighbors.index + 1) : '';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={displayTitle}
|
title={displayTitle}
|
||||||
description={post?.summary}
|
description={post?.summary}
|
||||||
|
image={post?.cover_image?.url}
|
||||||
type="article"
|
type="article"
|
||||||
>
|
>
|
||||||
{/* Reading progress bar */}
|
<div id="reading-progress" class="reading-progress" aria-hidden="true"></div>
|
||||||
<div
|
|
||||||
id="reading-progress"
|
|
||||||
class="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-mauve via-blue to-teal z-[150] origin-left"
|
|
||||||
style="transform: scaleX(0); transition: transform 80ms linear;"
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center">
|
<div class="max-w-2xl mx-auto py-20 md:py-32 text-center">
|
||||||
<h2 class="text-2xl md:text-3xl font-bold text-red mb-4">{error}</h2>
|
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">Pardon —</div>
|
||||||
<a href="/" class="inline-flex items-center gap-2 text-blue hover:text-sky transition-colors">
|
<h2 class="font-display italic text-3xl md:text-5xl text-[var(--mauve)] mb-6 leading-tight">{error}</h2>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
<a href="/" class="btn-ghost">← Return to the catalogue</a>
|
||||||
Back home
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{post && (
|
{post && (
|
||||||
<article class="animate-in fade-in slide-in-from-bottom-2 duration-500">
|
<article class="plate-enter">
|
||||||
{/* Toolbar: Back to list + admin actions */}
|
{/* Toolbar — exhibit nav */}
|
||||||
<div class="flex items-center justify-between gap-3 mb-8 md:mb-12">
|
<div class="flex items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]">
|
||||||
<a
|
<a href="/" class="inline-flex items-center gap-2 hover:text-[var(--mauve)] transition-colors group">
|
||||||
href="/"
|
<span class="transition-transform group-hover:-translate-x-1">←</span>
|
||||||
class="inline-flex items-center gap-2 text-subtext0 hover:text-text transition-colors text-sm group"
|
Back to catalogue
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
|
||||||
Back to list
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a
|
<a href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`} class="btn-ghost">
|
||||||
href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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>
|
||||||
class="inline-flex items-center gap-2 bg-surface0 hover:bg-blue/15 text-subtext1 hover:text-blue px-3 py-1.5 md:px-4 md:py-2 rounded-md border border-surface1 hover:border-blue/30 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<DeletePostButton slug={post.slug} title={displayTitle} client:load />
|
<DeletePostButton slug={post.slug} title={displayTitle} client:load />
|
||||||
@@ -97,50 +121,90 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post';
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero header — centered title + meta */}
|
{/* Plaque header */}
|
||||||
<header class="max-w-3xl mx-auto text-center mb-10 md:mb-16">
|
<header class="max-w-3xl mx-auto text-center mb-12 md:mb-16">
|
||||||
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 mb-4 text-xs md:text-sm text-subtext0">
|
{exhibitNumber && (
|
||||||
<time datetime={post.date}>{formatDate(post.date)}</time>
|
<div class="font-display italic text-[var(--mauve)] tracking-[0.3em] text-sm mb-5">
|
||||||
<span class="opacity-50">·</span>
|
№ {exhibitNumber} <span class="text-[var(--subtext0)] not-italic">/ {neighbors.total}</span>
|
||||||
<span>{post.reading_time} min read</span>
|
|
||||||
{post.draft && (
|
|
||||||
<>
|
|
||||||
<span class="opacity-50">·</span>
|
|
||||||
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl md:text-5xl lg:text-6xl font-extrabold text-mauve leading-[1.1] mb-6 md:mb-8 tracking-tight">
|
)}
|
||||||
|
|
||||||
|
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-6xl lg:text-7xl leading-[0.95] tracking-tight mb-6">
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<div class="section-rule max-w-md mx-auto mb-6">
|
||||||
|
<span class="ornament">✦</span>
|
||||||
|
<span>{formatDate(post.date)}</span>
|
||||||
|
<span class="ornament">·</span>
|
||||||
|
<span>{post.reading_time} min</span>
|
||||||
|
{post.image_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span class="ornament">·</span>
|
||||||
|
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span class="ornament">✦</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{post.summary && (
|
{post.summary && (
|
||||||
<p class="text-base md:text-xl text-subtext1 leading-relaxed max-w-2xl mx-auto">
|
<p class="font-display italic text-[var(--subtext1)] text-lg md:text-xl leading-relaxed max-w-2xl mx-auto">
|
||||||
{post.summary}
|
{post.summary}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{post.draft && (
|
||||||
|
<div class="mt-6 inline-block">
|
||||||
|
<span class="chip" style="background: var(--peach); color: var(--crust); border-color: var(--peach);">
|
||||||
|
Sketch · unpublished
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{post.tags?.length > 0 && (
|
{post.tags?.length > 0 && (
|
||||||
<div class="flex flex-wrap justify-center gap-2 mt-6">
|
<div class="flex flex-wrap justify-center gap-2 mt-6">
|
||||||
{post.tags.map(tag => (
|
{post.tags.map(tag => <span class="chip">{tag}</span>)}
|
||||||
<span class="text-[10px] uppercase tracking-wider px-2.5 py-1 rounded-full bg-surface0 text-subtext0 border border-surface1">{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="w-full max-w-3xl mx-auto h-px bg-gradient-to-r from-transparent via-surface2 to-transparent mb-10 md:mb-16"></div>
|
{/* Body — works on paper */}
|
||||||
|
<div id="post-content" class="prose" set:html={html} />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Closing — continue the room */}
|
||||||
<div id="post-content" class="prose px-1" set:html={html} />
|
<div class="max-w-3xl mx-auto mt-20 md:mt-28">
|
||||||
|
<div class="section-rule mb-10">
|
||||||
{/* Footer separator + back link */}
|
<span class="ornament">✦</span>
|
||||||
<div class="max-w-3xl mx-auto mt-16 md:mt-24 pt-8 md:pt-12 border-t border-surface1/60 text-center">
|
<span>continue the gallery</span>
|
||||||
<a
|
<span class="ornament">✦</span>
|
||||||
href="/"
|
</div>
|
||||||
class="inline-flex items-center gap-2 text-subtext0 hover:text-mauve transition-colors text-sm group"
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
>
|
{neighbors.prev && (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
<a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left">
|
||||||
Back to all posts
|
<div class="font-sans text-xs tracking-[0.22em] uppercase text-[var(--subtext0)] mb-2">← Previously hung</div>
|
||||||
|
<div class="font-display italic text-xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors leading-snug">
|
||||||
|
{neighbors.prev.title || formatSlug(neighbors.prev.slug)}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
{neighbors.next && (
|
||||||
|
<a href={`/posts/${encodeURIComponent(neighbors.next.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-right md:col-start-2">
|
||||||
|
<div class="font-sans text-xs tracking-[0.22em] uppercase text-[var(--subtext0)] mb-2">Next on the wall →</div>
|
||||||
|
<div class="font-display italic text-xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors leading-snug">
|
||||||
|
{neighbors.next.title || formatSlug(neighbors.next.slug)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!neighbors.prev && !neighbors.next && (
|
||||||
|
<div class="md:col-span-2 text-center font-display italic text-[var(--subtext0)]">
|
||||||
|
This is the sole work currently on view.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 text-center">
|
||||||
|
<a href="/" class="btn-ghost">↶ Return to catalogue</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+679
-154
@@ -1,8 +1,9 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* NARLBLOG THEME ENGINE
|
* SALON HANG — gallery theme.
|
||||||
* All UI components automatically pick up these tokens.
|
* Aged parchment ground, oxblood ink, ochre+cobalt+vermillion accents.
|
||||||
|
* Romantic gravity (Friedrich, Dix, Goya) + raw scrawl (Basquiat) + bold cutout (Matisse, Kahlo).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@@ -33,11 +34,75 @@
|
|||||||
--color-flamingo: var(--flamingo);
|
--color-flamingo: var(--flamingo);
|
||||||
--color-rosewater: var(--rosewater);
|
--color-rosewater: var(--rosewater);
|
||||||
|
|
||||||
--font-sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
--font-sans: 'EB Garamond Variable', 'EB Garamond', Georgia, 'Times New Roman', serif;
|
||||||
|
--font-display: 'Fraunces Variable', 'Fraunces', Georgia, 'Times New Roman', serif;
|
||||||
|
--font-hand: 'Caveat', 'Bradley Hand', cursive;
|
||||||
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;
|
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root, .mocha {
|
/* SALON — default. Aged parchment with romantic weight. */
|
||||||
|
:root, .salon {
|
||||||
|
--crust: #14100C;
|
||||||
|
--mantle: #2A1F18;
|
||||||
|
--base: #ECE0C6;
|
||||||
|
--surface0: #DDCEB0;
|
||||||
|
--surface1: #B69C70;
|
||||||
|
--surface2: #826846;
|
||||||
|
--overlay0: #5C463A;
|
||||||
|
--overlay1: #463226;
|
||||||
|
--overlay2: #2E1F17;
|
||||||
|
--text: #1A120C;
|
||||||
|
--subtext0: #5C463A;
|
||||||
|
--subtext1: #3D2B1E;
|
||||||
|
/* accents — mapped to the original token names so existing UI flows pick them up */
|
||||||
|
--blue: #1F3A78; /* Kahlo cobalt */
|
||||||
|
--lavender: #5C4D7A; /* faded violet */
|
||||||
|
--sapphire: #2B3E5C; /* deep ink-blue */
|
||||||
|
--sky: #4A6FA0; /* muted azure */
|
||||||
|
--teal: #4C7264; /* verdigris */
|
||||||
|
--green: #6A7341; /* olive */
|
||||||
|
--yellow: #C9882B; /* Friedrich ochre */
|
||||||
|
--peach: #C26847; /* terracotta */
|
||||||
|
--maroon: #6B2B2A; /* wine */
|
||||||
|
--red: #B83A2B; /* Matisse/Goya vermillion */
|
||||||
|
--mauve: #6B1F1A; /* oxblood — primary accent */
|
||||||
|
--pink: #B85A6C; /* rosehip */
|
||||||
|
--flamingo: #C77A6C; /* faded coral */
|
||||||
|
--rosewater: #E8D9BD; /* bone */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Salon Noir — black gallery wall variant (Goya black paintings, Abramović stark). */
|
||||||
|
.salon-noir {
|
||||||
|
--crust: #050402;
|
||||||
|
--mantle: #0E0A06;
|
||||||
|
--base: #16110B;
|
||||||
|
--surface0: #221A12;
|
||||||
|
--surface1: #3A2B1E;
|
||||||
|
--surface2: #5C4530;
|
||||||
|
--overlay0: #7A5D43;
|
||||||
|
--overlay1: #93755A;
|
||||||
|
--overlay2: #B69779;
|
||||||
|
--text: #ECE0C6;
|
||||||
|
--subtext0: #B69C70;
|
||||||
|
--subtext1: #D6C49E;
|
||||||
|
--blue: #5A7DC4;
|
||||||
|
--lavender: #9A8DBE;
|
||||||
|
--sapphire: #87A9D8;
|
||||||
|
--sky: #B0C4E0;
|
||||||
|
--teal: #84A89A;
|
||||||
|
--green: #B9C076;
|
||||||
|
--yellow: #E9B854;
|
||||||
|
--peach: #E89570;
|
||||||
|
--maroon: #A04A47;
|
||||||
|
--red: #E25940;
|
||||||
|
--mauve: #C24336; /* lifted oxblood for dark bg contrast */
|
||||||
|
--pink: #E090A0;
|
||||||
|
--flamingo: #EBA797;
|
||||||
|
--rosewater: #F4E5C9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy Catppuccin themes — kept for users that already opted in. */
|
||||||
|
.mocha {
|
||||||
--crust: #11111b; --mantle: #181825; --base: #1e1e2e;
|
--crust: #11111b; --mantle: #181825; --base: #1e1e2e;
|
||||||
--surface0: #313244; --surface1: #45475a; --surface2: #585b70;
|
--surface0: #313244; --surface1: #45475a; --surface2: #585b70;
|
||||||
--overlay0: #6c7086; --overlay1: #7f849c; --overlay2: #9399b2;
|
--overlay0: #6c7086; --overlay1: #7f849c; --overlay2: #9399b2;
|
||||||
@@ -49,31 +114,6 @@
|
|||||||
--flamingo: #f2cdcd; --rosewater: #f5e0dc;
|
--flamingo: #f2cdcd; --rosewater: #f5e0dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.macchiato {
|
|
||||||
--crust: #181926; --mantle: #1e2030; --base: #24273a;
|
|
||||||
--surface0: #363a4f; --surface1: #494d64; --surface2: #5b6078;
|
|
||||||
--overlay0: #6e738d; --overlay1: #8087a2; --overlay2: #939ab7;
|
|
||||||
--text: #cad3f5; --subtext0: #a5adcb; --subtext1: #b8c0e0;
|
|
||||||
--blue: #8aadf4; --lavender: #b7bdf8; --sapphire: #7dc4e4;
|
|
||||||
--sky: #91d7e3; --teal: #8bd5ca; --green: #a6da95;
|
|
||||||
--yellow: #eed49f; --peach: #f5a97f; --maroon: #ee99a0;
|
|
||||||
--red: #ed8796; --mauve: #c6a0f6; --pink: #f5bde6;
|
|
||||||
--flamingo: #f0c1c1; --rosewater: #f4dbd6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frappe {
|
|
||||||
--crust: #232634; --mantle: #292c3c; --base: #303446;
|
|
||||||
--surface0: #414559; --surface1: #51576d; --surface2: #626880;
|
|
||||||
--overlay0: #737994; --overlay1: #838ba7; --overlay2: #949cbb;
|
|
||||||
--text: #c6d0f5; --subtext0: #a5adce; --subtext1: #b5bfe3;
|
|
||||||
--blue: #8caaee; --lavender: #babbf1; --sapphire: #85c1dc;
|
|
||||||
--sky: #99d1db; --teal: #81c8be; --green: #a6d189;
|
|
||||||
--yellow: #e5c890; --peach: #ef9f76; --maroon: #ea999c;
|
|
||||||
--red: #e78284; --mauve: #ca9ee6; --pink: #f4b8e4;
|
|
||||||
--flamingo: #eebebe; --rosewater: #f2d5cf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light themes — darkened secondary text for WCAG AA against base. */
|
|
||||||
.latte {
|
.latte {
|
||||||
--crust: #dce0e8; --mantle: #e6e9ef; --base: #eff1f5;
|
--crust: #dce0e8; --mantle: #e6e9ef; --base: #eff1f5;
|
||||||
--surface0: #ccd0da; --surface1: #bcc0cc; --surface2: #acb0be;
|
--surface0: #ccd0da; --surface1: #bcc0cc; --surface2: #acb0be;
|
||||||
@@ -86,20 +126,9 @@
|
|||||||
--flamingo: #dd7878; --rosewater: #dc8a78;
|
--flamingo: #dd7878; --rosewater: #dc8a78;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scaled-and-icy {
|
|
||||||
--crust: #e2e8f0; --mantle: #f1f5f9; --base: #f8fafc;
|
|
||||||
--surface0: #cbd5e1; --surface1: #94a3b8; --surface2: #64748b;
|
|
||||||
--overlay0: #475569; --overlay1: #334155; --overlay2: #1e293b;
|
|
||||||
--text: #0f172a; --subtext0: #1e293b; --subtext1: #334155;
|
|
||||||
--blue: #0284c7; --lavender: #6366f1; --sapphire: #0ea5e9;
|
|
||||||
--sky: #0284c7; --teal: #0d9488; --green: #16a34a;
|
|
||||||
--yellow: #ca8a04; --peach: #ea580c; --maroon: #be123c;
|
|
||||||
--red: #dc2626; --mauve: #9333ea; --pink: #db2777;
|
|
||||||
--flamingo: #be185d; --rosewater: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: "kern", "liga", "calt", "onum";
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -108,194 +137,652 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper grain — applied as a fixed overlay so every page gets the texture. */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
background-color: var(--base);
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
background-image:
|
||||||
|
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0 0.04 0 0 0 0.22 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||||
|
}
|
||||||
|
.salon-noir body::after,
|
||||||
|
html.salon-noir body::after {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating motes of pigment — far background, very subtle. */
|
||||||
|
.salon-atmosphere {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.salon-atmosphere::before,
|
||||||
|
.salon-atmosphere::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
.salon-atmosphere::before {
|
||||||
|
width: 55vw; height: 55vw;
|
||||||
|
top: -15vw; left: -10vw;
|
||||||
|
background: var(--mauve);
|
||||||
|
}
|
||||||
|
.salon-atmosphere::after {
|
||||||
|
width: 45vw; height: 45vw;
|
||||||
|
bottom: -10vw; right: -10vw;
|
||||||
|
background: var(--blue);
|
||||||
|
opacity: 0.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
code, pre, kbd, samp {
|
code, pre, kbd, samp {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prose — readable column, calm hierarchy */
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: var(--mauve);
|
||||||
|
color: var(--rosewater);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Display utilities ───── */
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-feature-settings: "kern", "liga", "calt", "lnum", "ss01";
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.font-hand {
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.font-display-italic {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-feature-settings: "kern", "liga", "calt", "ss01";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roman numerals get small-caps treatment */
|
||||||
|
.numeral {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-variant-numeric: lining-nums;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Salon prose — exhibit plaque body ───── */
|
||||||
.prose {
|
.prose {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
max-width: 72ch;
|
max-width: 62ch;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
font-size: 1.05rem;
|
font-size: 1.125rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.prose {
|
.prose { font-size: 1.1875rem; }
|
||||||
font-size: 1.0625rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.prose h1 {
|
.prose > *:first-child { margin-top: 0; }
|
||||||
font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem);
|
|
||||||
font-weight: 700;
|
.prose p:first-of-type::first-letter {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 3.6em;
|
||||||
|
line-height: 0.85;
|
||||||
|
float: left;
|
||||||
|
margin: 0.08em 0.12em 0 -0.04em;
|
||||||
color: var(--mauve);
|
color: var(--mauve);
|
||||||
margin: 0 0 1rem;
|
}
|
||||||
line-height: 1.2;
|
|
||||||
|
.prose h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2rem, 1.5rem + 2vw, 3rem);
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.prose h2 {
|
.prose h2 {
|
||||||
font-size: clamp(1.4rem, 1.2rem + 0.8vw, 1.875rem);
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-size: clamp(1.5rem, 1.2rem + 1vw, 2rem);
|
||||||
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 2.5rem 0 1rem;
|
margin: 3rem 0 1rem;
|
||||||
padding-bottom: 0.4rem;
|
line-height: 1.2;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--surface2) 60%, transparent);
|
letter-spacing: -0.01em;
|
||||||
line-height: 1.3;
|
}
|
||||||
|
.prose h2::before {
|
||||||
|
content: "§ ";
|
||||||
|
color: var(--mauve);
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.prose h3 {
|
.prose h3 {
|
||||||
font-size: 1.35rem;
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 2rem 0 0.75rem;
|
margin: 2.25rem 0 0.75rem;
|
||||||
line-height: 1.35;
|
|
||||||
}
|
}
|
||||||
.prose h4 {
|
.prose h4 {
|
||||||
font-size: 1.15rem;
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-size: 1.2rem;
|
||||||
color: var(--text);
|
font-weight: 500;
|
||||||
margin: 1.5rem 0 0.5rem;
|
color: var(--subtext1);
|
||||||
|
margin: 1.75rem 0 0.5rem;
|
||||||
}
|
}
|
||||||
.prose h5 {
|
.prose h5 {
|
||||||
font-size: 1rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--subtext1);
|
color: var(--subtext0);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.18em;
|
||||||
margin: 1.5rem 0 0.5rem;
|
margin: 1.5rem 0 0.5rem;
|
||||||
}
|
}
|
||||||
.prose h6 {
|
.prose h6 {
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--subtext0);
|
color: var(--overlay0);
|
||||||
|
font-style: italic;
|
||||||
margin: 1rem 0 0.5rem;
|
margin: 1rem 0 0.5rem;
|
||||||
}
|
}
|
||||||
.prose p {
|
.prose p { margin: 0 0 1.15rem; }
|
||||||
margin: 0 0 1.1rem;
|
|
||||||
}
|
|
||||||
.prose blockquote {
|
.prose blockquote {
|
||||||
border-left: 3px solid var(--mauve);
|
border-left: 3px double var(--mauve);
|
||||||
padding: 0.25rem 0 0.25rem 1.1rem;
|
padding: 0.5rem 0 0.5rem 1.4rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.75rem 0;
|
||||||
color: var(--subtext1);
|
color: var(--subtext1);
|
||||||
|
font-family: var(--font-display);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
font-size: 1.15em;
|
||||||
.prose pre {
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--surface1) 70%, transparent);
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
background-color: var(--crust);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
.prose blockquote::before {
|
||||||
|
content: "“";
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--mauve);
|
||||||
|
font-size: 2.5em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: -0.35em;
|
||||||
|
margin-right: 0.1em;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-left-color: var(--mauve);
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
background-color: color-mix(in srgb, var(--surface0) 70%, transparent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
.prose code {
|
.prose code {
|
||||||
background-color: color-mix(in srgb, var(--surface0) 80%, transparent);
|
background-color: color-mix(in srgb, var(--surface0) 90%, transparent);
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 0.3rem;
|
border-radius: 0;
|
||||||
|
border-bottom: 1px solid var(--surface1);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: var(--peach);
|
color: var(--mauve);
|
||||||
}
|
}
|
||||||
.prose pre code {
|
.prose pre code {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border: 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
.prose img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
margin: 1.75rem 0;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--surface1) 50%, transparent);
|
|
||||||
}
|
|
||||||
.prose a {
|
.prose a {
|
||||||
color: var(--blue);
|
color: var(--mauve);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-decoration-color: var(--surface1);
|
||||||
text-decoration-thickness: 1px;
|
text-decoration-thickness: 1px;
|
||||||
transition: color 0.15s;
|
text-underline-offset: 3px;
|
||||||
|
transition: color 0.15s, text-decoration-color 0.15s;
|
||||||
}
|
}
|
||||||
.prose a:hover {
|
.prose a:hover {
|
||||||
color: var(--sky);
|
color: var(--red);
|
||||||
|
text-decoration-color: var(--red);
|
||||||
}
|
}
|
||||||
.prose ul, .prose ol {
|
|
||||||
margin: 0 0 1.1rem;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
.prose ul { list-style: disc; }
|
|
||||||
.prose ol { list-style: decimal; }
|
|
||||||
.prose ul ul { list-style: circle; }
|
|
||||||
.prose ul ul ul { list-style: square; }
|
|
||||||
.prose li { margin: 0.25rem 0; }
|
|
||||||
.prose hr {
|
|
||||||
margin: 2.5rem 0;
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--surface2) 70%, transparent);
|
|
||||||
}
|
|
||||||
.prose strong { color: var(--text); font-weight: 700; }
|
|
||||||
.prose em { color: inherit; font-style: italic; }
|
|
||||||
.prose del { color: var(--overlay1); text-decoration: line-through; }
|
|
||||||
.prose li input[type="checkbox"] { margin-right: 0.5rem; accent-color: var(--blue); vertical-align: middle; }
|
|
||||||
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.25rem; }
|
|
||||||
|
|
||||||
/* GFM tables */
|
.prose ul, .prose ol {
|
||||||
|
margin: 0 0 1.15rem;
|
||||||
|
padding-left: 1.6rem;
|
||||||
|
}
|
||||||
|
.prose ul { list-style: none; }
|
||||||
|
.prose ul > li { position: relative; padding-left: 0.2rem; }
|
||||||
|
.prose ul > li::before {
|
||||||
|
content: "❦";
|
||||||
|
position: absolute;
|
||||||
|
left: -1.2rem;
|
||||||
|
color: var(--mauve);
|
||||||
|
font-size: 0.85em;
|
||||||
|
top: 0.05em;
|
||||||
|
}
|
||||||
|
.prose ol { list-style: decimal-leading-zero; }
|
||||||
|
.prose ol > li::marker { color: var(--mauve); font-family: var(--font-display); font-style: italic; }
|
||||||
|
.prose li { margin: 0.3rem 0; }
|
||||||
|
|
||||||
|
.prose hr {
|
||||||
|
margin: 3rem auto;
|
||||||
|
border: 0;
|
||||||
|
text-align: center;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
.prose hr::before {
|
||||||
|
content: "✦ ✦ ✦";
|
||||||
|
color: var(--surface2);
|
||||||
|
letter-spacing: 0.8em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose strong { color: var(--mauve); font-weight: 700; }
|
||||||
|
.prose em { color: inherit; font-style: italic; font-family: var(--font-display); }
|
||||||
|
.prose del { color: var(--overlay0); text-decoration: line-through; }
|
||||||
|
|
||||||
|
/* ───── Figure / image plate — the heart of the gallery body ───── */
|
||||||
|
.prose figure,
|
||||||
|
.prose p > img:only-child {
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
.prose figure {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.prose figure img,
|
||||||
|
.prose img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
padding: 6px;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--rosewater), var(--rosewater)) padding-box,
|
||||||
|
linear-gradient(135deg, var(--surface2), var(--surface1)) border-box;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--surface0),
|
||||||
|
0 18px 38px -22px rgba(20, 16, 12, 0.45),
|
||||||
|
0 2px 6px -2px rgba(20, 16, 12, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.salon-noir .prose figure img,
|
||||||
|
.salon-noir .prose img {
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--surface0), var(--surface0)) padding-box,
|
||||||
|
linear-gradient(135deg, var(--surface2), var(--surface1)) border-box;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 38px -22px rgba(0, 0, 0, 0.7),
|
||||||
|
0 2px 6px -2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.prose figure figcaption {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--subtext0);
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.prose figure figcaption::before {
|
||||||
|
content: "— ";
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GFM tables — keep, slightly more editorial */
|
||||||
.prose table {
|
.prose table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 1.75rem 0;
|
margin: 1.75rem 0;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
border-radius: 0.5rem;
|
border: 1px solid var(--surface2);
|
||||||
overflow: hidden;
|
font-size: 0.95rem;
|
||||||
border: 1px solid var(--surface1);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
.prose thead { background-color: color-mix(in srgb, var(--surface0) 60%, transparent); }
|
.prose thead { background-color: color-mix(in srgb, var(--surface0) 80%, transparent); }
|
||||||
.prose th {
|
.prose th {
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.55rem 0.9rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom: 1px solid var(--surface1);
|
border-bottom: 1px solid var(--surface2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.prose td {
|
.prose td {
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.5rem 0.9rem;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--surface1) 60%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--surface1) 60%, transparent);
|
||||||
}
|
}
|
||||||
.prose tr:last-child td { border-bottom: 0; }
|
.prose tr:last-child td { border-bottom: 0; }
|
||||||
.prose tr:nth-child(even) td { background-color: color-mix(in srgb, var(--surface0) 25%, transparent); }
|
|
||||||
|
|
||||||
/* Glass surface */
|
/* ───── Salon plate — a single framed image card used on the gallery index ───── */
|
||||||
|
.plate {
|
||||||
|
position: relative;
|
||||||
|
background: var(--rosewater);
|
||||||
|
padding: 14px 14px 0 14px;
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
|
||||||
|
0 1px 0 var(--surface0),
|
||||||
|
0 22px 42px -28px rgba(20, 16, 12, 0.5),
|
||||||
|
0 4px 12px -6px rgba(20, 16, 12, 0.25);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.2, 0.6, 0.2, 1),
|
||||||
|
box-shadow 0.4s cubic-bezier(0.2, 0.6, 0.2, 1);
|
||||||
|
}
|
||||||
|
.salon-noir .plate {
|
||||||
|
background: var(--surface0);
|
||||||
|
}
|
||||||
|
.plate:hover {
|
||||||
|
transform: translateY(-4px) rotate(-0.25deg);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 60%, transparent),
|
||||||
|
0 1px 0 var(--surface0),
|
||||||
|
0 32px 60px -28px rgba(20, 16, 12, 0.55),
|
||||||
|
0 8px 20px -8px rgba(20, 16, 12, 0.3);
|
||||||
|
}
|
||||||
|
.plate .plate-image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--mantle);
|
||||||
|
}
|
||||||
|
.plate .plate-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: saturate(0.94) contrast(1.02);
|
||||||
|
transition: transform 0.8s cubic-bezier(0.2, 0.6, 0.2, 1), filter 0.4s ease;
|
||||||
|
}
|
||||||
|
.plate:hover .plate-image img {
|
||||||
|
transform: scale(1.03);
|
||||||
|
filter: saturate(1.05) contrast(1.04);
|
||||||
|
}
|
||||||
|
.plate .plate-caption {
|
||||||
|
padding: 12px 4px 14px 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.plate .plate-caption-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.plate .plate-caption-meta {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--subtext0);
|
||||||
|
white-space: nowrap;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The little exhibit number stuck to the corner of a plate */
|
||||||
|
.plate-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 14px;
|
||||||
|
background: var(--mauve);
|
||||||
|
color: var(--rosewater);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: 0 2px 6px -2px rgba(20, 16, 12, 0.45);
|
||||||
|
}
|
||||||
|
.plate-tag-mini {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 18px;
|
||||||
|
right: 18px;
|
||||||
|
background: rgba(20, 16, 12, 0.78);
|
||||||
|
color: var(--rosewater);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nameplate — the museum-style header used in the site chrome */
|
||||||
|
.nameplate {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nameplate::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -6px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
var(--mauve) 0%,
|
||||||
|
var(--mauve) 35%,
|
||||||
|
var(--surface2) 35%,
|
||||||
|
var(--surface2) 100%);
|
||||||
|
}
|
||||||
|
.nameplate-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nameplate-subtitle {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section ornaments */
|
||||||
|
.section-rule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--subtext0);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.section-rule::before,
|
||||||
|
.section-rule::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.section-rule .ornament {
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrawled handwritten margin notes */
|
||||||
|
.scrawl {
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
color: var(--mauve);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
transform: rotate(-6deg);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.scrawl-mark::before {
|
||||||
|
content: "✕";
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
color: var(--red);
|
||||||
|
margin-right: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stripe (Matisse cutout) chip used for tags */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
background: color-mix(in srgb, var(--surface0) 80%, transparent);
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
color: var(--subtext1);
|
||||||
|
border-radius: 1px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.chip-accent {
|
||||||
|
background: var(--mauve);
|
||||||
|
color: var(--rosewater);
|
||||||
|
border-color: var(--mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card / glass — keep the name but reinterpret as a paper card */
|
||||||
.glass {
|
.glass {
|
||||||
background-color: color-mix(in srgb, var(--surface0) 75%, transparent);
|
background-color: color-mix(in srgb, var(--surface0) 80%, transparent);
|
||||||
backdrop-filter: blur(12px);
|
border: 1px solid var(--surface2);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
box-shadow:
|
||||||
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
|
||||||
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.25);
|
0 10px 30px -20px rgba(20, 16, 12, 0.45);
|
||||||
border-radius: 1rem;
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.salon-noir .glass {
|
||||||
|
background-color: color-mix(in srgb, var(--surface0) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Don't double-blur nested glass surfaces */
|
/* ───── Buttons ───── */
|
||||||
.glass .glass {
|
.btn-stamp {
|
||||||
background-color: color-mix(in srgb, var(--surface0) 50%, transparent);
|
display: inline-flex;
|
||||||
backdrop-filter: none;
|
align-items: center;
|
||||||
-webkit-backdrop-filter: none;
|
gap: 0.5rem;
|
||||||
box-shadow: none;
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
background: var(--mauve);
|
||||||
|
color: var(--rosewater);
|
||||||
|
border: 1px solid var(--mauve);
|
||||||
|
border-radius: 1px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
box-shadow: 0 4px 0 -2px color-mix(in srgb, var(--mauve) 60%, black);
|
||||||
|
}
|
||||||
|
.btn-stamp:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
box-shadow: 0 6px 0 -2px color-mix(in srgb, var(--red) 60%, black);
|
||||||
|
}
|
||||||
|
.btn-stamp:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 1px 0 -1px color-mix(in srgb, var(--mauve) 60%, black);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--subtext1);
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
border-radius: 1px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: var(--mauve);
|
||||||
|
border-color: var(--mauve);
|
||||||
|
background: color-mix(in srgb, var(--mauve) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* hljs token colors — driven by theme tokens */
|
/* Form input look */
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
background: color-mix(in srgb, var(--surface0) 60%, transparent);
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
border-radius: 1px;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--mauve);
|
||||||
|
background: color-mix(in srgb, var(--rosewater) 70%, transparent);
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--subtext0);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hljs token colors — driven by theme tokens, slightly muted for parchment bg */
|
||||||
.hljs { color: var(--text); background: transparent; }
|
.hljs { color: var(--text); background: transparent; }
|
||||||
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--mauve); font-weight: 600; }
|
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--mauve); font-weight: 600; }
|
||||||
.hljs-string, .hljs-attr { color: var(--green); }
|
.hljs-string, .hljs-attr { color: var(--green); }
|
||||||
.hljs-number, .hljs-literal { color: var(--peach); }
|
.hljs-number, .hljs-literal { color: var(--peach); }
|
||||||
.hljs-comment, .hljs-quote { color: var(--overlay1); font-style: italic; }
|
.hljs-comment, .hljs-quote { color: var(--overlay0); font-style: italic; }
|
||||||
.hljs-title, .hljs-section, .hljs-name { color: var(--blue); }
|
.hljs-title, .hljs-section, .hljs-name { color: var(--blue); }
|
||||||
.hljs-type, .hljs-class .hljs-title { color: var(--yellow); }
|
.hljs-type, .hljs-class .hljs-title { color: var(--yellow); }
|
||||||
.hljs-variable, .hljs-template-variable { color: var(--red); }
|
.hljs-variable, .hljs-template-variable { color: var(--red); }
|
||||||
|
|
||||||
/* KaTeX inherits prose color */
|
/* KaTeX */
|
||||||
.katex { color: var(--text); }
|
.katex { color: var(--text); }
|
||||||
|
|
||||||
/* Skeleton loader */
|
/* Skeleton loader */
|
||||||
@@ -308,7 +795,7 @@ code, pre, kbd, samp {
|
|||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||||
border-radius: 0.5rem;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
@keyframes skeleton-shimmer {
|
@keyframes skeleton-shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
@@ -321,13 +808,15 @@ code, pre, kbd, samp {
|
|||||||
bottom: 1.5rem;
|
bottom: 1.5rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--surface0);
|
background: var(--mantle);
|
||||||
border: 1px solid var(--surface1);
|
border: 1px solid var(--surface2);
|
||||||
color: var(--text);
|
color: var(--rosewater);
|
||||||
padding: 0.65rem 1.1rem;
|
padding: 0.65rem 1.1rem;
|
||||||
border-radius: 0.6rem;
|
border-radius: 1px;
|
||||||
box-shadow: 0 8px 24px -8px rgba(0,0,0,0.4);
|
box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.45);
|
||||||
font-size: 0.85rem;
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
animation: toast-in 0.2s ease;
|
animation: toast-in 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -335,3 +824,39 @@ code, pre, kbd, samp {
|
|||||||
from { opacity: 0; transform: translate(-50%, 8px); }
|
from { opacity: 0; transform: translate(-50%, 8px); }
|
||||||
to { opacity: 1; transform: translate(-50%, 0); }
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Salon grid spans driven by --col-span custom prop (avoids Tailwind dynamic class issue). */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.md-col-span {
|
||||||
|
grid-column: span var(--col-span, 6) / span var(--col-span, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle page enter animation for gallery / plaque */
|
||||||
|
@keyframes plate-fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.plate-enter {
|
||||||
|
opacity: 0;
|
||||||
|
animation: plate-fade-up 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom checkbox accent for form bits inside the salon */
|
||||||
|
input[type="checkbox"] { accent-color: var(--mauve); }
|
||||||
|
input[type="date"] { color-scheme: light; }
|
||||||
|
.salon-noir input[type="date"] { color-scheme: dark; }
|
||||||
|
|
||||||
|
/* Reading progress bar - thin terracotta line */
|
||||||
|
.reading-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--mauve);
|
||||||
|
z-index: 150;
|
||||||
|
transform-origin: left;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 80ms linear;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user