Records pageviews (path, referrer, browser/OS/device, IP, GeoIP country) via a POST /api/track beacon into a new PageView table, and exposes aggregated daily/breakdown stats behind a token-gated GET /api/admin/stats endpoint with brute-force lockout. Frontend gets a /admin dashboard (charts + breakdown tables) built on recharts, with a switchable per-day pageviews chart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import {
|
|
CartesianGrid,
|
|
Legend,
|
|
Line,
|
|
LineChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts';
|
|
|
|
type Dimension = 'total' | 'device' | 'browser' | 'os';
|
|
|
|
const DIMENSION_LABELS: Record<Dimension, string> = {
|
|
total: 'Spolu',
|
|
device: 'Zariadenie',
|
|
browser: 'Prehliadač',
|
|
os: 'OS',
|
|
};
|
|
|
|
// Cycled per category line -- theme golds/creams, enough spread to stay
|
|
// distinguishable across the handful of browsers/OSes/device types we expect.
|
|
const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064', '#7a6e4a'];
|
|
|
|
interface Props {
|
|
total: Record<string, number>;
|
|
byDevice: Record<string, Record<string, number>>;
|
|
byBrowser: Record<string, Record<string, number>>;
|
|
byOs: Record<string, Record<string, number>>;
|
|
}
|
|
|
|
export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) {
|
|
const [dimension, setDimension] = useState<Dimension>('total');
|
|
|
|
const { rows, categories } = useMemo(() => {
|
|
if (dimension === 'total') {
|
|
const days = Object.keys(total).sort();
|
|
return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] };
|
|
}
|
|
const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
|
|
const days = Object.keys(byDay).sort();
|
|
const categories = [...new Set(days.flatMap((d) => Object.keys(byDay[d])))].sort();
|
|
const rows = days.map((day) => {
|
|
const row: Record<string, number | string> = { day };
|
|
for (const c of categories) row[c] = byDay[day][c] ?? 0;
|
|
return row;
|
|
});
|
|
return { rows, categories };
|
|
}, [dimension, total, byDevice, byBrowser, byOs]);
|
|
|
|
return (
|
|
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
|
<p className="text-sm text-gold">Návštevy za deň</p>
|
|
<div className="flex gap-1">
|
|
{(Object.keys(DIMENSION_LABELS) as Dimension[]).map((d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => setDimension(d)}
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
dimension === d ? 'bg-gold text-table' : 'text-green-dim hover:text-gold'
|
|
}`}
|
|
>
|
|
{DIMENSION_LABELS[d]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<LineChart data={rows}>
|
|
<CartesianGrid stroke="#142018" />
|
|
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
|
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
|
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
|
{dimension !== 'total' && <Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />}
|
|
{categories.map((c, i) => (
|
|
<Line
|
|
key={c}
|
|
type="monotone"
|
|
dataKey={c}
|
|
name={dimension === 'total' ? 'Návštevy' : c}
|
|
stroke={PALETTE[i % PALETTE.length]}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|