/* ==========================================================================
Mia Druck — shared behaviour. Loaded with `defer` on every page.
Listings are static HTML now, so there is no client-side rendering here.
========================================================================== */
(function () {
'use strict';
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* -- Footer year -------------------------------------------------------- */
var year = document.getElementById('y');
if (year) year.textContent = new Date().getFullYear();
/* -- Mobile nav --------------------------------------------------------- */
(function () {
var topbar = document.querySelector('.topbar');
var btn = document.getElementById('navToggle');
var nav = document.getElementById('siteNav');
if (!topbar || !btn || !nav) return;
function close() {
topbar.classList.remove('nav-open');
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function () {
var open = topbar.classList.toggle('nav-open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
nav.addEventListener('click', function (e) {
if (e.target.closest('a')) close();
});
document.addEventListener('click', function (e) {
if (!topbar.contains(e.target)) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
})();
/* -- Nav scroll spy -----------------------------------------------------
Marks the nav link for whichever section is currently at the top of the
viewport. Only same-page anchors that actually resolve take part: the area
pages link out with "/#…", so they keep their static aria-current="page"
and this no-ops there. */
(function () {
var nav = document.getElementById('siteNav');
var topbar = document.querySelector('.topbar');
if (!nav) return;
var targets = [];
nav.querySelectorAll('a[href^="#"]').forEach(function (link) {
var id = link.getAttribute('href').slice(1);
var section = id && document.getElementById(id);
if (section) targets.push({ link: link, section: section });
});
// One target is not a spy, it's just a link (the area pages' Contact).
if (targets.length < 2) return;
var current = null;
function update() {
// Sections sit behind the sticky bar, so activate a little below it.
var line = window.scrollY + (topbar ? topbar.offsetHeight : 0) + 24;
var atBottom = window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 2;
var active = null;
var bestTop = -1;
targets.forEach(function (t) {
// Measured live: nav order does not match document order here, and
// the layout reflows across breakpoints.
var top = t.section.getBoundingClientRect().top + window.scrollY;
if ((atBottom || top <= line) && top > bestTop) {
bestTop = top;
active = t;
}
});
if (active === current) return;
if (current) current.link.removeAttribute('aria-current');
if (active) active.link.setAttribute('aria-current', 'location');
current = active;
}
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () { update(); ticking = false; });
}
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
update();
})();
/* -- Toast -------------------------------------------------------------- */
var toastTimer;
function showToast(msg) {
var t = document.getElementById('copyToast');
if (!t) {
t = document.createElement('div');
t.id = 'copyToast';
t.className = 'copy-toast';
t.setAttribute('role', 'status');
t.setAttribute('aria-live', 'polite');
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.classList.remove('show'); }, 1800);
}
/* -- Listing deep links ------------------------------------------------- */
var highlightTimer;
function highlight(id) {
var el = document.getElementById(id);
if (!el) return false;
document.querySelectorAll('.listing.is-highlighted')
.forEach(function (n) { n.classList.remove('is-highlighted'); });
el.scrollIntoView({
behavior: reduceMotion ? 'auto' : 'smooth',
block: 'center'
});
setTimeout(function () {
el.classList.add('is-highlighted');
clearTimeout(highlightTimer);
highlightTimer = setTimeout(function () {
el.classList.remove('is-highlighted');
}, 20000);
}, 220);
return true;
}
function shareUrl(id) {
// Listings only live on the homepage, so always build the link against it.
var u = new URL(location.origin + '/');
u.searchParams.set('listing', id);
return u.toString();
}
// Delegated — works for any listing added to the HTML later.
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-copy-listing]');
if (!btn) return;
var id = btn.getAttribute('data-copy-listing');
var url = shareUrl(id);
function done() {
showToast('Link copied');
highlight(id);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url).then(done, function () {
window.prompt('Copy this link:', url);
});
} else {
var ta = document.createElement('textarea');
ta.value = url;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); }
catch (err) { window.prompt('Copy this link:', url); }
ta.remove();
}
});
var deepLink = new URLSearchParams(location.search).get('listing');
if (deepLink) setTimeout(function () { highlight(deepLink); }, 300);
/* -- Contact form (Formspree, AJAX) ------------------------------------- */
(function () {
var form = document.getElementById('contactForm');
var success = document.getElementById('contactSuccess');
if (!form || !success) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
// Honeypot: bots fill hidden fields, humans never see this one.
if (form.querySelector('[name="_gotcha"]').value) return;
var btn = form.querySelector('button[type="submit"]');
var label = btn ? btn.textContent : 'Send';
if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; }
fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form)
}).then(function (res) {
if (!res.ok) throw new Error('Bad response');
form.classList.add('hide');
success.classList.remove('hide');
success.setAttribute('tabindex', '-1');
success.focus();
form.reset();
}).catch(function () {
showToast('Could not send — please call');
if (btn) { btn.disabled = false; btn.textContent = label; }
});
});
})();
/* -- Scroll reveal ------------------------------------------------------ */
(function () {
var targets = document.querySelectorAll('.reveal');
if (!targets.length) return;
if (reduceMotion || !('IntersectionObserver' in window)) {
targets.forEach(function (el) { el.classList.add('is-visible'); });
return;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
});
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.05 });
targets.forEach(function (el) { io.observe(el); });
})();
})();