Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
297 lines
13 KiB
JavaScript
297 lines
13 KiB
JavaScript
/* ── Fonts ───────────────────────────────────────────────── */
|
||
import '@fontsource-variable/dm-sans';
|
||
import '@fontsource/dm-serif-display/400.css';
|
||
import '@fontsource/dm-serif-display/400-italic.css';
|
||
|
||
/* ── PhotoSwipe ──────────────────────────────────────────── */
|
||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||
import PhotoSwipe from 'photoswipe';
|
||
import 'photoswipe/style.css';
|
||
|
||
/* ── Scrollama (used by story.html.twig inline script) ────── */
|
||
import scrollama from 'scrollama';
|
||
window.scrollama = scrollama;
|
||
|
||
/* ── Photo strip: prev/next buttons + scroll-based dot sync ─ */
|
||
function initPhotoStrip() {
|
||
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
|
||
strip.setAttribute('role', 'region');
|
||
strip.setAttribute('aria-label', 'Photo strip');
|
||
strip.setAttribute('tabindex', '0');
|
||
|
||
var slideCount = parseInt(strip.dataset.slides, 10) || 1;
|
||
var dots = strip.nextElementSibling;
|
||
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
|
||
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
|
||
|
||
strip.addEventListener('scroll', function () {
|
||
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
|
||
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
|
||
}, { passive: true });
|
||
|
||
if (slideCount < 2) return;
|
||
|
||
var prev = document.createElement('button');
|
||
prev.className = 'strip-prev';
|
||
prev.setAttribute('aria-label', 'Previous photo');
|
||
prev.textContent = '‹';
|
||
prev.addEventListener('click', function () {
|
||
strip.scrollBy({ left: -strip.offsetWidth, behavior: 'smooth' });
|
||
});
|
||
|
||
var next = document.createElement('button');
|
||
next.className = 'strip-next';
|
||
next.setAttribute('aria-label', 'Next photo');
|
||
next.textContent = '›';
|
||
next.addEventListener('click', function () {
|
||
strip.scrollBy({ left: strip.offsetWidth, behavior: 'smooth' });
|
||
});
|
||
|
||
var controls = document.createElement('div');
|
||
controls.className = 'strip-controls';
|
||
controls.appendChild(prev);
|
||
controls.appendChild(next);
|
||
var wrap = strip.closest('.journal-photo-wrap');
|
||
(wrap || dots).insertAdjacentElement('afterend', controls);
|
||
});
|
||
}
|
||
|
||
/* ── PhotoSwipe lightbox + IntersectionObserver dot sync ──── */
|
||
function initPhotoSwipe() {
|
||
if (!document.querySelector('.pswp-gallery')) return;
|
||
|
||
var lightbox = new PhotoSwipeLightbox({
|
||
gallery: '.pswp-gallery',
|
||
children: 'a.journal-photo-slide',
|
||
pswpModule: PhotoSwipe
|
||
});
|
||
|
||
lightbox.on('afterOpen', function () {
|
||
var pswp = lightbox.pswp;
|
||
var keyDir = 0;
|
||
var clearTimer = null;
|
||
|
||
function onKey(e) {
|
||
if (e.key === 'ArrowRight') keyDir = 1;
|
||
else if (e.key === 'ArrowLeft') keyDir = -1;
|
||
else keyDir = 0;
|
||
}
|
||
document.addEventListener('keydown', onKey, true);
|
||
|
||
pswp.on('change', function () {
|
||
if (!keyDir) return;
|
||
var dir = keyDir;
|
||
keyDir = 0;
|
||
var el = pswp.currSlide && pswp.currSlide.container;
|
||
if (!el) return;
|
||
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
|
||
el.offsetWidth; /* force reflow */
|
||
el.classList.add(dir > 0 ? 'pswp-key-from-right' : 'pswp-key-from-left');
|
||
clearTimeout(clearTimer);
|
||
clearTimer = setTimeout(function () {
|
||
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
|
||
}, 400);
|
||
});
|
||
|
||
pswp.on('close', function () {
|
||
document.removeEventListener('keydown', onKey, true);
|
||
clearTimeout(clearTimer);
|
||
});
|
||
});
|
||
|
||
lightbox.init();
|
||
|
||
/* Per-strip: IntersectionObserver dot sync + expand button */
|
||
document.querySelectorAll('.journal-photo-wrap').forEach(function (wrap) {
|
||
var strip = wrap.querySelector('.journal-photo-strip');
|
||
if (!strip) return;
|
||
var slides = Array.from(strip.querySelectorAll('a.journal-photo-slide'));
|
||
var expandBtn = wrap.querySelector('.journal-photo-expand');
|
||
var article = wrap.closest('article');
|
||
var dots = article ? Array.from(article.querySelectorAll('.journal-photo-dot')) : [];
|
||
var visibleIdx = 0;
|
||
|
||
var io = new IntersectionObserver(function (entries) {
|
||
entries.forEach(function (e) {
|
||
if (!e.isIntersecting) return;
|
||
visibleIdx = slides.indexOf(e.target);
|
||
dots.forEach(function (d) { d.classList.remove('is-active'); });
|
||
if (dots[visibleIdx]) dots[visibleIdx].classList.add('is-active');
|
||
});
|
||
}, { root: strip, threshold: 0.5 });
|
||
slides.forEach(function (s) { io.observe(s); });
|
||
|
||
if (expandBtn && slides.length) {
|
||
expandBtn.addEventListener('click', function () {
|
||
slides[visibleIdx].dispatchEvent(
|
||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||
);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ── Sort button ─────────────────────────────────────────────
|
||
btnId: element id of the sort toggle button
|
||
containerSel: CSS selector for the list container
|
||
itemSel: CSS selector for sortable items within container
|
||
withLabel: true = button shows "↑ Oldest first" / "↓ Newest first"
|
||
false = button shows "↑" / "↓" only
|
||
──────────────────────────────────────────────────────────── */
|
||
function initSortButton(btnId, containerSel, itemSel, withLabel) {
|
||
var btn = document.getElementById(btnId);
|
||
if (!btn) return;
|
||
var container = document.querySelector(containerSel);
|
||
if (!container) return;
|
||
var sentinel = container.querySelector('#feed-filter-empty');
|
||
var ascending = true;
|
||
|
||
btn.addEventListener('click', function () {
|
||
ascending = !ascending;
|
||
var items = Array.from(container.querySelectorAll(itemSel));
|
||
items.reverse().forEach(function (el) {
|
||
if (sentinel) container.insertBefore(el, sentinel);
|
||
else container.appendChild(el);
|
||
});
|
||
btn.textContent = withLabel
|
||
? (ascending ? '↑ Oldest first' : '↓ Newest first')
|
||
: (ascending ? '↑' : '↓');
|
||
btn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
|
||
btn.classList.toggle('is-active', !ascending);
|
||
});
|
||
}
|
||
|
||
/* ── Filter bar (trip page: All / Journal / Stories) ─────── */
|
||
function initFilterBar() {
|
||
var filterBtns = document.querySelectorAll('.trip-filter-btn');
|
||
if (!filterBtns.length) return;
|
||
var cards = document.querySelectorAll('[data-type]');
|
||
var filterEmpty = document.getElementById('feed-filter-empty');
|
||
|
||
filterBtns.forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
filterBtns.forEach(function (b) {
|
||
b.classList.remove('is-active');
|
||
b.setAttribute('aria-pressed', 'false');
|
||
});
|
||
btn.classList.add('is-active');
|
||
btn.setAttribute('aria-pressed', 'true');
|
||
|
||
var filter = btn.getAttribute('data-filter');
|
||
var visible = 0;
|
||
cards.forEach(function (card) {
|
||
var show = filter === 'all' || card.getAttribute('data-type') === filter;
|
||
card.style.display = show ? '' : 'none';
|
||
if (show) visible++;
|
||
});
|
||
|
||
if (filterEmpty) {
|
||
if (visible === 0) {
|
||
filterEmpty.textContent = filter === 'story'
|
||
? 'No stories yet for this trip.'
|
||
: 'No entries yet.';
|
||
filterEmpty.style.display = '';
|
||
} else {
|
||
filterEmpty.style.display = 'none';
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ── Back to top ─────────────────────────────────────────── */
|
||
function initBackToTop(btnId) {
|
||
var btn = document.getElementById(btnId);
|
||
if (!btn) return;
|
||
var threshold = window.innerHeight * 0.8;
|
||
var shown = false;
|
||
btn.addEventListener('click', function () {
|
||
history.pushState(null, '', window.location.pathname + window.location.search);
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
});
|
||
window.addEventListener('scroll', function () {
|
||
var shouldShow = window.scrollY > threshold;
|
||
if (shouldShow !== shown) {
|
||
shown = shouldShow;
|
||
btn.classList.toggle('is-visible', shown);
|
||
}
|
||
}, { passive: true });
|
||
}
|
||
|
||
/* ── Panel toggles (trip stats / cycling panels) ─────────── */
|
||
function initPanelToggles() {
|
||
document.querySelectorAll('.trip-panel-toggle').forEach(function (toggle) {
|
||
var blockId = toggle.getAttribute('aria-controls');
|
||
var block = blockId ? document.getElementById(blockId) : null;
|
||
if (!block) return;
|
||
toggle.addEventListener('click', function () {
|
||
var isOpen = block.classList.contains('is-open');
|
||
block.classList.toggle('is-open', !isOpen);
|
||
toggle.classList.toggle('is-active', !isOpen);
|
||
toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.trip-panel-close').forEach(function (btn) {
|
||
var toggleBtn = document.getElementById(btn.getAttribute('data-toggle'));
|
||
if (toggleBtn) btn.addEventListener('click', function () { toggleBtn.click(); });
|
||
});
|
||
}
|
||
|
||
/* ── Trip stats / cycling computation (trip + home-active) ───
|
||
config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
|
||
No-op if #stat-distance is absent (page rendered no stats panel).
|
||
No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0'). */
|
||
function initTripStats(config) {
|
||
var distEl = document.getElementById('stat-distance');
|
||
if (!distEl) return;
|
||
|
||
var gpxUrls = config.gpxUrls || [];
|
||
var gpsPoints = config.gpsPoints || [];
|
||
|
||
if (config.hasGpx) {
|
||
MapUtils.parseGpxFiles(gpxUrls, function (result) {
|
||
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
|
||
function setText(id, val) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.textContent = val;
|
||
}
|
||
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
|
||
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
|
||
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
|
||
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
|
||
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
|
||
setText('cyc-moving-time', result.movingTime || '—');
|
||
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
|
||
});
|
||
} else {
|
||
if (gpsPoints.length < 2) {
|
||
distEl.textContent = '—';
|
||
return;
|
||
}
|
||
var total = 0;
|
||
for (var i = 1; i < gpsPoints.length; i++) {
|
||
total += MapUtils.haversineKm(
|
||
parseFloat(gpsPoints[i-1][0]), parseFloat(gpsPoints[i-1][1]),
|
||
parseFloat(gpsPoints[i][0]), parseFloat(gpsPoints[i][1])
|
||
);
|
||
}
|
||
distEl.textContent = '~' + Math.round(total).toLocaleString();
|
||
}
|
||
}
|
||
window.initTripStats = initTripStats;
|
||
|
||
/* ── Boot ────────────────────────────────────────────────── */
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
initPhotoStrip();
|
||
initPhotoSwipe();
|
||
initFilterBar();
|
||
/* Sort buttons — each call is silent if its button/container isn't on this page */
|
||
initSortButton('trip-sort-toggle', '.feed', '[data-type]', false);
|
||
initSortButton('feed-sort-toggle', '.feed', '[data-type]', true);
|
||
initSortButton('feed-sort-toggle', '.stories-grid', '.story-card', true);
|
||
initBackToTop('story-totop');
|
||
initBackToTop('trip-totop');
|
||
initPanelToggles();
|
||
});
|