web: put the traffic screen in the admin frame

installTracking() beacons one view per page load and one click per control that
carries a data-key, so every existing button is already counted. The new STATS
pane reads it back: a 7/30/90-day range, the three totals, an SVG timeline and
eight proportional bar lists (pages, clicked features, country, region, city,
browser, system, device).
This commit is contained in:
2026-09-18 14:23:25 +07:00
parent 8a7192eae8
commit 89beb5f160
8 changed files with 305 additions and 1 deletions
+25
View File
@@ -84,6 +84,29 @@ export interface AdminUser {
removed: boolean;
}
// One row of a grouped count on the traffic screen. The API sorts them, largest
// first, and drops the empty ones.
export interface StatBucket {
key: string;
n: number;
}
// What /api/admin/stats reports: the day series the chart draws, plus one
// grouped breakdown per dimension. Admin only.
export interface Stats {
days: number;
totals: { views: number; clicks: number; visitors: number };
series: { date: string; views: number; clicks: number }[];
pages: StatBucket[];
targets: StatBucket[];
countries: StatBucket[];
regions: StatBucket[];
cities: StatBucket[];
browsers: StatBucket[];
systems: StatBucket[];
devices: StatBucket[];
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: 'same-origin',
@@ -167,6 +190,8 @@ export const api = {
// Admin only: every account, with how many photos it owns.
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
// The traffic screen. `days` is clamped server-side, never rejected.
adminStats: (days: number) => call<Stats>(`/admin/stats?days=${days}`),
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),