| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- 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));
- window.Alpine = Alpine;
- Alpine.start();
|