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;
// Faded "glass" palette: Tailwind -400 tones for a powdery, translucent
// feel. Emerald-400 (#34d399) matches the mid-stop of the logo's gradient.
const PIE_COLORS = [
'#34d399', // emerald-400 (matches logo's mid-stop)
'#22d3ee', // cyan-400
'#818cf8', // indigo-400
'#a78bfa', // violet-400
'#f472b6', // pink-400
'#fb7185', // rose-400
'#fbbf24', // amber-400
'#a3e635', // lime-400
'#2dd4bf', // teal-400
'#60a5fa', // blue-400
];
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 hexToRgba(hex, alpha) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
if (!m) return hex;
return `rgba(${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)},${alpha})`;
}
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: 'rgba(52, 211, 153, 0.7)', // emerald-400 @ 70% — frosted bar
borderColor: 'rgba(52, 211, 153, 0.9)',
borderWidth: 1,
borderRadius: 4,
}],
},
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.25)', // crisper glass-segment seam
borderWidth: 1.5,
}],
},
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: hexToRgba(colour, 0.7), // frosted fill
borderColor: colour, // crisp glass edge
borderWidth: 1,
};
});
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