1
0

app.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. /*
  2. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  3. * SPDX-License-Identifier: Apache-2.0
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * See the LICENSE file in the project root for the full license text.
  8. */
  9. /**
  10. * Site-wide behaviour. Three concerns:
  11. * 1. Vanilla-JS click delegation for [data-href] rows (sprint list).
  12. * 2. Alpine CSP component: appMenu (hamburger).
  13. * 3. Alpine CSP component: themeToggle (dark-mode flip).
  14. *
  15. * htmx is loaded site-wide and sends X-CSRF-Token automatically per the
  16. * `htmx:configRequest` listener below.
  17. */
  18. (function () {
  19. 'use strict';
  20. // ----- data-href click delegation ------------------------------------
  21. function navigate(el, ev) {
  22. const href = String(el.getAttribute('data-href') || '');
  23. if (href === '') { return; }
  24. if (ev.metaKey || ev.ctrlKey || ev.button === 1) {
  25. window.open(href, '_blank');
  26. } else {
  27. window.location.href = href;
  28. }
  29. }
  30. document.addEventListener('click', function (ev) {
  31. const el = ev.target.closest('[data-href]');
  32. if (!el) { return; }
  33. // Ignore clicks that originated on an interactive descendant.
  34. if (ev.target.closest('a, button, input, select, textarea, label')) { return; }
  35. navigate(el, ev);
  36. });
  37. document.addEventListener('keydown', function (ev) {
  38. if (ev.key !== 'Enter') { return; }
  39. const el = ev.target.closest('[data-href]');
  40. if (!el) { return; }
  41. if (ev.target.closest('a, button, input, select, textarea, label')) { return; }
  42. ev.preventDefault();
  43. window.location.href = String(el.getAttribute('data-href') || '');
  44. });
  45. // Make every data-href row keyboard-reachable + screenreader-friendly.
  46. document.querySelectorAll('[data-href]').forEach(function (el) {
  47. el.setAttribute('role', 'link');
  48. el.setAttribute('tabindex', '0');
  49. });
  50. // ----- htmx CSRF wiring ---------------------------------------------
  51. // The CSRF token lives on every layout's form (_csrf hidden input) and on
  52. // the [data-sprint-root] element (data-csrf). Pull from a hidden input if
  53. // present so non-sprint pages also get the header attached.
  54. function readCsrfToken() {
  55. const inp = document.querySelector('input[name="_csrf"]');
  56. if (inp && inp.value) { return inp.value; }
  57. const root = document.querySelector('[data-sprint-root]');
  58. if (root && root.getAttribute('data-csrf')) {
  59. return root.getAttribute('data-csrf');
  60. }
  61. return '';
  62. }
  63. document.addEventListener('htmx:configRequest', function (ev) {
  64. const tok = readCsrfToken();
  65. if (tok && ev.detail && ev.detail.headers) {
  66. ev.detail.headers['X-CSRF-Token'] = tok;
  67. }
  68. });
  69. // ----- Local-timezone datetime rendering ----------------------------
  70. // Server emits each audit row's `occurred_at` as a UTC ISO-8601 string
  71. // inside <time data-local-datetime datetime="…">…</time>. Rewrite the
  72. // text content to the viewer's local timezone in dd.mm.YYYY HH:mm:ss.
  73. function pad2(n) { return n < 10 ? '0' + n : '' + n; }
  74. function formatLocalDateTime(d) {
  75. return pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1) + '.' + d.getFullYear()
  76. + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
  77. }
  78. function localizeDateTimes(root) {
  79. const scope = root || document;
  80. const els = scope.querySelectorAll('time[data-local-datetime]');
  81. els.forEach(function (el) {
  82. const iso = el.getAttribute('datetime') || '';
  83. if (iso === '') { return; }
  84. const d = new Date(iso);
  85. if (isNaN(d.getTime())) { return; }
  86. el.textContent = formatLocalDateTime(d);
  87. });
  88. }
  89. document.addEventListener('DOMContentLoaded', function () { localizeDateTimes(); });
  90. document.addEventListener('htmx:afterSwap', function (ev) {
  91. localizeDateTimes(ev.target || document);
  92. });
  93. // ----- Alpine component registrations -------------------------------
  94. document.addEventListener('alpine:init', function () {
  95. // Hamburger menu — toggle, close on outside / Escape / item click.
  96. window.Alpine.data('appMenu', function () {
  97. return {
  98. open: false,
  99. toggle() { this.open = !this.open; },
  100. close() { this.open = false; },
  101. closeAndFocus() {
  102. if (!this.open) { return; }
  103. this.open = false;
  104. if (this.$refs.trigger) { this.$refs.trigger.focus(); }
  105. },
  106. closeOnItem(ev) {
  107. if (ev.target.closest('[role="menuitem"]')) { this.open = false; }
  108. },
  109. };
  110. });
  111. // Theme toggle — flips <html class="dark">, persists in localStorage,
  112. // mirrors a "Dark" / "Light" label into the menu row.
  113. window.Alpine.data('themeToggle', function () {
  114. return {
  115. label: '',
  116. init() {
  117. this.stamp();
  118. // If theme-init.js loaded after Alpine for some reason,
  119. // reflect the current class state on next tick.
  120. this.$nextTick(() => this.stamp());
  121. },
  122. stamp() {
  123. this.label = document.documentElement.classList.contains('dark') ? 'Dark' : 'Light';
  124. },
  125. flip() {
  126. const nowDark = document.documentElement.classList.toggle('dark');
  127. try { localStorage.setItem('sp:theme', nowDark ? 'dark' : 'light'); }
  128. catch (e) { /* private mode quota — ignore */ }
  129. this.stamp();
  130. },
  131. };
  132. });
  133. });
  134. })();