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
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,
);
// Expose Chart globally so per-page inline scripts (e.g. the policy
// score-distribution component) can render charts without re-bundling.
window.Chart = Chart;
// 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 renderBlockedIpsChart() {
const canvas = document.getElementById('blocked-ips-chart');
if (!canvas) return;
let payload;
try {
payload = JSON.parse(canvas.dataset.blocked || '{}');
} catch (e) {
return;
}
const days = Array.isArray(payload.days) ? payload.days : [];
const series = Array.isArray(payload.series) ? payload.series : [];
if (days.length === 0 || series.length === 0) return;
// Day-of-month for axis ticks; the ISO date is preserved in the
// tooltip title via the closure on `days`.
const labels = days.map((iso) => {
const d = new Date(iso + 'T00:00:00Z');
return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
});
const datasets = series.map((row, i) => {
const colour = PIE_COLORS[i % PIE_COLORS.length];
return {
label: row.category || '—',
data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
backgroundColor: colour,
borderColor: colour,
borderWidth: 0,
};
});
const t = chartTheme();
new Chart(canvas, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
title: (items) => (items[0] && days[items[0].dataIndex]) || '',
},
},
},
scales: {
x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
y: {
stacked: true,
ticks: { color: t.tickColor, precision: 0 },
grid: { color: t.gridColor },
beginAtZero: true,
},
},
},
});
}
document.addEventListener('DOMContentLoaded', () => {
renderReportsChart();
renderPieChart('top-reporters-chart');
renderPieChart('top-categories-chart');
renderBlockedIpsChart();
});
// Locale-aware