app.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import Alpine from 'alpinejs';
  2. import 'htmx.org';
  3. import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Title } from 'chart.js';
  4. // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
  5. // initial paint; this just wires the toggle button.
  6. function applyTheme(theme) {
  7. if (theme === 'dark') {
  8. document.documentElement.classList.add('dark');
  9. } else {
  10. document.documentElement.classList.remove('dark');
  11. }
  12. try {
  13. localStorage.setItem('irdb-theme', theme);
  14. } catch (e) {
  15. /* ignore */
  16. }
  17. }
  18. document.addEventListener('click', (e) => {
  19. const target = e.target.closest('[data-theme-toggle]');
  20. if (!target) return;
  21. const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
  22. applyTheme(next);
  23. });
  24. // htmx: send the per-session CSRF token on every state-changing request.
  25. document.body.addEventListener('htmx:configRequest', (e) => {
  26. const meta = document.querySelector('meta[name="csrf-token"]');
  27. if (meta && meta.content) {
  28. e.detail.headers['X-CSRF-Token'] = meta.content;
  29. }
  30. });
  31. // Dashboard reports-per-hour chart. The canvas carries the buckets in a
  32. // `data-buckets` attribute (server-pre-bucketed; no AJAX). Chart.js is
  33. // tree-shaken to just the bar/linear pieces we need so the bundle stays
  34. // small.
  35. Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Title);
  36. function renderReportsChart() {
  37. const canvas = document.getElementById('reports-chart');
  38. if (!canvas) return;
  39. let buckets = [];
  40. try {
  41. buckets = JSON.parse(canvas.dataset.buckets || '[]');
  42. } catch (e) {
  43. return;
  44. }
  45. const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
  46. const data = buckets.map((b) => b.count || 0);
  47. const isDark = document.documentElement.classList.contains('dark');
  48. const tickColor = isDark ? '#94a3b8' : '#475569';
  49. const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)';
  50. new Chart(canvas, {
  51. type: 'bar',
  52. data: {
  53. labels,
  54. datasets: [{
  55. label: 'reports',
  56. data,
  57. backgroundColor: '#6366f1',
  58. }],
  59. },
  60. options: {
  61. responsive: true,
  62. maintainAspectRatio: false,
  63. plugins: { legend: { display: false } },
  64. scales: {
  65. x: { ticks: { color: tickColor }, grid: { color: gridColor } },
  66. y: { ticks: { color: tickColor, precision: 0 }, grid: { color: gridColor }, beginAtZero: true },
  67. },
  68. },
  69. });
  70. }
  71. document.addEventListener('DOMContentLoaded', renderReportsChart);
  72. // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
  73. // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
  74. // string as a no-JS fallback. This pass replaces it with the user's
  75. // browser locale formatting, with an optional configured fallback (set
  76. // via UI_LOCALE on the html data attribute) appended so browser locale
  77. // wins but a deployment can still ensure something sensible if the
  78. // browser's preference isn't supported. `data-irdb-dt-format="date"`
  79. // switches to date-only output.
  80. function getDateLocales() {
  81. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  82. const locales = [];
  83. if (typeof navigator !== 'undefined' && navigator.language) {
  84. locales.push(navigator.language);
  85. }
  86. if (fallback && fallback.trim()) {
  87. locales.push(fallback.trim());
  88. }
  89. return locales.length > 0 ? locales : undefined;
  90. }
  91. function buildDateFormatters() {
  92. const locales = getDateLocales();
  93. return {
  94. datetime: new Intl.DateTimeFormat(locales, {
  95. year: 'numeric', month: '2-digit', day: '2-digit',
  96. hour: '2-digit', minute: '2-digit', second: '2-digit',
  97. }),
  98. date: new Intl.DateTimeFormat(locales, {
  99. year: 'numeric', month: '2-digit', day: '2-digit',
  100. }),
  101. };
  102. }
  103. function formatTimes(root) {
  104. const scope = root && root.querySelectorAll ? root : document;
  105. const formatters = buildDateFormatters();
  106. const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
  107. elements.forEach((el) => {
  108. const iso = el.getAttribute('datetime');
  109. if (!iso) return;
  110. const d = new Date(iso);
  111. if (isNaN(d.getTime())) return;
  112. const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
  113. try {
  114. el.textContent = fmt.format(d);
  115. if (!el.hasAttribute('title')) {
  116. el.setAttribute('title', iso);
  117. }
  118. } catch (e) {
  119. /* leave the ISO fallback in place */
  120. }
  121. });
  122. }
  123. document.addEventListener('DOMContentLoaded', () => formatTimes(document));
  124. document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
  125. window.Alpine = Alpine;
  126. Alpine.start();