Files
roux/static/js/app.js
T
valknar ba9a9dc9c4 Update page title, URL and meta when navigating between lightbox slides
goToSlide() now calls history.replaceState + updates document.title,
description, og:title/description/url and canonical on each slide change.
replaceState is used so swipe/keyboard navigation doesn't pollute history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 10:03:08 +02:00

591 lines
22 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>');
}
function setMeta(attr, key, val) {
let el = document.querySelector(`meta[${attr}="${key}"]`);
if (val !== null && val !== undefined && val !== '') {
if (!el) { el = document.createElement('meta'); el.setAttribute(attr, key); document.head.appendChild(el); }
el.setAttribute('content', val);
} else if (el) {
el.remove();
}
}
function syncHeadMeta(doc) {
const canon = doc.querySelector('link[rel="canonical"]');
const curCanon = document.querySelector('link[rel="canonical"]');
if (canon && curCanon) curCanon.href = canon.href;
[
['name', 'description'],
['property', 'og:title'],
['property', 'og:description'],
['property', 'og:url'],
['property', 'og:image'],
['property', 'og:image:width'],
['property', 'og:image:height'],
['name', 'twitter:card'],
['name', 'twitter:image'],
].forEach(([attr, key]) => {
const src = doc.querySelector(`meta[${attr}="${key}"]`);
setMeta(attr, key, src ? src.getAttribute('content') : null);
});
}
// ── 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
let imgZoomOpen = false;
let imgZoomIdx = -1;
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}`;
}
const p = lbList[lbIdx];
lbBuildMeta(p);
syncThumbs();
preloadNeighbors(lbIdx);
if (p && smooth) {
const postTitle = p.title + ' — Roux';
const postUrl = new URL(p.url, location.origin).href;
document.title = postTitle;
history.replaceState({ slug: p.slug }, '', p.url);
setMeta('name', 'description', p.description || null);
setMeta('property', 'og:title', postTitle);
setMeta('property', 'og:description', p.description || null);
setMeta('property', 'og:url', postUrl);
const canon = document.querySelector('link[rel="canonical"]');
if (canon) canon.href = postUrl;
}
}
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') { if (imgZoomOpen) closeImgZoom(); else lbClose(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); imgZoomOpen ? goToZoomSlide(imgZoomIdx - 1) : goToSlide(lbIdx - 1); }
if (e.key === 'ArrowRight') { e.preventDefault(); imgZoomOpen ? goToZoomSlide(imgZoomIdx + 1) : 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);
if (clicked) {
const postTitle = clicked.title + ' — Roux';
const postUrl = new URL(clicked.url, location.origin).href;
document.title = postTitle;
setMeta('name', 'description', clicked.description || null);
setMeta('property', 'og:title', postTitle);
setMeta('property', 'og:description', clicked.description || null);
setMeta('property', 'og:url', postUrl);
const canon = document.querySelector('link[rel="canonical"]');
if (canon) canon.href = postUrl;
}
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;
syncHeadMeta(doc);
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);
}
// ── Full-image zoom overlay
const imgZoom = document.getElementById('imgZoom');
const imgZoomImg = document.getElementById('imgZoomImg');
function openImgZoom(src) {
if (!imgZoom || !imgZoomImg || !src) return;
imgZoomImg.src = src;
imgZoomIdx = lbIdx;
imgZoom.dataset.open = 'true';
imgZoomOpen = true;
document.body.style.overflow = 'hidden';
}
function closeImgZoom() {
if (!imgZoom) return;
imgZoom.dataset.open = 'false';
imgZoomOpen = false;
if (!lb || lb.dataset.open !== 'true') document.body.style.overflow = '';
}
function goToZoomSlide(idx) {
if (!lbList.length || !imgZoomImg) return;
imgZoomIdx = Math.max(0, Math.min(idx, lbList.length - 1));
const p = lbList[imgZoomIdx];
if (p) imgZoomImg.src = p.card || p.thumb || '';
goToSlide(imgZoomIdx);
}
// Hero "View full image" button
const viewFullBtn = document.getElementById('viewFull');
if (viewFullBtn) {
viewFullBtn.addEventListener('click', () => openImgZoom(viewFullBtn.dataset.src));
}
// Click on the lightbox main image → zoom
document.addEventListener('click', e => {
const img = e.target.closest('.lb-img');
if (img) openImgZoom(img.src);
});
// Close on backdrop click or close button
if (imgZoom) {
imgZoom.addEventListener('click', e => { if (e.target === imgZoom) closeImgZoom(); });
document.getElementById('imgZoomClose')?.addEventListener('click', closeImgZoom);
// Swipe to navigate
let zoomTouchX = null;
imgZoom.addEventListener('touchstart', e => { zoomTouchX = e.touches[0].clientX; }, { passive: true });
imgZoom.addEventListener('touchend', e => {
if (zoomTouchX === null) return;
const dx = e.changedTouches[0].clientX - zoomTouchX;
if (Math.abs(dx) > 50) dx < 0 ? goToZoomSlide(imgZoomIdx + 1) : goToZoomSlide(imgZoomIdx - 1);
zoomTouchX = null;
});
}
// Escape when lightbox is NOT open (standalone zoom)
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && imgZoomOpen && (!lb || lb.dataset.open !== 'true')) closeImgZoom();
});
})();