Files
roux/assets/js/app.js
T
valknar 50047451d9 Fix lightbox thumb strip not scrolling to current plate on direct load
lbBuildThumbs() was called after goToSlide(), so the scroll in
syncThumbs() ran against an empty container. Reorder so thumbs are
built before goToSlide() positions them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:53:25 +02:00

473 lines
18 KiB
JavaScript

/* ROUX — app.js */
(function () {
'use strict';
// ── Data (injected by Hugo into a JSON script tag)
let POSTS = [];
try {
const el = document.getElementById('roux-data');
if (el) POSTS = JSON.parse(el.textContent) || [];
} catch (e) { console.warn('roux-data parse failed', e); }
const STOP = new Set(['the','a','an','of','and','in','on','at','by','with','is','to','for','from','as','into','onto','its','it','that','this','but','or','be','not','no','one','two','three']);
// ── Helpers
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function highlight(text, terms) {
if (!terms.length) return esc(text);
const re = new RegExp('(' + terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'ig');
return esc(text).replace(re, '<mark class="hl">$1</mark>');
}
// ── Inverted search index
const INDEX = (() => {
const map = new Map();
function add(token, idx, weight) {
const t = token.toLowerCase();
if (!t || t.length < 2 || STOP.has(t)) return;
let b = map.get(t);
if (!b) { b = new Map(); map.set(t, b); }
b.set(idx, (b.get(idx) || 0) + weight);
}
function tok(s) { return (s || '').toLowerCase().split(/[^a-z0-9àâäéèêëîïôöùûüçœæñ]+/i).filter(Boolean); }
POSTS.forEach((p, i) => {
tok(p.title).forEach(t => add(t, i, 6));
(p.categories || []).forEach(c => tok(c).forEach(t => add(t, i, 4)));
(p.tags || []).forEach(tag => tok(tag).forEach(t => add(t, i, 5)));
tok(p.description).forEach(t => add(t, i, 1));
tok(p.id).forEach(t => add(t, i, 8));
});
return function search(q) {
const tokens = tok(q);
if (!tokens.length) return null;
const perToken = tokens.map(tok => {
const merged = new Map();
for (const [term, b] of map) {
if (term.startsWith(tok)) {
const factor = term === tok ? 1.0 : tok.length / term.length;
for (const [pi, w] of b) merged.set(pi, (merged.get(pi) || 0) + w * factor);
}
}
return merged;
});
const first = perToken[0];
const scores = new Map();
for (const [idx, w] of first) {
let total = w, ok = true;
for (let i = 1; i < perToken.length; i++) {
const v = perToken[i].get(idx);
if (!v) { ok = false; break; }
total += v;
}
if (ok) scores.set(idx, total);
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([i]) => POSTS[i]);
};
})();
// ── Masthead date
const mhDate = document.getElementById('mhDate');
if (mhDate) {
const d = new Date();
mhDate.textContent = d.toLocaleDateString('en-GB', { day:'numeric', month:'long', year:'numeric' });
}
// ── Count display
const countEl = document.getElementById('count');
function setCount(n) {
if (!countEl) return;
countEl.innerHTML = `<b>${String(n).padStart(3,'0')}</b> ${n === 1 ? 'post' : 'posts'}`;
}
setCount(POSTS.length);
// ── Tabs: sync active state with current URL
function syncTabs() {
const tabs = document.querySelectorAll('.tabs button[data-cat]');
const path = location.pathname;
tabs.forEach(btn => {
const cat = btn.dataset.cat;
const slug = cat.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const active = (cat === 'All' && (path === '/' || path.startsWith('/issues') || path === '/posts/'))
|| (cat !== 'All' && path === `/categories/${slug}/`);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
syncTabs();
// Tab click → navigate to category page
document.addEventListener('click', e => {
const btn = e.target.closest('.tabs button[data-cat]');
if (!btn) return;
const cat = btn.dataset.cat;
if (cat === 'All') navigate('/');
else navigate('/categories/' + cat.toLowerCase().replace(/[\s,]+/g, '-').replace(/[^a-z0-9-]/g, '') + '/');
});
// ── Search popup
const searchInput = document.getElementById('searchInput');
const searchPop = document.getElementById('searchpop');
function allCats() {
const s = new Set();
POSTS.forEach(p => (p.categories || []).forEach(c => s.add(c)));
return Array.from(s).slice(0, 8);
}
function allTags() {
const freq = new Map();
POSTS.forEach(p => (p.tags || []).forEach(t => freq.set(t, (freq.get(t)||0)+1)));
return Array.from(freq.entries()).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([t])=>t);
}
function renderSearchPop(q) {
if (!searchPop) return;
if (!q) {
const cats = allCats();
const tags = allTags();
searchPop.innerHTML = `
<div class="sp-section">
<div class="sp-label">Categories <small>${cats.length}</small></div>
<div class="sp-chips">
${cats.map(c => `<button class="sp-chip" data-jump="/categories/${encodeURIComponent(c.toLowerCase().replace(/[\s,]+/g,'-').replace(/[^a-z0-9-]/g,''))}/"> ${esc(c)}</button>`).join('')}
</div>
</div>
<div class="sp-section">
<div class="sp-label">Popular tags <small>${tags.length}</small></div>
<div class="sp-chips">
${tags.map(t => `<button class="sp-chip" data-jump="/tags/${encodeURIComponent(t.toLowerCase().replace(/[\s,]+/g,'-').replace(/[^a-z0-9-]/g,''))}/"> # ${esc(t)}</button>`).join('')}
</div>
</div>`;
searchPop.dataset.open = 'true';
return;
}
const hits = INDEX(q) || [];
const terms = q.toLowerCase().split(/\s+/).filter(t => t.length > 1);
if (!hits.length) {
searchPop.innerHTML = `<div class="sp-section"><p style="color:var(--ink-soft);font-size:13px;font-style:italic">No plates match — try <em>gothic</em>, <em>warrior</em>, or <em>neon</em>.</p></div>`;
searchPop.dataset.open = 'true';
return;
}
const shown = hits.slice(0, 6);
searchPop.innerHTML = `
<div class="sp-section">
<div class="sp-label">Plates <small>${hits.length}</small></div>
<div class="sp-hits">
${shown.map(p => `
<button class="sp-hit" data-jump="${esc(p.url)}">
${p.thumb ? `<img src="${esc(p.thumb)}" alt="" loading="lazy" />` : '<div style="width:44px;height:66px;background:var(--paper-2)"></div>'}
<div>
<div class="t">${highlight(p.title, terms)}</div>
<div class="s">${highlight((p.categories||[]).join(', '), terms)}</div>
</div>
<div class="n">${esc(p.id)}</div>
</button>`).join('')}
</div>
</div>`;
searchPop.dataset.open = 'true';
}
if (searchInput) {
searchInput.addEventListener('input', () => renderSearchPop(searchInput.value.trim()));
searchInput.addEventListener('focus', () => renderSearchPop(searchInput.value.trim()));
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); searchInput.focus(); renderSearchPop(''); }
if (e.key === 'Escape') closeSearch();
});
}
if (searchPop) {
searchPop.addEventListener('click', e => {
const btn = e.target.closest('[data-jump]');
if (btn) { closeSearch(); navigate(btn.dataset.jump); }
});
}
document.addEventListener('click', e => {
if (searchPop && searchPop.dataset.open === 'true') {
const inSearch = e.target.closest('label[for="searchInput"]')
|| e.target.id === 'searchInput'
|| e.target.closest('#searchpop');
if (!inSearch) closeSearch();
}
});
function closeSearch() {
if (searchPop) { searchPop.dataset.open = 'false'; searchPop.innerHTML = ''; }
if (searchInput) searchInput.blur();
}
// ── Lightbox
const lb = document.getElementById('lb');
const lbTrack = document.getElementById('lbTrack');
const lbMeta = document.getElementById('lbMeta');
const lbThumbs = document.getElementById('lbThumbs');
const lbIndex = document.getElementById('lbIndex');
let lbList = []; // current scoped post list
let lbIdx = -1; // index into lbList
let lbBuilt = false;
let lbReferrer = null; // URL to return to on close
function lbOpen(slug, scopedList) {
lbList = scopedList || POSTS;
const idx = lbList.findIndex(p => p.slug === slug);
if (idx === -1) return;
lbIdx = idx;
if (!lbBuilt) buildLbSlides();
lb.dataset.open = 'true';
document.body.style.overflow = 'hidden';
lbBuildMeta(lbList[lbIdx]);
lbBuildThumbs();
goToSlide(lbIdx, false);
}
function lbClose() {
if (!lb) return;
lb.dataset.open = 'false';
document.body.style.overflow = '';
if (window.__ROUX_OPEN_SLUG) {
// Direct load of a single-post URL — go back to the issue page
const cur = lbList[lbIdx];
const issueId = (cur && cur.issue) || '01';
navigate(`/issues/${issueId}/`);
} else if (lbReferrer) {
// Opened from a grid page — return to it
const ref = lbReferrer;
lbReferrer = null;
navigate(ref);
}
}
function buildLbSlides() {
if (!lbTrack) return;
lbTrack.innerHTML = lbList.map((p, i) => {
const imgSrc = p.card || p.thumb || '';
return `<div class="lb-slide" data-i="${i}">
<div class="lb-frame">
<img class="lb-img" src="${esc(imgSrc)}" alt="${esc(p.title)}" loading="lazy" />
</div>
</div>`;
}).join('');
lbBuilt = true;
preloadNeighbors(lbIdx);
}
function goToSlide(idx, smooth = true) {
if (!lbTrack) return;
lbIdx = Math.max(0, Math.min(idx, lbList.length - 1));
lbTrack.style.transition = smooth ? '' : 'none';
lbTrack.style.transform = `translateX(-${lbIdx * 100}%)`;
if (!smooth) lbTrack.getBoundingClientRect(); // force reflow
if (lbIndex) {
lbIndex.innerHTML = `<b>${String(lbIdx + 1).padStart(3,'0')}</b> / ${lbList.length}`;
}
lbBuildMeta(lbList[lbIdx]);
syncThumbs();
preloadNeighbors(lbIdx);
}
function preloadNeighbors(idx) {
if (!lbTrack) return;
[-1, 0, 1, 2].forEach(d => {
const ni = idx + d;
if (ni < 0 || ni >= lbList.length) return;
const slide = lbTrack.querySelector(`.lb-slide[data-i="${ni}"]`);
if (!slide) return;
const img = slide.querySelector('img');
if (img && !img.src) img.src = lbList[ni].card || lbList[ni].thumb || '';
});
}
function lbBuildMeta(p) {
if (!lbMeta || !p) return;
const cats = (p.categories || []).join(', ');
const tags = (p.tags || []).slice(0, 8);
lbMeta.innerHTML = `
<div class="lb-cat">${esc(cats)}</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>
</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-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>
<span class="lb-sh-l">Copy link</span>
</button>
</div>`;
document.getElementById('lbCopy')?.addEventListener('click', function () {
const url = new URL(this.dataset.url, location.origin).href;
navigator.clipboard.writeText(url).then(() => {
this.classList.add('is-ok');
this.querySelector('.lb-sh-l').textContent = 'Copied!';
setTimeout(() => { this.classList.remove('is-ok'); this.querySelector('.lb-sh-l').textContent = 'Copy link'; }, 2000);
});
});
}
function lbBuildThumbs() {
if (!lbThumbs) return;
lbThumbs.innerHTML = lbList.map((p, i) =>
`<button class="lb-thumb" data-i="${i}" aria-current="${i === lbIdx}" aria-label="${esc(p.title)}">
<img src="${esc(p.thumb || p.card || '')}" alt="" loading="lazy" />
</button>`
).join('');
lbThumbs.addEventListener('click', e => {
const btn = e.target.closest('.lb-thumb');
if (btn) goToSlide(parseInt(btn.dataset.i));
});
}
function syncThumbs() {
if (!lbThumbs) return;
lbThumbs.querySelectorAll('.lb-thumb').forEach((b, i) => {
b.setAttribute('aria-current', i === lbIdx ? 'true' : 'false');
});
const active = lbThumbs.querySelector('.lb-thumb[aria-current="true"]');
if (active) active.scrollIntoView({ inline: 'center', behavior: 'smooth' });
}
// Lightbox controls
if (lb) {
lb.addEventListener('click', e => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'close') lbClose();
else if (act === 'prev') goToSlide(lbIdx - 1);
else if (act === 'next') goToSlide(lbIdx + 1);
});
document.addEventListener('keydown', e => {
if (lb.dataset.open !== 'true') return;
if (e.key === 'Escape') { lbClose(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); goToSlide(lbIdx - 1); }
if (e.key === 'ArrowRight') { e.preventDefault(); goToSlide(lbIdx + 1); }
});
// Touch swipe
let touchX = null;
lb.addEventListener('touchstart', e => { touchX = e.touches[0].clientX; }, { passive: true });
lb.addEventListener('touchend', e => {
if (touchX === null) return;
const dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 50) { dx < 0 ? goToSlide(lbIdx + 1) : goToSlide(lbIdx - 1); }
touchX = null;
});
}
// ── Card clicks → open lightbox (no full page navigation for same-origin)
document.addEventListener('click', e => {
const card = e.target.closest('.card[data-slug]');
if (!card) return;
e.preventDefault();
const slug = card.dataset.slug;
const clicked = POSTS.find(p => p.slug === slug);
const issueId = clicked ? clicked.issue : null;
const scopedList = issueId ? POSTS.filter(p => p.issue === issueId) : POSTS;
lbReferrer = location.href;
history.pushState({ slug }, '', card.href);
lbOpen(slug, scopedList.length ? scopedList : POSTS);
});
// 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 = ''; }
}
});
// ── Ribbon
const ribbon = document.getElementById('ribbon');
const ribbonClose = document.getElementById('ribbonClose');
if (ribbonClose && ribbon) {
if (sessionStorage.getItem('ribbon-closed')) ribbon.classList.add('hidden');
ribbonClose.addEventListener('click', () => {
ribbon.classList.add('hidden');
sessionStorage.setItem('ribbon-closed', '1');
});
}
// ── Async page transitions (View Transitions API)
function isSameOrigin(url) {
try { return new URL(url).origin === location.origin; } catch { return false; }
}
async function navigate(url) {
if (!isSameOrigin(url)) { location.href = url; return; }
if (!document.startViewTransition) {
location.href = url;
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');
const newContent = doc.getElementById('content');
const newData = doc.getElementById('roux-data');
const oldContent = document.getElementById('content');
if (newContent && oldContent) {
oldContent.replaceWith(newContent);
}
if (newData) {
try {
POSTS = JSON.parse(newData.textContent) || [];
lbBuilt = false; // force rebuild on next open
} catch {}
}
document.title = doc.title;
history.pushState({}, '', url);
// 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;
if (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;
lbOpen(openSlug, scoped.length ? scoped : POSTS);
}
});
}
// Intercept link clicks for async transitions
document.addEventListener('click', e => {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest('a[href]');
if (!a || a.target || a.hasAttribute('download')) return;
if (!isSameOrigin(a.href)) return;
// Skip lightbox card clicks (handled above)
if (a.closest('.card[data-slug]')) return;
// Skip anchor links
if (a.href.includes('#')) return;
e.preventDefault();
if (lb && lb.dataset.open === 'true') lbClose();
navigate(a.href);
});
// ── On single-post pages: auto-open lightbox
if (window.__ROUX_OPEN_SLUG && POSTS.length) {
const opened = POSTS.find(p => p.slug === window.__ROUX_OPEN_SLUG);
const issueId = opened ? opened.issue : null;
const scoped = issueId ? POSTS.filter(p => p.issue === issueId) : POSTS;
lbOpen(window.__ROUX_OPEN_SLUG, scoped.length ? scoped : POSTS);
}
})();