1
0

app.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import Alpine from 'alpinejs';
  2. import 'htmx.org';
  3. import {
  4. Chart,
  5. BarController,
  6. BarElement,
  7. LineController,
  8. LineElement,
  9. PointElement,
  10. PieController,
  11. ArcElement,
  12. CategoryScale,
  13. LinearScale,
  14. Tooltip,
  15. Title,
  16. Legend,
  17. Filler,
  18. } from 'chart.js';
  19. // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
  20. // initial paint; this just wires the toggle button.
  21. function applyTheme(theme) {
  22. if (theme === 'dark') {
  23. document.documentElement.classList.add('dark');
  24. } else {
  25. document.documentElement.classList.remove('dark');
  26. }
  27. try {
  28. localStorage.setItem('irdb-theme', theme);
  29. } catch (e) {
  30. /* ignore */
  31. }
  32. }
  33. document.addEventListener('click', (e) => {
  34. const target = e.target.closest('[data-theme-toggle]');
  35. if (!target) return;
  36. const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
  37. applyTheme(next);
  38. });
  39. // htmx: send the per-session CSRF token on every state-changing request.
  40. document.body.addEventListener('htmx:configRequest', (e) => {
  41. const meta = document.querySelector('meta[name="csrf-token"]');
  42. if (meta && meta.content) {
  43. e.detail.headers['X-CSRF-Token'] = meta.content;
  44. }
  45. });
  46. // Dashboard charts. Each canvas carries its data in a data-attribute
  47. // (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the
  48. // pieces we use so the bundle stays small.
  49. Chart.register(
  50. BarController,
  51. BarElement,
  52. LineController,
  53. LineElement,
  54. PointElement,
  55. PieController,
  56. ArcElement,
  57. CategoryScale,
  58. LinearScale,
  59. Tooltip,
  60. Title,
  61. Legend,
  62. Filler,
  63. );
  64. // Palette tuned for both light and dark backgrounds. Reused across the
  65. // pie charts so categories/reporters don't share colours.
  66. const PIE_COLORS = [
  67. '#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
  68. '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6',
  69. ];
  70. function chartTheme() {
  71. const isDark = document.documentElement.classList.contains('dark');
  72. return {
  73. tickColor: isDark ? '#94a3b8' : '#475569',
  74. gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
  75. legendColor: isDark ? '#cbd5e1' : '#334155',
  76. };
  77. }
  78. function parseSeries(canvas, attr) {
  79. try {
  80. return JSON.parse(canvas.dataset[attr] || '[]');
  81. } catch (e) {
  82. return [];
  83. }
  84. }
  85. function renderReportsChart() {
  86. const canvas = document.getElementById('reports-chart');
  87. if (!canvas) return;
  88. const buckets = parseSeries(canvas, 'buckets');
  89. const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
  90. const data = buckets.map((b) => b.count || 0);
  91. const t = chartTheme();
  92. new Chart(canvas, {
  93. type: 'bar',
  94. data: {
  95. labels,
  96. datasets: [{
  97. label: 'reports',
  98. data,
  99. backgroundColor: '#6366f1',
  100. }],
  101. },
  102. options: {
  103. responsive: true,
  104. maintainAspectRatio: false,
  105. plugins: { legend: { display: false } },
  106. scales: {
  107. x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  108. y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
  109. },
  110. },
  111. });
  112. }
  113. function renderPieChart(canvasId) {
  114. const canvas = document.getElementById(canvasId);
  115. if (!canvas) return;
  116. const series = parseSeries(canvas, 'series');
  117. if (series.length === 0) return;
  118. const labelKey = canvas.dataset.labelKey || 'name';
  119. const labels = series.map((row) => String(row[labelKey] ?? ''));
  120. const data = series.map((row) => row.count || 0);
  121. const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
  122. const t = chartTheme();
  123. new Chart(canvas, {
  124. type: 'pie',
  125. data: {
  126. labels,
  127. datasets: [{
  128. data,
  129. backgroundColor: colors,
  130. borderColor: 'rgba(255,255,255,0.1)',
  131. borderWidth: 1,
  132. }],
  133. },
  134. options: {
  135. responsive: true,
  136. maintainAspectRatio: false,
  137. plugins: {
  138. legend: {
  139. position: 'bottom',
  140. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  141. },
  142. tooltip: {
  143. callbacks: {
  144. label: (ctx) => {
  145. const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
  146. const pct = ((ctx.parsed / total) * 100).toFixed(1);
  147. return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
  148. },
  149. },
  150. },
  151. },
  152. },
  153. });
  154. }
  155. function renderBansTrendChart() {
  156. const canvas = document.getElementById('bans-trend-chart');
  157. if (!canvas) return;
  158. const series = parseSeries(canvas, 'series');
  159. if (series.length === 0) return;
  160. // Show day-of-month as label; the full ISO date stays in the tooltip
  161. // title via the dataset.
  162. const labels = series.map((row) => {
  163. const d = new Date(row.day + 'T00:00:00Z');
  164. return isNaN(d.getTime()) ? row.day : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  165. });
  166. const data = series.map((row) => row.count || 0);
  167. const t = chartTheme();
  168. new Chart(canvas, {
  169. type: 'line',
  170. data: {
  171. labels,
  172. datasets: [{
  173. label: 'bans',
  174. data,
  175. borderColor: '#ef4444',
  176. backgroundColor: 'rgba(239,68,68,0.18)',
  177. tension: 0.3,
  178. fill: true,
  179. pointRadius: 3,
  180. pointHoverRadius: 5,
  181. pointBackgroundColor: '#ef4444',
  182. }],
  183. },
  184. options: {
  185. responsive: true,
  186. maintainAspectRatio: false,
  187. plugins: {
  188. legend: { display: false },
  189. tooltip: {
  190. callbacks: {
  191. title: (items) => (items[0] && series[items[0].dataIndex]?.day) || '',
  192. },
  193. },
  194. },
  195. scales: {
  196. x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  197. y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
  198. },
  199. },
  200. });
  201. }
  202. document.addEventListener('DOMContentLoaded', () => {
  203. renderReportsChart();
  204. renderPieChart('top-reporters-chart');
  205. renderPieChart('top-categories-chart');
  206. renderBansTrendChart();
  207. });
  208. // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
  209. // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
  210. // string as a no-JS fallback. This pass replaces it with the user's
  211. // browser locale formatting, with an optional configured fallback (set
  212. // via UI_LOCALE on the html data attribute) appended so browser locale
  213. // wins but a deployment can still ensure something sensible if the
  214. // browser's preference isn't supported. `data-irdb-dt-format="date"`
  215. // switches to date-only output.
  216. function getDateLocales() {
  217. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  218. const locales = [];
  219. if (typeof navigator !== 'undefined' && navigator.language) {
  220. locales.push(navigator.language);
  221. }
  222. if (fallback && fallback.trim()) {
  223. locales.push(fallback.trim());
  224. }
  225. return locales.length > 0 ? locales : undefined;
  226. }
  227. function buildDateFormatters() {
  228. const locales = getDateLocales();
  229. return {
  230. datetime: new Intl.DateTimeFormat(locales, {
  231. year: 'numeric', month: '2-digit', day: '2-digit',
  232. hour: '2-digit', minute: '2-digit', second: '2-digit',
  233. }),
  234. date: new Intl.DateTimeFormat(locales, {
  235. year: 'numeric', month: '2-digit', day: '2-digit',
  236. }),
  237. };
  238. }
  239. function formatTimes(root) {
  240. const scope = root && root.querySelectorAll ? root : document;
  241. const formatters = buildDateFormatters();
  242. const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
  243. elements.forEach((el) => {
  244. const iso = el.getAttribute('datetime');
  245. if (!iso) return;
  246. const d = new Date(iso);
  247. if (isNaN(d.getTime())) return;
  248. const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
  249. try {
  250. el.textContent = fmt.format(d);
  251. if (!el.hasAttribute('title')) {
  252. el.setAttribute('title', iso);
  253. }
  254. } catch (e) {
  255. /* leave the ISO fallback in place */
  256. }
  257. });
  258. }
  259. document.addEventListener('DOMContentLoaded', () => formatTimes(document));
  260. document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
  261. window.Alpine = Alpine;
  262. Alpine.start();