From 89beb5f16001245a1b27fb5ab8cc36722b182eff Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 14:23:25 +0700 Subject: [PATCH] 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). --- docker/frontend/src/Admin.tsx | 7 +- docker/frontend/src/api.ts | 25 +++++ docker/frontend/src/i18n/en.ts | 18 ++++ docker/frontend/src/i18n/vi.ts | 20 ++++ docker/frontend/src/main.tsx | 5 + docker/frontend/src/styles/app.css | 32 ++++++ docker/frontend/src/track.ts | 48 +++++++++ docker/frontend/src/ui/Stats.tsx | 151 +++++++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 docker/frontend/src/track.ts create mode 100644 docker/frontend/src/ui/Stats.tsx diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 9d40f78..f3c33a1 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api'; import { useI18n } from './i18n/I18nProvider'; import { Profile } from './ui/Profile'; +import { Stats } from './ui/Stats'; import type { MsgKey } from './i18n/vi'; // Curating screen: one bordered frame, a left column of what can be managed and @@ -9,13 +10,14 @@ import type { MsgKey } from './i18n/vi'; // Profile — the signed-in account's own picture/email/password // User account — every account: its name, email, picture and contribution count // Pictures — every photo in the system, with the slot that shows it +// Stats — the visitor counter: views, clicks and their breakdowns // Close — leaves the frame and goes back to the landing page // The frame holds no privilege of its own — the API answers 403 unless the // signed-in account is on the ADMIN_EMAILS allowlist, so this is only a viewer. // ponytail: no pagination. The upload quota caps the table at a handful of // rows per account; add a page cursor when the strip outgrows one screen. type State = 'loading' | 'guest' | 'forbidden' | 'ready'; -type Tab = 'profile' | 'users' | 'pictures'; +type Tab = 'profile' | 'users' | 'pictures' | 'stats'; // The four places a photo can be drawn from. `strip` feeds the community reel; // each live slot shows one photo of its set, picked at random per page load. @@ -31,6 +33,7 @@ const MENU: { id: Tab; key: MsgKey }[] = [ { id: 'profile', key: 'adm.tabProfile' }, { id: 'users', key: 'adm.tabUsers' }, { id: 'pictures', key: 'adm.tabPictures' }, + { id: 'stats', key: 'adm.tabStats' }, ]; export function Admin() { @@ -429,6 +432,8 @@ export function Admin() { ) : null} {tab === 'profile' ? setNote(msg)} /> : null} + + {tab === 'stats' ? : null} ) : null} diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 5c45179..9e711f9 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { 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(`/admin/stats?days=${days}`), adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'), adminDeletePhoto: (id: number) => call(`/admin/photos/${id}`, { method: 'DELETE' }), adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }), diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 10b15da..b4018d7 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -196,6 +196,24 @@ export const en: Dict = { 'adm.selectNone': 'SELECT NONE', 'adm.deleteSelected': 'DELETE SELECTED ({n})', 'adm.userDeleteSelectedConfirm': 'Permanently delete the {n} selected accounts with all of their photos and recipes? This cannot be undone.', + 'adm.tabStats': 'STATS', + + 'stats.days': '{n} DAYS', + 'stats.hint': 'Traffic over the selected range.', + 'stats.views': 'PAGE VIEWS', + 'stats.clicks': 'FEATURE CLICKS', + 'stats.visitors': 'VISITORS', + 'stats.timeline': 'TRAFFIC PER DAY', + 'stats.peak': 'peak', + 'stats.pages': 'PAGES VIEWED', + 'stats.targets': 'FEATURES CLICKED', + 'stats.countries': 'COUNTRIES', + 'stats.regions': 'REGIONS', + 'stats.cities': 'CITIES', + 'stats.browsers': 'BROWSERS', + 'stats.systems': 'OPERATING SYSTEMS', + 'stats.devices': 'DEVICES', + 'stats.empty': 'No data yet.', 'profile.title': 'My profile', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 9ef74a3..1268175 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -199,6 +199,26 @@ export const vi = { 'adm.selectNone': 'BỎ CHỌN', 'adm.deleteSelected': 'XOÁ ĐÃ CHỌN ({n})', 'adm.userDeleteSelectedConfirm': 'Xoá vĩnh viễn {n} tài khoản đã chọn cùng toàn bộ ảnh và công thức của chúng? Không hoàn tác được.', + 'adm.tabStats': 'THỐNG KÊ', + + // The traffic screen. Bucket names ('/app', a control's data-key, a browser + // version) are machine values and stay as they are. + 'stats.days': '{n} NGÀY', + 'stats.hint': 'Lưu lượng trong khoảng đã chọn.', + 'stats.views': 'LƯỢT XEM', + 'stats.clicks': 'LƯỢT NHẤN', + 'stats.visitors': 'KHÁCH TRUY CẬP', + 'stats.timeline': 'LƯU LƯỢNG THEO NGÀY', + 'stats.peak': 'đỉnh', + 'stats.pages': 'TRANG ĐƯỢC XEM', + 'stats.targets': 'TÍNH NĂNG ĐƯỢC NHẤN', + 'stats.countries': 'QUỐC GIA', + 'stats.regions': 'VÙNG MIỀN', + 'stats.cities': 'THÀNH PHỐ', + 'stats.browsers': 'TRÌNH DUYỆT', + 'stats.systems': 'HỆ ĐIỀU HÀNH', + 'stats.devices': 'THIẾT BỊ', + 'stats.empty': 'Chưa có dữ liệu.', 'profile.title': 'Hồ sơ của tôi', diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 71a9daf..ff83339 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -10,6 +10,7 @@ import { Workspace } from './App'; import { Admin } from './Admin'; import { ProfilePage } from './ProfilePage'; import { PhotosPage } from './PhotosPage'; +import { installTracking } from './track'; // Five routes, no router: the landing page, the workspace, the strip moderation // screen, the member's own profile and the member's photo folder. nginx serves @@ -29,6 +30,10 @@ const page = path.startsWith('/app') ? ( const root = document.getElementById('root'); if (!root) throw new Error('#root missing'); +// The traffic counter records this page and then any click on a `data-key` +// control, for the life of the page. +installTracking(); + // No StrictMode: it double-invokes effects, which would load CanvasKit twice and // run the render pipeline twice on every mount. createRoot(root).render( diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 1394d5e..42635fe 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -463,6 +463,38 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } /* The signed-in name rides the accent with the face beside it. */ .btn.account-btn { color: var(--accent); } +/* --- stats (admin) ------------------------------------------------------- */ +/* The traffic screen: one SVG chart and a column of proportional bar lists. + No charting dependency — a couple of polylines and styled divs are enough. */ +.stats-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 14px; } +.stats-bar .hint { margin: 0; } +.stats-kpis { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 16px; } +.stats-kpi { + display: flex; flex-direction: column; gap: 2px; min-width: 104px; + padding: 10px 14px; border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--bg-elev); +} +.stats-kpi b { font-size: 22px; font-variant-numeric: tabular-nums; } +.stats-kpi span { font-size: 11px; letter-spacing: 0.06em; color: var(--text-dim); } +.stats-chart { + display: block; width: 100%; height: 180px; + border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-sunken); +} +.stats-area { fill: var(--accent); opacity: 0.12; } +.stats-line { fill: none; stroke: var(--accent); stroke-width: 1.5; } +.stats-line-2 { stroke: var(--text-dim); } +.stats-legend { display: flex; gap: 14px; margin: 8px 0 18px; font-size: 11px; color: var(--text-dim); } +.stats-legend i { display: inline-block; width: 10px; height: 3px; border-radius: 2px; margin-right: 5px; vertical-align: middle; } +.stats-axis { margin: 0 0 18px; } +.stats-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px 26px; } +.stats-h { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; color: var(--text-dim); } +.stats-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; } +.stats-row { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 12px; } +.stats-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); } +.stats-track { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; } +.stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); } +.stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; } + /* --- responsive --------------------------------------------------------- */ @media (max-width: 860px) { .workspace { flex-direction: column; } diff --git a/docker/frontend/src/track.ts b/docker/frontend/src/track.ts new file mode 100644 index 0000000..6608855 --- /dev/null +++ b/docker/frontend/src/track.ts @@ -0,0 +1,48 @@ +// The page counter's browser end. One beacon per page view, one per click on an +// element that carries a `data-key` — that attribute is already on nearly every +// control, so it doubles as the feature's name. Nothing here may break the app: +// every path is wrapped, a refused beacon is simply not counted, and a visitor +// who asked not to be tracked is left alone. +const ENDPOINT = '/api/events'; + +type Kind = 'view' | 'click'; + +function send(kind: Kind, path: string, target?: string): void { + try { + if (navigator.doNotTrack === '1') return; + const body = JSON.stringify({ kind, path, target }); + const blob = new Blob([body], { type: 'application/json' }); + // sendBeacon survives the page unload a click often triggers; the fetch is + // the fallback for a browser that has no beacon (or refuses this one). + if (navigator.sendBeacon?.(ENDPOINT, blob)) return; + void fetch(ENDPOINT, { + method: 'POST', + body, + keepalive: true, + headers: { 'content-type': 'application/json' }, + }).catch(() => {}); + } catch { + // Analytics is best-effort — a failure here must never reach the app. + } +} + +export function track(kind: Kind, path: string, target?: string): void { + send(kind, path, target); +} + +// Call once, before the first render. The view is recorded now; the click is +// recorded from one capturing listener on the document, so a control keeps its +// own handler and needs no tracking code of its own. +export function installTracking(): void { + send('view', window.location.pathname); + document.addEventListener( + 'click', + (event) => { + const node = event.target; + const el = node instanceof Element ? node.closest('[data-key]') : null; + const target = el?.getAttribute('data-key'); + if (target) send('click', window.location.pathname, target); + }, + true, + ); +} diff --git a/docker/frontend/src/ui/Stats.tsx b/docker/frontend/src/ui/Stats.tsx new file mode 100644 index 0000000..c79e0a6 --- /dev/null +++ b/docker/frontend/src/ui/Stats.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react'; +import { api, type StatBucket, type Stats as StatsData } from '../api'; +import { useI18n } from '../i18n/I18nProvider'; + +// The traffic screen. One fetch per range; every figure below is drawn from it. +// No charting dependency: two polylines and a list of styled divs are what an +// admin actually reads here, and they cost a few hundred bytes instead of a +// library's worth. +const RANGES = [7, 30, 90]; + +export function Stats() { + const { t } = useI18n(); + const [days, setDays] = useState(30); + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let live = true; + setErr(null); + api.adminStats(days).then( + (d) => { + if (live) setData(d); + }, + (e: unknown) => { + if (live) setErr(e instanceof Error ? e.message : String(e)); + }, + ); + return () => { + live = false; + }; + }, [days]); + + return ( +
+
+ {RANGES.map((n) => ( + + ))} + {t('stats.hint')} +
+ + {err ?

{err}

: null} + {data ? : err ? null :

{t('auth.busy')}

} +
+ ); +} + +function Body({ data }: { data: StatsData }) { + const { t } = useI18n(); + return ( + <> +
+ + + +
+ +
+ + + + + + + + +
+ + ); +} + +function Kpi({ k, label, n }: { k: string; label: string; n: number }) { + return ( +
+ {n} + {label} +
+ ); +} + +// Views and clicks per day. `viewBox` plus `preserveAspectRatio="none"` lets the +// SVG stretch to the pane's width without measuring the DOM. +function Chart({ series }: { series: StatsData['series'] }) { + const { t } = useI18n(); + const W = 600; + const H = 100; + const max = Math.max(1, ...series.map((d) => Math.max(d.views, d.clicks))); + const step = series.length > 1 ? W / (series.length - 1) : 0; + const line = (pick: (d: StatsData['series'][number]) => number) => + series.map((d, i) => `${(i * step).toFixed(1)},${(H - (pick(d) / max) * H).toFixed(1)}`).join(' '); + const first = series[0]?.date ?? ''; + const last = series[series.length - 1]?.date ?? ''; + + return ( +
+

{t('stats.timeline')}

+ + d.views)} ${W},${H}`} /> + d.views)} + /> + d.clicks)} + /> + +
+ {t('stats.views')} + {t('stats.clicks')} +
+

{first} → {last} · {t('stats.peak')} {max}

+
+ ); +} + +// One grouped count, longest bar first. The bar is proportional to the block's +// own largest row, so a small dimension stays readable next to a big one. +function Bars({ k, title, rows }: { k: string; title: string; rows: StatBucket[] }) { + const { t } = useI18n(); + const max = Math.max(1, ...rows.map((r) => r.n)); + return ( +
+

{title}

+ {rows.length === 0 ? ( +

{t('stats.empty')}

+ ) : ( +
    + {rows.map((r) => ( +
  • + {r.key} + + {r.n} +
  • + ))} +
+ )} +
+ ); +}