2026-05-18 16:27:47 +02:00
|
|
|
/* 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, '<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;
|
2026-05-18 17:09:39 +02:00
|
|
|
const active = (cat === 'All' && (path === '/' || path.startsWith('/issue') || path === '/posts/'))
|
2026-05-18 16:27:47 +02:00
|
|
|
|| (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 = `
|
|
|
|
|
<div class="searchpop__section">
|
|
|
|
|
<div class="searchpop__label">Categories <small>${cats.length}</small></div>
|
|
|
|
|
<div class="searchpop__chips">
|
|
|
|
|
${cats.map(c => `<button class="searchpop__chip" data-jump="/categories/${encodeURIComponent(c.toLowerCase().replace(/[\s,]+/g,'-').replace(/[^a-z0-9-]/g,''))}/"> ${esc(c)}</button>`).join('')}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="searchpop__section">
|
|
|
|
|
<div class="searchpop__label">Popular tags <small>${tags.length}</small></div>
|
|
|
|
|
<div class="searchpop__chips">
|
|
|
|
|
${tags.map(t => `<button class="searchpop__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="searchpop__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="searchpop__section">
|
|
|
|
|
<div class="searchpop__label">Plates <small>${hits.length}</small></div>
|
|
|
|
|
<div class="searchpop__hits">
|
|
|
|
|
${shown.map(p => `
|
|
|
|
|
<button class="searchpop__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' && !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 = '';
|
|
|
|
|
if (window.__ROUX_OPEN_SLUG) {
|
2026-05-18 17:09:39 +02:00
|
|
|
const cur = lbList[lbIdx];
|
|
|
|
|
const issueId = (cur && cur.issue) || '01';
|
|
|
|
|
navigate(`/issues/${issueId}/`);
|
2026-05-18 16:27:47 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
// 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 = `<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__factgrid">
|
|
|
|
|
<div class="lb__fact"><dt>Plate</dt><dd>№ ${esc(p.id)}</dd></div>
|
2026-05-18 17:09:39 +02:00
|
|
|
<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>
|
2026-05-18 16:27:47 +02:00
|
|
|
<div class="lb__fact"><dt>Category</dt><dd>${esc((p.categories||['—'])[0])}</dd></div>
|
|
|
|
|
</dl>
|
2026-05-18 16:38:39 +02:00
|
|
|
<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>
|
2026-05-18 16:27:47 +02:00
|
|
|
<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 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();
|
2026-05-18 16:43:19 +02:00
|
|
|
if (lb && lb.dataset.open === 'true') lbClose();
|
2026-05-18 16:27:47 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
})();
|