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
+6 -1
View File
@@ -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' ? <Profile onSaved={(msg) => setNote(msg)} /> : null}
{tab === 'stats' ? <Stats /> : null}
</div>
</div>
) : null}
+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' }),
+18
View File
@@ -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',
+20
View File
@@ -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',
+5
View File
@@ -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(
+32
View File
@@ -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; }
+48
View File
@@ -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,
);
}
+151
View File
@@ -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<StatsData | null>(null);
const [err, setErr] = useState<string | null>(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 (
<div data-key="stats-pane">
<div className="stats-bar">
{RANGES.map((n) => (
<button
key={n}
type="button"
className={`btn${days === n ? ' primary' : ''}`}
data-key={`stats-range-${n}`}
aria-pressed={days === n}
onClick={() => setDays(n)}
>
{t('stats.days', { n })}
</button>
))}
<span className="hint">{t('stats.hint')}</span>
</div>
{err ? <p className="err adm-note">{err}</p> : null}
{data ? <Body data={data} /> : err ? null : <p className="hint adm-note">{t('auth.busy')}</p>}
</div>
);
}
function Body({ data }: { data: StatsData }) {
const { t } = useI18n();
return (
<>
<div className="stats-kpis">
<Kpi k="views" label={t('stats.views')} n={data.totals.views} />
<Kpi k="clicks" label={t('stats.clicks')} n={data.totals.clicks} />
<Kpi k="visitors" label={t('stats.visitors')} n={data.totals.visitors} />
</div>
<Chart series={data.series} />
<div className="stats-cols">
<Bars k="pages" title={t('stats.pages')} rows={data.pages} />
<Bars k="targets" title={t('stats.targets')} rows={data.targets} />
<Bars k="countries" title={t('stats.countries')} rows={data.countries} />
<Bars k="regions" title={t('stats.regions')} rows={data.regions} />
<Bars k="cities" title={t('stats.cities')} rows={data.cities} />
<Bars k="browsers" title={t('stats.browsers')} rows={data.browsers} />
<Bars k="systems" title={t('stats.systems')} rows={data.systems} />
<Bars k="devices" title={t('stats.devices')} rows={data.devices} />
</div>
</>
);
}
function Kpi({ k, label, n }: { k: string; label: string; n: number }) {
return (
<div className="stats-kpi" data-key={`stats-kpi-${k}`}>
<b data-key={`stats-kpi-${k}-n`}>{n}</b>
<span>{label}</span>
</div>
);
}
// 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 (
<section data-key="stats-chart">
<h3 className="stats-h">{t('stats.timeline')}</h3>
<svg className="stats-chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img">
<polygon className="stats-area" points={`0,${H} ${line((d) => d.views)} ${W},${H}`} />
<polyline
className="stats-line"
vectorEffect="non-scaling-stroke"
points={line((d) => d.views)}
/>
<polyline
className="stats-line stats-line-2"
vectorEffect="non-scaling-stroke"
points={line((d) => d.clicks)}
/>
</svg>
<div className="stats-legend">
<span><i style={{ background: 'var(--accent)' }} />{t('stats.views')}</span>
<span><i style={{ background: 'var(--text-dim)' }} />{t('stats.clicks')}</span>
</div>
<p className="hint stats-axis" data-key="stats-axis">{first} → {last} · {t('stats.peak')} {max}</p>
</section>
);
}
// 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 (
<section data-key={`stats-block-${k}`}>
<h3 className="stats-h">{title}</h3>
{rows.length === 0 ? (
<p className="hint">{t('stats.empty')}</p>
) : (
<ul className="stats-list">
{rows.map((r) => (
<li className="stats-row" key={r.key}>
<span className="stats-key" title={r.key}>{r.key}</span>
<span className="stats-track"><i style={{ width: `${(r.n / max) * 100}%` }} /></span>
<b className="stats-n">{r.n}</b>
</li>
))}
</ul>
)}
</section>
);
}