From 0da5b24dc35c1c5bb3b25fe654eef3c359881730 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 21 May 2026 04:29:43 +0200 Subject: [PATCH] updated readme + just --- Justfile | 26 ++++ README.md | 117 ++++++++---------- frontend/src/components/CyberFx.astro | 21 +++- frontend/src/lib/cybersigil.ts | 100 ++++++++------- .../src/styles/partials/70-cybersigil.css | 41 ++++-- 5 files changed, 177 insertions(+), 128 deletions(-) create mode 100644 Justfile diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..dfc253d --- /dev/null +++ b/Justfile @@ -0,0 +1,26 @@ +default: + @just --list + +# Install dependencies for both backend and frontend +setup: + cd backend && cargo fetch + cd frontend && npm install + +# Build backend and frontend +build: + cd backend && cargo build + cd frontend && npm run build + +# Run tests for both backend and frontend +test: + cd backend && cargo test + cd frontend && npm test + +# Start the stack with Docker Compose +docker: + docker compose up --build + +# Run backend and frontend for development +dev: + @echo "Starting backend and frontend..." + (cd backend && cargo run) & (cd frontend && npm run dev) & wait diff --git a/README.md b/README.md index fdd8174..534d22d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Ela's Atelier +# narlblog -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. +A minimalist, filesystem-backed portfolio and blog engine. Rust/Axum API for the backend and Astro/React for the frontend. ``` backend/ Rust + Axum API (filesystem-backed) @@ -10,102 +10,91 @@ data/ Runtime data — posts, uploads, config.json (host volume) ## Quick start -```sh -cp .env.example .env -# Generate a strong admin token: -echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env +1. **Environment Setup**: + ```sh + cp .env.example .env + # Generate a strong admin token: + echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env + ``` -docker compose up --build -# → http://localhost:4321 -# → log in at /admin/login with the token from .env -``` +2. **Using Just**: + This project uses `just` to simplify common tasks. + ```sh + just setup # Install backend and frontend dependencies + just dev # Run both backend and frontend locally in parallel + just docker # Start the full stack using Docker Compose + ``` -Make sure the `data/` host directory is owned by UID 1000 (the backend container's app user): +3. **Permissions**: + Ensure the `data/` host directory is owned by UID 1000: + ```sh + sudo chown -R 1000:1000 data/ + ``` -```sh -sudo chown -R 1000:1000 data/ -``` +## Site Modes + +The engine supports two distinct modes via the `SITE_MODE` environment variable: + +- **`atelier` (Default)**: Optimized for art portfolios. Requires at least one image per post; the first image serves as the cover plate in the gallery view. +- **`blog`**: A traditional text-focused blog layout. Images are optional, and the index prioritizes titles and excerpts. + +## Commands + +| Command | Description | +| -------------- | ------------------------------------------------ | +| `just setup` | Installs dependencies for backend and frontend. | +| `just build` | Builds production binaries and assets. | +| `just dev` | Runs both services locally for development. | +| `just docker` | Starts the stack using `docker compose`. | +| `just test` | Runs the test suite for both services. | ## Environment | Variable | Required | Default | Notes | | ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- | | `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | +| `SITE_MODE` | no | `atelier` | `atelier` (art portfolio) or `blog` (traditional blog). | | `PORT` | no | `3000` | Backend port. | | `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | | `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | | `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | -| `RUST_LOG` | no | `info` | tracing-subscriber filter. | | `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | -## Local development +## Authoring -Backend: - -```sh -cd backend -ADMIN_TOKEN=devtoken COOKIE_SECURE=false DATA_DIR=../data cargo run -``` - -Frontend (separate terminal, Node ≥ 22.12): - -```sh -cd frontend -npm install -npm run dev -# → http://localhost:4321 (proxies to PUBLIC_API_URL, default http://backend:3000) -``` - -For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`. - -## Authoring a work - -Each work is a markdown file at `data/posts/.md` with YAML frontmatter. Every work **must** contain at least one markdown image — the first image becomes the cover plate on the gallery index. Image alt text becomes the figure caption on the work page. +Posts are stored as markdown files in `data/posts/.md` with YAML frontmatter. ```markdown --- date: 2026-05-09 -summary: Optional short caption shown beneath the plate on the index. +title: My First Post +summary: Optional short caption or excerpt. tags: - - oil + - general - 2026 draft: false --- -# Untitled (charcoal on paper) +# Content Title -![A view of the cliff at dawn](/uploads/cliff-dawn.jpg "Plate I — graphite, A3") +Post content goes here. In `atelier` mode, include at least one image: -Notes on the piece: materials, references, what worked, what didn't. - -![Detail of the foreground](/uploads/cliff-detail.jpg "Plate II — detail") +![Alt text](/uploads/image.jpg "Caption") ``` -- `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. +- `draft: true` hides a post from the public index. +- Access the web editor at `/admin/editor` after logging in at `/admin/login`. ## Uploads -Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, ogg, mp4, webm, mov. Magic bytes are checked against the extension. SVG and HTML are intentionally rejected — `/uploads/*` is served as-is, so any active content there would be XSS. - -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ć. +Supported: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, ogg, mp4, webm, mov. +Max size: 50 MB. ## Backups -The deployed `data/` directory is the entire gallery. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in. - -```sh -rsync -av data/ backup-host:/path/to/gallery-data/ -``` +The `data/` directory contains all state (posts, uploads, configuration). Backup this folder regularly. ## Stack -- **Backend**: Rust 2024 edition (requires 1.85+), axum 0.8, serde_yaml frontmatter, subtle constant-time auth, infer for upload sniffing -- **Frontend**: Astro 6, React 19, Tailwind 4, marked + marked-katex-extension + marked-highlight + DOMPurify -- **Editor**: CodeMirror 6 with vim mode and asset autocomplete +- **Backend**: Rust 2024, Axum 0.8 +- **Frontend**: Astro 6, React 19, Tailwind 4 +- **Editor**: CodeMirror 6 with Vim mode diff --git a/frontend/src/components/CyberFx.astro b/frontend/src/components/CyberFx.astro index 3759fc1..3d76d5c 100644 --- a/frontend/src/components/CyberFx.astro +++ b/frontend/src/components/CyberFx.astro @@ -78,18 +78,21 @@ } } - /* ─── Ambient: entrance fade-in (opacity:0 → target via the CSS - * transition on first apply) + scroll-depth recede. Parallax is - * disabled for now — the --cs-px/py/cx/cy vars default to 0px so the - * wire/corner transforms stay put; re-enable by driving those vars - * from scroll/pointer here again. ─── */ + /* ─── Ambient: entrance fade-in + scroll recede + parallax ─── */ if (teardown) teardown(); const off: Array<() => void> = []; let depth = 0, raf = 0; + let mx = 0, my = 0; // mouse relative (-1..1) const apply = () => { raf = 0; fx.style.opacity = String(1 - 0.5 * depth); + + // subtle parallax drift + root.style.setProperty('--cs-px', `${(mx * 15).toFixed(1)}px`); + root.style.setProperty('--cs-py', `${(my * 15).toFixed(1)}px`); + root.style.setProperty('--cs-cx', `${(mx * -8).toFixed(1)}px`); + root.style.setProperty('--cs-cy', `${(my * -8).toFixed(1)}px`); }; const schedule = () => { if (!raf) raf = requestAnimationFrame(apply); }; @@ -98,10 +101,18 @@ depth = Math.max(0, Math.min(1, window.scrollY / vh)); schedule(); }; + const onMouseMove = (e: MouseEvent) => { + mx = (e.clientX / window.innerWidth) * 2 - 1; + my = (e.clientY / window.innerHeight) * 2 - 1; + schedule(); + }; + window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); + window.addEventListener('mousemove', onMouseMove, { passive: true }); off.push(() => window.removeEventListener('scroll', onScroll)); off.push(() => window.removeEventListener('resize', onScroll)); + off.push(() => window.removeEventListener('mousemove', onMouseMove)); onScroll(); /* Freeze every loop while the tab is hidden — idle-battery win. */ diff --git a/frontend/src/lib/cybersigil.ts b/frontend/src/lib/cybersigil.ts index 1147078..dfb0b25 100644 --- a/frontend/src/lib/cybersigil.ts +++ b/frontend/src/lib/cybersigil.ts @@ -70,7 +70,7 @@ export function buildCybersigil(opts: SigilOptions = {}): string { }; // Catmull-Rom → cubic Bézier through an ordered point list (organic sweep). - const spline = (pts: Pt[]): string => { + const spline = (pts: Pt[], tension = 6): string => { if (pts.length < 2) return ''; let d = `M${n(pts[0][0])} ${n(pts[0][1])}`; for (let i = 0; i < pts.length - 1; i++) { @@ -78,8 +78,8 @@ export function buildCybersigil(opts: SigilOptions = {}): string { const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] ?? p2; - const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; - const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; + const c1: Pt = [p1[0] + (p2[0] - p0[0]) / tension, p1[1] + (p2[1] - p0[1]) / tension]; + const c2: Pt = [p2[0] - (p3[0] - p1[0]) / tension, p2[1] - (p3[1] - p1[1]) / tension]; d += `C${n(c1[0])} ${n(c1[1])} ${n(c2[0])} ${n(c2[1])} ${n(p2[0])} ${n(p2[1])}`; track(c1[0]); track(c2[0]); @@ -94,10 +94,11 @@ export function buildCybersigil(opts: SigilOptions = {}): string { if (tip[0] < -3) tip[0] = -3; track(base[0]); track(tip[0]); - if (rng() < 0.45) { + if (rng() < 0.55) { + const k = rng() < 0.3 ? 0.6 : 0.3; // varying kink intensity const mid: Pt = [ - (base[0] + tip[0]) / 2 + Math.cos(ang + Math.PI / 2) * len * 0.3, - (base[1] + tip[1]) / 2 + Math.sin(ang + Math.PI / 2) * len * 0.3, + (base[0] + tip[0]) / 2 + Math.cos(ang + Math.PI / 2) * len * k, + (base[1] + tip[1]) / 2 + Math.sin(ang + Math.PI / 2) * len * k, ]; emit( `M${n(base[0])} ${n(base[1])}Q${n(mid[0])} ${n(mid[1])} ${n(tip[0])} ${n(tip[1])}`, @@ -108,10 +109,10 @@ export function buildCybersigil(opts: SigilOptions = {}): string { } }; - const ornament = (at: Pt, ang: number) => { + const ornament = (at: Pt, ang: number, scaleFactor = 1) => { const g = pick(GLYPHS); - const s = rnd(0.45, 0.85); - const deg = (ang * 180) / Math.PI + rnd(-25, 25); + const s = rnd(0.4, 0.75) * scaleFactor; + const deg = (ang * 180) / Math.PI + rnd(-20, 20); track(at[0] + g.w * s); parts.push( `` + @@ -121,46 +122,51 @@ export function buildCybersigil(opts: SigilOptions = {}): string { }; // Recursive limb: a curved sweep out from (ox,oy) along `ang`, hooking back, - // scattering barbs, shedding a filament shadow, branching, sometimes tipped - // with a motif. + // scattering barbs, shedding multiple filament shadows, branching. const limb = (ox: number, oy: number, ang: number, scale: number, depth: number) => { if (strokeCount >= MAX_PATHS) return; - const L = scale * rnd(34, 64); + const L = scale * rnd(38, 72); const dx = Math.cos(ang) * L; const dy = Math.sin(ang) * L; const peak: Pt = [ox + dx, oy + dy]; const mid: Pt = [ - ox + dx * 0.45 + Math.cos(ang + Math.PI / 2) * rnd(-10, 14), - oy + dy * 0.45 + Math.sin(ang + Math.PI / 2) * rnd(-10, 14), + ox + dx * 0.45 + Math.cos(ang + Math.PI / 2) * rnd(-12, 16), + oy + dy * 0.45 + Math.sin(ang + Math.PI / 2) * rnd(-12, 16), ]; - // hook back toward the spine + // hook back toward the spine with more tension const hook: Pt = [ - peak[0] - Math.cos(ang) * L * rnd(0.3, 0.55), - peak[1] + Math.sin(ang + 0.7) * L * rnd(0.25, 0.5), + peak[0] - Math.cos(ang) * L * rnd(0.35, 0.6), + peak[1] + Math.sin(ang + 0.8) * L * rnd(0.3, 0.55), ]; - const tail: Pt = [Math.max(-2, hook[0] - L * rnd(0.2, 0.4)), hook[1] + rnd(-6, 10)]; + const tail: Pt = [Math.max(-2, hook[0] - L * rnd(0.25, 0.45)), hook[1] + rnd(-4, 12)]; const pts: Pt[] = [[ox, oy], mid, peak, hook, tail]; - emit(spline(pts), 'cs-sig-main'); + emit(spline(pts, 5.5), 'cs-sig-main'); // terminal spike off the outermost point - barb(peak, ang + rnd(-0.5, 0.5), scale * rnd(8, 18)); + barb(peak, ang + rnd(-0.4, 0.4), scale * rnd(10, 22)); - // filament shadow trailing the main sweep - if (rng() < 0.4) { - const off = rnd(2, 6); + // denser filament shadows trailing the main sweep + const numFilaments = rng() < 0.5 ? 2 : 1; + for (let f = 0; f < numFilaments; f++) { + const off = rnd(2, 8) * (f + 1); + const drift = rnd(-0.2, 0.2); emit( spline( pts.map( ([x, y]) => - [x + Math.cos(ang + Math.PI / 2) * off, y + Math.sin(ang + Math.PI / 2) * off] as Pt, + [ + x + Math.cos(ang + Math.PI / 2 + drift) * off, + y + Math.sin(ang + Math.PI / 2 + drift) * off, + ] as Pt, ), + 6.5, ), 'cs-sig-fil', ); } // barb scatter along the chord - const nb = 1 + Math.floor(rng() * 2); + const nb = 2 + Math.floor(rng() * 2); for (let k = 0; k < nb; k++) { const t = (k + 1) / (nb + 1); const seg = t < 0.5 ? [pts[0], pts[2], t * 2] : [pts[2], pts[4], (t - 0.5) * 2]; @@ -169,56 +175,56 @@ export function buildCybersigil(opts: SigilOptions = {}): string { const tt = seg[2] as number; const base: Pt = [a[0] + (b[0] - a[0]) * tt, a[1] + (b[1] - a[1]) * tt]; const side = k % 2 ? 1 : -1; - barb(base, ang + side * rnd(0.6, 1.3), scale * rnd(6, 16)); + barb(base, ang + side * rnd(0.7, 1.4), scale * rnd(7, 18)); } - // recurse — one child curls off the mid/peak region - if (depth > 0 && rng() < 0.55) { - const from = rng() < 0.5 ? mid : peak; + // recurse — child curl + if (depth > 0 && rng() < 0.6) { + const from = rng() < 0.4 ? mid : peak; limb( from[0], from[1], - ang + (rng() < 0.5 ? 1 : -1) * rnd(0.5, 1.2), - scale * rnd(0.42, 0.6), + ang + (rng() < 0.5 ? 1 : -1) * rnd(0.6, 1.3), + scale * rnd(0.45, 0.65), depth - 1, ); } - // motif flourish at a terminal tip - if (depth === 0 && rng() < 0.3) ornament(peak, ang); + // motif flourish at terminal region + if (depth === 0 && rng() < 0.35) ornament(peak, ang, 0.9); }; // ── Wavering spine: a curve from top to bottom, gently bowing in +x. - const spineNodes = 5 + Math.floor(rng() * 3); + const spineNodes = 6 + Math.floor(rng() * 3); const spinePts: Pt[] = []; for (let i = 0; i <= spineNodes; i++) { const y = (H * i) / spineNodes; - const x = i === 0 || i === spineNodes ? 0 : rnd(0, 11); + const x = i === 0 || i === spineNodes ? 0 : rnd(0, 14); spinePts.push([x, y]); } - emit(spline(spinePts), 'cs-sig-main'); + emit(spline(spinePts, 5), 'cs-sig-main'); - // ── Branch nodes ride the spine and throw limbs outward. Nodes are - // inset from the very ends and spread the full height so growth flows - // down the whole trunk rather than clumping at the top. - const nodes = opts.count ?? 7 + Math.floor(rng() * 3); // 7–9 + // terminal ornaments on the spine itself (anchors the design) + ornament(spinePts[0], -Math.PI / 2, 1.1); + ornament(spinePts[spinePts.length - 1], Math.PI / 2, 1.1); + + // ── Branch nodes ride the spine and throw limbs outward. + const nodes = opts.count ?? 8 + Math.floor(rng() * 4); // 8–11 for (let i = 0; i < nodes; i++) { - const t = 0.08 + (0.86 * (i + rnd(-0.25, 0.25))) / (nodes - 1); + const t = 0.1 + (0.8 * (i + rnd(-0.2, 0.2))) / (nodes - 1); const tc = Math.max(0.05, Math.min(0.95, t)); const si = Math.min(spinePts.length - 2, Math.floor(tc * (spinePts.length - 1))); const sf = tc * (spinePts.length - 1) - si; const a = spinePts[si]; const b = spinePts[si + 1]; const node: Pt = [a[0] + (b[0] - a[0]) * sf, a[1] + (b[1] - a[1]) * sf]; - // later nodes lean downward so the lower trunk fills out - const bias = -0.25 + tc * 0.7; + const bias = -0.3 + tc * 0.8; const limbs = 1 + Math.floor(rng() * 2); for (let l = 0; l < limbs; l++) { - const ang = bias + rnd(-0.55, 0.55); - limb(node[0], node[1], ang, rnd(0.65, 1.05), 1); + const ang = bias + rnd(-0.6, 0.6); + limb(node[0], node[1], ang, rnd(0.7, 1.1), 1); } - // the odd bare barb straight off the spine keeps the trunk prickly - if (rng() < 0.55) barb(node, bias + rnd(-0.3, 0.3), rnd(7, 14)); + if (rng() < 0.6) barb(node, bias + rnd(-0.4, 0.4), rnd(8, 16)); } const half = parts.join(''); diff --git a/frontend/src/styles/partials/70-cybersigil.css b/frontend/src/styles/partials/70-cybersigil.css index 47bd81c..c76b302 100644 --- a/frontend/src/styles/partials/70-cybersigil.css +++ b/frontend/src/styles/partials/70-cybersigil.css @@ -124,18 +124,29 @@ html.cybersigil body::after { will-change: transform; } .cybersigil .cs-fx-wire .cs-sigil { - filter: drop-shadow(0 0 6px color-mix(in srgb, var(--sky) 35%, transparent)); + filter: + drop-shadow(0 0 4px color-mix(in srgb, var(--sky) 45%, transparent)) + drop-shadow(0 0 12px color-mix(in srgb, var(--sky) 20%, transparent)) + drop-shadow(0 0 30px color-mix(in srgb, var(--mauve) 12%, transparent)); } .cybersigil .cs-fx-wire .cs-sigil path { - animation: cs-redraw 5.5s ease-in-out infinite; + animation: + cs-redraw 6.5s cubic-bezier(0.4, 0, 0.2, 1) infinite, + cs-shimmer 4s ease-in-out infinite alternate; /* negative, per-stroke offset: the field is always mid-carve, never blank */ - animation-delay: calc(var(--i, 0) * -0.34s); + animation-delay: calc(var(--i, 0) * -0.4s); } @keyframes cs-redraw { - 0% { stroke-dashoffset: 1; } + 0% { stroke-dashoffset: 1; opacity: 0.2; } + 15% { opacity: 1; } 35% { stroke-dashoffset: 0; } - 60% { stroke-dashoffset: 0; } - 100% { stroke-dashoffset: -1; } + 65% { stroke-dashoffset: 0; } + 85% { opacity: 1; } + 100% { stroke-dashoffset: -1; opacity: 0.2; } +} +@keyframes cs-shimmer { + from { stroke-opacity: 0.8; stroke-width: inherit; } + to { stroke-opacity: 1; stroke-width: calc(1.1 * inherit); } } /* Random horizontal databend tears — bright displaced bars, mostly absent. */ @@ -198,7 +209,7 @@ html.cybersigil body::after { .cybersigil .cs-sigil path { fill: none; stroke: var(--sky); - stroke-width: 2; + stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; @@ -206,10 +217,14 @@ html.cybersigil body::after { stroke-dashoffset: 1; } /* Stroke-weight tiers — heavy growth, hair filaments, prickly barbs, motifs. */ -.cybersigil .cs-sigil .cs-sig-main { stroke-width: 2.4; } -.cybersigil .cs-sigil .cs-sig-fil { stroke-width: 0.9; opacity: 0.5; } -.cybersigil .cs-sigil .cs-sig-barb { stroke-width: 1.3; } -.cybersigil .cs-sigil .cs-sig-orn { stroke-width: 1.7; opacity: 0.92; } +.cybersigil .cs-sigil .cs-sig-main { stroke-width: 2.8; } +.cybersigil .cs-sigil .cs-sig-fil { stroke-width: 0.6; opacity: 0.35; } +.cybersigil .cs-sigil .cs-sig-barb { stroke-width: 1.5; } +.cybersigil .cs-sigil .cs-sig-orn { + stroke-width: 1.8; + opacity: 0.95; + filter: drop-shadow(0 0 2px var(--sky)); +} @keyframes cs-carve { to { stroke-dashoffset: 0; } @@ -446,7 +461,9 @@ html.cybersigil body::after { .cybersigil .cs-plate-sig .cs-sigil path, .cybersigil .cs-plate-sig .cs-sigil line { stroke: color-mix(in srgb, var(--mauve) 68%, var(--sky)); - filter: drop-shadow(0 0 4px color-mix(in srgb, var(--mauve) 55%, transparent)); + filter: + drop-shadow(0 0 4px color-mix(in srgb, var(--mauve) 55%, transparent)) + drop-shadow(0 0 12px color-mix(in srgb, var(--sky) 25%, transparent)); } .cybersigil .plate-enter:hover .cs-plate-sig, .cybersigil .plate-enter:focus-within .cs-plate-sig {