Add issue No. 02 "Obsession" + reusable image pipeline

Content
- 100 new plates under content/posts/02/ — one recurring red-haired model
  across 10 editorial registers (Haute Couture, Film Noir, Boudoir,
  Avant-Garde, Minimalism, Baroque, Street Style, Surrealism, Monochrome,
  Nocturne), generated with flux-1.1-pro, face-swapped to a consistent
  identity (FaceFusion), and upscaled (Upscayl + Remacri, 3328x4992).
- content/issues/02/_index.md; issue 01 -> status archived; hugo.toml params
  (issueIds newest-first, issueNumber/Name/Season/Blurb).

Structure
- Plate bundles grouped by issue: content/posts/<issue>/<slug>/, with
  build.render:never stubs so /posts/<issue>/ isn't emitted. nginx 301s the
  legacy flat /posts/<slug>/ URLs to /posts/01/<slug>/. The /posts/ archive
  uses .RegularPagesRecursive.

Image pipeline (scripts/, data/ gitignored)
- generate-images.py  — Replicate flux-1.1-pro, 960x1440, idempotent.
- faceswap-images.py  — FaceFusion batch-run, one consistent face.
- upscale-images.py   — Upscayl + remacri-4x, long edge clamped to 4992.
- build-issue.py      — prompts JSON -> content/posts/<issue>/*/index.md.

Build
- CSS now compiled by Hugo's css.TailwindCSS (partials/css.html +
  templates.Defer); dropped the standalone Tailwind CLI step, concurrently,
  and the gitignored static/css/main.css. Requires Hugo >= 0.161; Dockerfile
  collapsed to a single hugomods/hugo:debian-node build stage.
  pnpm-workspace.yaml: preferSymlinkedExecutables + allowBuilds.

Front end
- Pagination 8 per page.
- Lightbox: brand mark and category link out; robust SPA back/forward
  (fetch before startViewTransition, single-flight guard, swallow the
  transition abort rejection, popstate re-renders / reopens the viewer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZPmxGywFnAhmYJB1eh9fm
This commit is contained in:
2026-08-29 21:11:24 +02:00
co-authored by Claude Sonnet 5
parent 1480f73a8f
commit 2dc7a0063f
432 changed files with 3195 additions and 830 deletions
+78 -39
View File
@@ -13,6 +13,7 @@
// ── Helpers
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function slugify(s) { return String(s).toLowerCase().replace(/[\s,]+/g, '-').replace(/[^a-z0-9-]/g, ''); }
function highlight(text, terms) {
if (!terms.length) return esc(text);
@@ -329,21 +330,23 @@
function lbBuildMeta(p) {
if (!lbMeta || !p) return;
const cats = (p.categories || []).join(', ');
const cat0 = (p.categories || [])[0] || '';
const catLink = cat0
? `<a href="/categories/${slugify(cat0)}/" style="color:inherit;border-bottom:1px solid currentColor">${esc(cat0)}</a>`
: '—';
const tags = (p.tags || []).slice(0, 8);
lbMeta.innerHTML = `
<div class="lb-cat">${esc(cats)}</div>
<div class="lb-cat">${cat0 ? `<a href="/categories/${slugify(cat0)}/">${esc(cat0)}</a>` : ''}</div>
<h2 class="lb-title">${esc(p.title)}</h2>
<p class="lb-desc">${esc(p.description)}</p>
<dl class="lb-facts">
<div class="lb-fact"><dt>Plate</dt><dd>№ ${esc(p.id)}</dd></div>
<div class="lb-fact"><dt>Issue</dt><dd><a href="/issues/${esc(p.issue || '01')}/" style="color:inherit;border-bottom:1px solid currentColor">№ ${esc(p.issue || '01')}</a></dd></div>
<div class="lb-fact"><dt>Category</dt><dd>${esc((p.categories||['—'])[0])}</dd></div>
<div class="lb-fact"><dt>Category</dt><dd>${catLink}</dd></div>
</dl>
<div class="lb-tags">${tags.map(t => {
const slug = t.toLowerCase().replace(/[\s,]+/g, '-').replace(/[^a-z0-9-]/g, '');
return `<a class="lb-tag" href="/tags/${slug}/"># ${esc(t)}</a>`;
}).join('')}</div>
<div class="lb-tags">${tags.map(t =>
`<a class="lb-tag" href="/tags/${slugify(t)}/"># ${esc(t)}</a>`
).join('')}</div>
<div class="lb-share">
<button class="lb-sh lb-sh-primary" id="lbCopy" data-url="${esc(p.url)}">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 1 0-7.07-7.07l-1 1"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 1 0 7.07 7.07l1-1"/></svg>
@@ -434,11 +437,19 @@
lbOpen(slug, scopedList.length ? scopedList : POSTS);
});
// Handle browser back/forward
// Handle browser back/forward.
window.addEventListener('popstate', e => {
if (lb && lb.dataset.open === 'true') {
if (!e.state?.slug) { lb.dataset.open = 'false'; document.body.style.overflow = ''; }
const slug = e.state && e.state.slug;
// Entry points at a plate we already know about → show it in the lightbox now,
// no page fetch needed (covers sliding + card-opened plates).
if (slug && POSTS.some(p => p.slug === slug)) {
const opened = POSTS.find(p => p.slug === slug);
const scoped = POSTS.filter(p => p.issue === opened.issue);
lbOpen(slug, scoped.length ? scoped : POSTS);
return;
}
// Otherwise re-render #content for the URL we landed on (also closes the lightbox).
navigate(location.href, { push: false });
});
// ── Ribbon
@@ -453,54 +464,83 @@
}
// ── Async page transitions (View Transitions API)
function absUrl(url) {
try { return new URL(url, location.href).href; } catch { return null; }
}
function isSameOrigin(url) {
try { return new URL(url).origin === location.origin; } catch { return false; }
const a = absUrl(url);
return !!a && new URL(a).origin === location.origin;
}
async function navigate(url) {
if (!isSameOrigin(url)) { location.href = url; return; }
let navigating = false;
if (!document.startViewTransition) {
location.href = url;
// push:false → the URL is already current (back/forward); don't add a history entry.
async function navigate(url, opts) {
const push = !opts || opts.push !== false;
const abs = absUrl(url);
if (!abs || !isSameOrigin(abs)) { location.href = url; return; }
if (navigating) return; // one transition at a time
navigating = true;
let doc;
try {
const res = await fetch(abs, { headers: { 'X-Requested-With': 'fetch' } });
doc = new DOMParser().parseFromString(await res.text(), 'text/html');
} catch {
navigating = false;
location.href = abs; // network failed — hard load
return;
}
document.startViewTransition(async () => {
const res = await fetch(url, { headers: { 'X-Requested-With': 'fetch' } });
const text = await res.text();
const doc = new DOMParser().parseFromString(text, 'text/html');
let applied = false;
const apply = () => {
if (applied) return;
applied = true;
const newContent = doc.getElementById('content');
const newData = doc.getElementById('roux-data');
const oldContent = document.getElementById('content');
const newContent = doc.getElementById('content');
const oldContent = document.getElementById('content');
if (newContent && oldContent) oldContent.replaceWith(newContent);
if (newContent && oldContent) {
oldContent.replaceWith(newContent);
}
const newData = doc.getElementById('roux-data');
if (newData) {
try {
POSTS = JSON.parse(newData.textContent) || [];
lbBuilt = false; // force rebuild on next open
} catch {}
try { POSTS = JSON.parse(newData.textContent) || []; lbBuilt = false; } catch {}
}
const openSlug = doc.querySelector('[data-open-slug]')?.dataset.openSlug || null;
document.title = doc.title;
syncHeadMeta(doc);
history.pushState({}, '', url);
if (push) history.pushState({ url: abs, slug: openSlug || undefined }, '', abs);
// Re-init page state
setCount(POSTS.length);
syncTabs();
// If new page has a slug to open
const openSlug = window.__ROUX_OPEN_SLUG = doc.querySelector('[data-open-slug]')?.dataset.openSlug || null;
window.__ROUX_OPEN_SLUG = openSlug;
if (openSlug) {
const opened = POSTS.find(p => p.slug === openSlug);
const opened = POSTS.find(p => p.slug === openSlug);
const issueId = opened ? opened.issue : null;
const scoped = issueId ? POSTS.filter(p => p.issue === issueId) : POSTS;
const scoped = issueId ? POSTS.filter(p => p.issue === issueId) : POSTS;
lbOpen(openSlug, scoped.length ? scoped : POSTS);
} else if (lb && lb.dataset.open === 'true') {
lb.dataset.open = 'false';
document.body.style.overflow = '';
lbReferrer = null;
}
});
};
try {
if (document.startViewTransition) {
const t = document.startViewTransition(apply);
t.finished.catch(() => {}); // superseded/aborted transitions are fine
await t.updateCallbackDone;
} else {
apply();
}
} catch {
apply(); // startViewTransition threw or was aborted
} finally {
navigating = false;
}
}
// Intercept link clicks for async transitions
@@ -512,10 +552,9 @@
// Skip lightbox card clicks (handled above)
if (a.closest('.card[data-slug]')) return;
// Skip anchor links
if (a.href.includes('#')) return;
if (a.getAttribute('href').includes('#')) return;
e.preventDefault();
if (lb && lb.dataset.open === 'true') lbClose();
navigate(a.href);
navigate(a.href); // navigate() closes the lightbox if needed
});
// ── On single-post pages: auto-open lightbox