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:
+78
-39
@@ -13,6 +13,7 @@
|
||||
|
||||
// ── Helpers
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
||||
|
||||
Reference in New Issue
Block a user