| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- import Alpine from 'alpinejs';
- import 'htmx.org';
- import {
- Chart,
- BarController,
- BarElement,
- LineController,
- LineElement,
- PointElement,
- PieController,
- ArcElement,
- CategoryScale,
- LinearScale,
- Tooltip,
- Title,
- Legend,
- Filler,
- } from 'chart.js';
- // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
- // initial paint; this just wires the toggle button.
- function applyTheme(theme) {
- if (theme === 'dark') {
- document.documentElement.classList.add('dark');
- } else {
- document.documentElement.classList.remove('dark');
- }
- try {
- localStorage.setItem('irdb-theme', theme);
- } catch (e) {
- /* ignore */
- }
- }
- document.addEventListener('click', (e) => {
- const target = e.target.closest('[data-theme-toggle]');
- if (!target) return;
- const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
- applyTheme(next);
- });
- // htmx: send the per-session CSRF token on every state-changing request.
- document.body.addEventListener('htmx:configRequest', (e) => {
- const meta = document.querySelector('meta[name="csrf-token"]');
- if (meta && meta.content) {
- e.detail.headers['X-CSRF-Token'] = meta.content;
- }
- });
- // Dashboard charts. Each canvas carries its data in a data-attribute
- // (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the
- // pieces we use so the bundle stays small.
- Chart.register(
- BarController,
- BarElement,
- LineController,
- LineElement,
- PointElement,
- PieController,
- ArcElement,
- CategoryScale,
- LinearScale,
- Tooltip,
- Title,
- Legend,
- Filler,
- );
- // Palette tuned for both light and dark backgrounds. Reused across the
- // pie charts so categories/reporters don't share colours.
- const PIE_COLORS = [
- '#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
- '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6',
- ];
- function chartTheme() {
- const isDark = document.documentElement.classList.contains('dark');
- return {
- tickColor: isDark ? '#94a3b8' : '#475569',
- gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
- legendColor: isDark ? '#cbd5e1' : '#334155',
- };
- }
- function parseSeries(canvas, attr) {
- try {
- return JSON.parse(canvas.dataset[attr] || '[]');
- } catch (e) {
- return [];
- }
- }
- function renderReportsChart() {
- const canvas = document.getElementById('reports-chart');
- if (!canvas) return;
- const buckets = parseSeries(canvas, 'buckets');
- const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
- const data = buckets.map((b) => b.count || 0);
- const t = chartTheme();
- new Chart(canvas, {
- type: 'bar',
- data: {
- labels,
- datasets: [{
- label: 'reports',
- data,
- backgroundColor: '#6366f1',
- }],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: { legend: { display: false } },
- scales: {
- x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
- y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
- },
- },
- });
- }
- function renderPieChart(canvasId) {
- const canvas = document.getElementById(canvasId);
- if (!canvas) return;
- const series = parseSeries(canvas, 'series');
- if (series.length === 0) return;
- const labelKey = canvas.dataset.labelKey || 'name';
- const labels = series.map((row) => String(row[labelKey] ?? ''));
- const data = series.map((row) => row.count || 0);
- const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
- const t = chartTheme();
- new Chart(canvas, {
- type: 'pie',
- data: {
- labels,
- datasets: [{
- data,
- backgroundColor: colors,
- borderColor: 'rgba(255,255,255,0.1)',
- borderWidth: 1,
- }],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- position: 'bottom',
- labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
- },
- tooltip: {
- callbacks: {
- label: (ctx) => {
- const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
- const pct = ((ctx.parsed / total) * 100).toFixed(1);
- return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
- },
- },
- },
- },
- },
- });
- }
- function renderBansTrendChart() {
- const canvas = document.getElementById('bans-trend-chart');
- if (!canvas) return;
- const series = parseSeries(canvas, 'series');
- if (series.length === 0) return;
- // Show day-of-month as label; the full ISO date stays in the tooltip
- // title via the dataset.
- const labels = series.map((row) => {
- const d = new Date(row.day + 'T00:00:00Z');
- return isNaN(d.getTime()) ? row.day : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
- });
- const data = series.map((row) => row.count || 0);
- const t = chartTheme();
- new Chart(canvas, {
- type: 'line',
- data: {
- labels,
- datasets: [{
- label: 'bans',
- data,
- borderColor: '#ef4444',
- backgroundColor: 'rgba(239,68,68,0.18)',
- tension: 0.3,
- fill: true,
- pointRadius: 3,
- pointHoverRadius: 5,
- pointBackgroundColor: '#ef4444',
- }],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: { display: false },
- tooltip: {
- callbacks: {
- title: (items) => (items[0] && series[items[0].dataIndex]?.day) || '',
- },
- },
- },
- scales: {
- x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
- y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
- },
- },
- });
- }
- document.addEventListener('DOMContentLoaded', () => {
- renderReportsChart();
- renderPieChart('top-reporters-chart');
- renderPieChart('top-categories-chart');
- renderBansTrendChart();
- });
- // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
- // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
- // string as a no-JS fallback. This pass replaces it with the user's
- // browser locale formatting, with an optional configured fallback (set
- // via UI_LOCALE on the html data attribute) appended so browser locale
- // wins but a deployment can still ensure something sensible if the
- // browser's preference isn't supported. `data-irdb-dt-format="date"`
- // switches to date-only output.
- function getDateLocales() {
- const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
- const locales = [];
- if (typeof navigator !== 'undefined' && navigator.language) {
- locales.push(navigator.language);
- }
- if (fallback && fallback.trim()) {
- locales.push(fallback.trim());
- }
- return locales.length > 0 ? locales : undefined;
- }
- function buildDateFormatters() {
- const locales = getDateLocales();
- return {
- datetime: new Intl.DateTimeFormat(locales, {
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- }),
- date: new Intl.DateTimeFormat(locales, {
- year: 'numeric', month: '2-digit', day: '2-digit',
- }),
- };
- }
- function formatTimes(root) {
- const scope = root && root.querySelectorAll ? root : document;
- const formatters = buildDateFormatters();
- const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
- elements.forEach((el) => {
- const iso = el.getAttribute('datetime');
- if (!iso) return;
- const d = new Date(iso);
- if (isNaN(d.getTime())) return;
- const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
- try {
- el.textContent = fmt.format(d);
- if (!el.hasAttribute('title')) {
- el.setAttribute('title', iso);
- }
- } catch (e) {
- /* leave the ISO fallback in place */
- }
- });
- }
- document.addEventListener('DOMContentLoaded', () => formatTimes(document));
- document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
- // Sortable tables. Tables marked with `data-sortable-table="<id>"` get
- // click-to-sort headers. Sort state lives in sessionStorage under
- // `irdb-sort:<id>` and is wiped on the login page so logging out
- // forgets it. The comparator is selected by the th's `data-sort-type`
- // (`string` | `number` | `date`); cells may override the displayed
- // text via `data-sort-value`.
- const SORT_KEY_PREFIX = 'irdb-sort:';
- function readSortState(tableId) {
- try {
- const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
- if (!raw) return null;
- const parsed = JSON.parse(raw);
- if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
- return parsed;
- }
- } catch (e) { /* ignore */ }
- return null;
- }
- function writeSortState(tableId, state) {
- try {
- if (state) {
- sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
- } else {
- sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
- }
- } catch (e) { /* ignore */ }
- }
- function clearAllSortState() {
- try {
- const keys = [];
- for (let i = 0; i < sessionStorage.length; i++) {
- const k = sessionStorage.key(i);
- if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
- }
- keys.forEach((k) => sessionStorage.removeItem(k));
- } catch (e) { /* ignore */ }
- }
- function cellSortValue(cell, type) {
- if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
- const explicit = cell.getAttribute('data-sort-value');
- if (explicit !== null) return explicit;
- // <time datetime="..."> is the canonical date carrier.
- const timeEl = cell.querySelector('time[datetime]');
- if (timeEl) return timeEl.getAttribute('datetime') || '';
- return (cell.textContent || '').trim();
- }
- function compareValues(a, b, type) {
- if (type === 'number') {
- const na = parseFloat(a);
- const nb = parseFloat(b);
- const aNaN = Number.isNaN(na);
- const bNaN = Number.isNaN(nb);
- if (aNaN && bNaN) return 0;
- if (aNaN) return 1; // empty/— goes last on asc
- if (bNaN) return -1;
- return na - nb;
- }
- if (type === 'date') {
- const ta = a ? Date.parse(a) : NaN;
- const tb = b ? Date.parse(b) : NaN;
- const aNaN = Number.isNaN(ta);
- const bNaN = Number.isNaN(tb);
- if (aNaN && bNaN) return 0;
- if (aNaN) return 1;
- if (bNaN) return -1;
- return ta - tb;
- }
- // string
- return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
- }
- function findSortableThs(table) {
- // Only <th> in the *first* thead row (avoid scanning nested tables
- // — the score-chart has its own SVG layout, not a sortable table).
- const head = table.tHead;
- if (!head || !head.rows.length) return [];
- return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
- }
- function applySort(table, key, dir) {
- const ths = findSortableThs(table);
- const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
- if (!th) return;
- const colIndex = th.cellIndex;
- const type = th.getAttribute('data-sort-type') || 'string';
- const tbody = table.tBodies[0];
- if (!tbody) return;
- // Treat each contiguous run of rows with the same `data-sort-row-group`
- // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
- // expandable rows (e.g. the audit log payload toggle) stay glued to
- // their parent. By default, every row sorts independently.
- const allRows = Array.from(tbody.rows);
- const groups = [];
- let current = null;
- for (const row of allRows) {
- if (row.hasAttribute('data-sort-row-detail') && current) {
- current.push(row);
- continue;
- }
- current = [row];
- groups.push(current);
- }
- groups.sort((ga, gb) => {
- const va = cellSortValue(ga[0].cells[colIndex], type);
- const vb = cellSortValue(gb[0].cells[colIndex], type);
- const cmp = compareValues(va, vb, type);
- return dir === 'desc' ? -cmp : cmp;
- });
- const frag = document.createDocumentFragment();
- groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
- tbody.appendChild(frag);
- // Update indicators.
- ths.forEach((t) => {
- const ind = t.querySelector('.sort-indicator');
- const isActive = t === th;
- if (ind) {
- ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
- ind.classList.toggle('text-slate-300', !isActive);
- ind.classList.toggle('dark:text-slate-600', !isActive);
- ind.classList.toggle('text-indigo-600', isActive);
- ind.classList.toggle('dark:text-indigo-400', isActive);
- }
- t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
- });
- }
- function initSortableTable(table) {
- if (!table || table.dataset.sortableInit === '1') return;
- table.dataset.sortableInit = '1';
- const tableId = table.getAttribute('data-sortable-table');
- if (!tableId) return;
- const ths = findSortableThs(table);
- if (ths.length === 0) return;
- ths.forEach((th) => {
- th.classList.add('cursor-pointer', 'select-none');
- th.addEventListener('click', () => {
- const key = th.getAttribute('data-sort-key');
- const current = readSortState(tableId);
- const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
- writeSortState(tableId, { key, dir });
- applySort(table, key, dir);
- });
- });
- const initial = readSortState(tableId);
- if (initial) {
- applySort(table, initial.key, initial.dir);
- }
- }
- function initSortableTables(root) {
- const scope = root && root.querySelectorAll ? root : document;
- scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
- }
- document.addEventListener('DOMContentLoaded', () => {
- // The login page is the canonical "logged out" landing spot —
- // wipe any sort state left over from the previous session.
- if (window.location && window.location.pathname === '/login') {
- clearAllSortState();
- }
- initSortableTables(document);
- });
- document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
- window.Alpine = Alpine;
- Alpine.start();
|