/* 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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, '$1'); } // ── 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 = `${String(n).padStart(3,'0')} ${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 active = (cat === 'All' && (path === '/' || path === '/posts/' || path.startsWith('/posts'))) || (cat !== 'All' && path.toLowerCase().includes(cat.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''))); 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 = `
Categories ${cats.length}
${cats.map(c => ``).join('')}
Popular tags ${tags.length}
${tags.map(t => ``).join('')}
`; 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 = `

No plates match — try gothic, warrior, or neon.

`; searchPop.dataset.open = 'true'; return; } const shown = hits.slice(0, 6); searchPop.innerHTML = `
Plates ${hits.length}
${shown.map(p => ` `).join('')}
`; 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' && !e.target.closest('.subhead')) 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; 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'; goToSlide(lbIdx, false); lbBuildMeta(lbList[lbIdx]); lbBuildThumbs(); } function lbClose() { if (!lb) return; lb.dataset.open = 'false'; document.body.style.overflow = ''; // pop back to gallery if we're on a single-post URL if (window.__ROUX_OPEN_SLUG) { navigate('/'); } } function buildLbSlides() { if (!lbTrack) return; lbTrack.innerHTML = lbList.map((p, i) => { const imgSrc = p.card || p.thumb || ''; return `
${esc(p.title)}
`; }).join(''); lbBuilt = true; // Preload neighbors 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 = `${String(lbIdx + 1).padStart(3,'0')} / ${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 = `
${esc(cats)}

${esc(p.title)}

${esc(p.description)}

Plate
№ ${esc(p.id)}
Category
${esc((p.categories||['—'])[0])}
${tags.map(t => { const slug = t.toLowerCase().replace(/[\s,]+/g, '-').replace(/[^a-z0-9-]/g, ''); return `# ${esc(t)}`; }).join('')}
`; 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) => `` ).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 visibleSlugs = Array.from(document.querySelectorAll('.card[data-slug]')).map(c => c.dataset.slug); const scopedList = POSTS.filter(p => visibleSlugs.includes(p.slug)); // Update URL without page reload 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 script = doc.querySelector('script[data-open-slug]'); const openSlug = window.__ROUX_OPEN_SLUG = doc.querySelector('[data-open-slug]')?.dataset.openSlug || null; if (openSlug) { const visibleSlugs = Array.from(document.querySelectorAll('.card[data-slug]')).map(c => c.dataset.slug); const scoped = POSTS.filter(p => visibleSlugs.includes(p.slug)); 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(); navigate(a.href); }); // ── On single-post pages: auto-open lightbox if (window.__ROUX_OPEN_SLUG && POSTS.length) { const visibleSlugs = Array.from(document.querySelectorAll('.card[data-slug]')).map(c => c.dataset.slug); const scoped = POSTS.filter(p => visibleSlugs.includes(p.slug)); lbOpen(window.__ROUX_OPEN_SLUG, scoped.length ? scoped : POSTS); } })();