- Admin menu page: mobile item cards now put Actions in the top-right
corner next to Status, matching the desktop row layout instead of
stacking at the bottom; same treatment applied to announcements and
users for consistency across all three admin lists
- Center button label text everywhere via .btn { justify-content: center }
instead of one-off overrides
- Add a Disable/Enable Menu toggle to the admin menu toolbar; the public
menu page shows its existing "menu being updated" message while disabled
- Add an Enabled/Disabled status to user accounts, with its own toggle
badge, guardrails against disabling your own account or the last
enabled account, and an immediate session kick for anyone disabled
mid-session
- Fix the announcement banner / hero tagline "·" separators to hide only
when their neighboring text actually wraps onto a different line,
instead of a fixed breakpoint or always-on dot
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
120 lines
3.8 KiB
JavaScript
120 lines
3.8 KiB
JavaScript
// Shared public-site behavior: scroll-reveal animations and the mobile
|
|
// nav toggle. Loaded by both index.php and menu.php.
|
|
|
|
// Reveal-on-scroll
|
|
(() => {
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
|
|
const els = document.querySelectorAll('.reveal');
|
|
if (!('IntersectionObserver' in window)) {
|
|
els.forEach((el) => el.classList.add('is-visible'));
|
|
return;
|
|
}
|
|
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('is-visible');
|
|
io.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.15 });
|
|
|
|
els.forEach((el) => io.observe(el));
|
|
})();
|
|
|
|
// Mobile nav toggle
|
|
(() => {
|
|
const toggle = document.getElementById('nav-toggle');
|
|
const nav = document.getElementById('primary-nav');
|
|
if (!toggle || !nav) return;
|
|
|
|
function closeNav() {
|
|
nav.classList.remove('is-open');
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
function openNav() {
|
|
nav.classList.add('is-open');
|
|
toggle.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
toggle.addEventListener('click', () => {
|
|
if (nav.classList.contains('is-open')) {
|
|
closeNav();
|
|
} else {
|
|
openNav();
|
|
}
|
|
});
|
|
|
|
// Close after following a link
|
|
nav.querySelectorAll('a').forEach((link) => {
|
|
link.addEventListener('click', closeNav);
|
|
});
|
|
|
|
// Close on Escape
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeNav();
|
|
});
|
|
|
|
// Close on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!nav.classList.contains('is-open')) return;
|
|
if (nav.contains(e.target) || toggle.contains(e.target)) return;
|
|
closeNav();
|
|
});
|
|
})();
|
|
|
|
// Wrap-aware separator dots (announcement banner, hero tagline row).
|
|
// Hides a "·" only when its two flanking elements wrap onto different
|
|
// visual lines. There's no CSS way to detect "same line as sibling," and
|
|
// a fixed viewport breakpoint can't substitute for it either, since
|
|
// wrapping depends on the announcement's own text length, not just
|
|
// screen width.
|
|
(() => {
|
|
const seps = document.querySelectorAll('.wrap-sep');
|
|
if (!seps.length) return;
|
|
|
|
function sameLine(a, b) {
|
|
const ra = a.getBoundingClientRect();
|
|
const rb = b.getBoundingClientRect();
|
|
return ra.top < rb.bottom && rb.top < ra.bottom;
|
|
}
|
|
|
|
// Hiding a separator removes it from flow (see .wrap-sep-hidden in
|
|
// styles.css — needs to, so a hidden dot never skews centering by still
|
|
// reserving its space), which means one separator's hide/show can shift
|
|
// where a *later* one's neighbors land — e.g. the tagline row's second
|
|
// dot needs to see the first dot's decision already applied, or it may
|
|
// judge its own neighbors' line-sharing from a layout that's since
|
|
// changed underneath it. So this deliberately processes separators one
|
|
// at a time, in DOM order, applying each decision before measuring the
|
|
// next: reset just this one to visible (so its own contribution is
|
|
// never argued away by whatever its own current hidden state already
|
|
// happens to be — otherwise "hidden" would be self-confirming, since a
|
|
// hidden dot's neighbors always look like they share a line), measure,
|
|
// decide, apply, then move on with that change already in effect.
|
|
function sync() {
|
|
seps.forEach((sep) => {
|
|
const prev = sep.previousElementSibling;
|
|
const next = sep.nextElementSibling;
|
|
if (!prev || !next) return;
|
|
|
|
sep.classList.remove('wrap-sep-hidden');
|
|
sep.classList.toggle('wrap-sep-hidden', !sameLine(prev, next));
|
|
});
|
|
}
|
|
|
|
sync();
|
|
|
|
let resizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(sync, 100);
|
|
});
|
|
|
|
if (document.fonts && document.fonts.ready) {
|
|
document.fonts.ready.then(sync);
|
|
}
|
|
})();
|