/* * Copyright 2026 Alessandro Chiapparini * SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ /** * Site-wide behaviour. Three concerns: * 1. Vanilla-JS click delegation for [data-href] rows (sprint list). * 2. Alpine CSP component: appMenu (hamburger). * 3. Alpine CSP component: themeToggle (dark-mode flip). * * htmx is loaded site-wide and sends X-CSRF-Token automatically per the * `htmx:configRequest` listener below. */ (function () { 'use strict'; // ----- data-href click delegation ------------------------------------ function navigate(el, ev) { const href = String(el.getAttribute('data-href') || ''); if (href === '') { return; } if (ev.metaKey || ev.ctrlKey || ev.button === 1) { window.open(href, '_blank'); } else { window.location.href = href; } } document.addEventListener('click', function (ev) { const el = ev.target.closest('[data-href]'); if (!el) { return; } // Ignore clicks that originated on an interactive descendant. if (ev.target.closest('a, button, input, select, textarea, label')) { return; } navigate(el, ev); }); document.addEventListener('keydown', function (ev) { if (ev.key !== 'Enter') { return; } const el = ev.target.closest('[data-href]'); if (!el) { return; } if (ev.target.closest('a, button, input, select, textarea, label')) { return; } ev.preventDefault(); window.location.href = String(el.getAttribute('data-href') || ''); }); // Make every data-href row keyboard-reachable + screenreader-friendly. document.querySelectorAll('[data-href]').forEach(function (el) { el.setAttribute('role', 'link'); el.setAttribute('tabindex', '0'); }); // ----- htmx CSRF wiring --------------------------------------------- // The CSRF token lives on every layout's form (_csrf hidden input) and on // the [data-sprint-root] element (data-csrf). Pull from a hidden input if // present so non-sprint pages also get the header attached. function readCsrfToken() { const inp = document.querySelector('input[name="_csrf"]'); if (inp && inp.value) { return inp.value; } const root = document.querySelector('[data-sprint-root]'); if (root && root.getAttribute('data-csrf')) { return root.getAttribute('data-csrf'); } return ''; } document.addEventListener('htmx:configRequest', function (ev) { const tok = readCsrfToken(); if (tok && ev.detail && ev.detail.headers) { ev.detail.headers['X-CSRF-Token'] = tok; } }); // ----- Local-timezone datetime rendering ---------------------------- // Server emits each audit row's `occurred_at` as a UTC ISO-8601 string // inside . Rewrite the // text content to the viewer's local timezone in dd.mm.YYYY HH:mm:ss. function pad2(n) { return n < 10 ? '0' + n : '' + n; } function formatLocalDateTime(d) { return pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1) + '.' + d.getFullYear() + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); } function localizeDateTimes(root) { const scope = root || document; const els = scope.querySelectorAll('time[data-local-datetime]'); els.forEach(function (el) { const iso = el.getAttribute('datetime') || ''; if (iso === '') { return; } const d = new Date(iso); if (isNaN(d.getTime())) { return; } el.textContent = formatLocalDateTime(d); }); } document.addEventListener('DOMContentLoaded', function () { localizeDateTimes(); }); document.addEventListener('htmx:afterSwap', function (ev) { localizeDateTimes(ev.target || document); }); // ----- Alpine component registrations ------------------------------- document.addEventListener('alpine:init', function () { // Hamburger menu — toggle, close on outside / Escape / item click. window.Alpine.data('appMenu', function () { return { open: false, toggle() { this.open = !this.open; }, close() { this.open = false; }, closeAndFocus() { if (!this.open) { return; } this.open = false; if (this.$refs.trigger) { this.$refs.trigger.focus(); } }, closeOnItem(ev) { if (ev.target.closest('[role="menuitem"]')) { this.open = false; } }, }; }); // Theme toggle — flips , persists in localStorage, // mirrors a "Dark" / "Light" label into the menu row. window.Alpine.data('themeToggle', function () { return { label: '', init() { this.stamp(); // If theme-init.js loaded after Alpine for some reason, // reflect the current class state on next tick. this.$nextTick(() => this.stamp()); }, stamp() { this.label = document.documentElement.classList.contains('dark') ? 'Dark' : 'Light'; }, flip() { const nowDark = document.documentElement.classList.toggle('dark'); try { localStorage.setItem('sp:theme', nowDark ? 'dark' : 'light'); } catch (e) { /* private mode quota — ignore */ } this.stamp(); }, }; }); }); })();