diff --git a/.gitignore b/.gitignore index 4702c83..ede46df 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist .DS_Store npm-debug.log* vite-dev*.log +server-local*.log +.local-storage/ diff --git a/README.md b/README.md index 98bdb22..c132d02 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Routes: - `/teams` TSS team leaderboard - `/teams/:teamname` generated team profile with roster, summary, rating history, and battle results - `/battle-logs` Battle Logs +- `/viewers` public consented viewer analytics dashboard ## Local development @@ -83,6 +84,30 @@ UPTIME_HISTORY_LIMIT=336 The server creates the storage folder, SQLite database, and `uptime_snapshots` table automatically. +## Viewer analytics + +The site shows a GDPR-style consent banner before analytics start. If a visitor +allows analytics, the browser sends page-view and heartbeat events to +`POST /api/viewers/event`. The public `/viewers` page reads `GET /api/viewers` +and shows active pages, client/browser information, 24-hour page totals, and +top pages. + +Viewer analytics are stored in SQLite under the same `UPTIME_STORAGE_DIR` by +default. Raw IP addresses are not stored in the public response; the server +stores a salted IP hash for deduplication and abuse review. Set a unique salt in +production: + +```sh +ANALYTICS_DATABASE_FILE=viewers.sqlite +ANALYTICS_RETENTION_DAYS=30 +ANALYTICS_ACTIVE_WINDOW_SECONDS=75 +ANALYTICS_SALT=replace-with-a-random-secret +``` + +This is an implementation aid, not legal advice. For production GDPR compliance, +publish a privacy notice that matches the configured retention period and data +fields, and make sure the configured salt is secret. + ## GitHub webhook The webhook process listens on port `3011` at `/github`. Configure GitHub to send diff --git a/example.env b/example.env index b1a8b03..9d59bd3 100644 --- a/example.env +++ b/example.env @@ -7,6 +7,10 @@ UPTIME_STORAGE_DIR=~/tsswebstorage UPTIME_DATABASE_FILE=uptime.sqlite UPTIME_SAMPLE_INTERVAL_MS=1800000 UPTIME_HISTORY_LIMIT=336 +ANALYTICS_DATABASE_FILE=viewers.sqlite +ANALYTICS_RETENTION_DAYS=30 +ANALYTICS_ACTIVE_WINDOW_SECONDS=75 +ANALYTICS_SALT=change-me-viewer-salt API_CACHE_TTL_MS=15000 API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX=120 diff --git a/server.cjs b/server.cjs index 100e2d7..4b49ebd 100644 --- a/server.cjs +++ b/server.cjs @@ -1,4 +1,5 @@ const fs = require('node:fs') +const crypto = require('node:crypto') const http = require('node:http') const https = require('node:https') const os = require('node:os') @@ -42,6 +43,10 @@ const UPTIME_STORAGE_DIR = process.env.UPTIME_STORAGE_DIR || '~/tsswebstorage' const UPTIME_DATABASE_FILE = process.env.UPTIME_DATABASE_FILE || 'uptime.sqlite' const UPTIME_SAMPLE_INTERVAL_MS = Number(process.env.UPTIME_SAMPLE_INTERVAL_MS || 30 * 60 * 1000) const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336) +const ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite' +const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30) +const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75) +const ANALYTICS_SALT = process.env.ANALYTICS_SALT || 'change-me-viewer-salt' const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000) const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) @@ -49,6 +54,7 @@ const DIST_DIR = path.join(__dirname, 'dist') const MAX_TEAM_NAME_LENGTH = 80 const MAX_CACHE_ENTRIES = 200 const MAX_RATE_LIMIT_KEYS = 1000 +const MAX_ANALYTICS_BODY_BYTES = 16 * 1024 const mimeTypes = { '.css': 'text/css; charset=utf-8', @@ -76,6 +82,7 @@ const jsonHeaders = { const apiCache = new Map() const rateLimits = new Map() let uptimeDb = null +let analyticsDb = null let latestUptimeSnapshot = null function sendJson(res, status, body, headers = {}) { @@ -138,6 +145,67 @@ function uptimeStoragePath() { return path.resolve(expandHome(UPTIME_STORAGE_DIR)) } +function ensureAnalyticsDb() { + if (analyticsDb) return analyticsDb + + const storageDir = uptimeStoragePath() + fs.mkdirSync(storageDir, { recursive: true }) + + analyticsDb = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE)) + analyticsDb.pragma('journal_mode = WAL') + analyticsDb.exec(` + create table if not exists viewer_events ( + id integer primary key autoincrement, + occurred_at text not null default (datetime('now')), + visitor_id text not null, + session_id text not null, + ip_hash text not null, + event_type text not null, + page_path text not null, + page_title text not null, + referrer text not null default '', + user_agent text not null default '', + browser text not null default 'Unknown', + os text not null default 'Unknown', + device text not null default 'Desktop', + screen text not null default '', + language text not null default '', + timezone text not null default '', + consent text not null default 'analytics', + metadata text not null default '{}' + ); + + create table if not exists active_viewers ( + session_id text primary key, + visitor_id text not null, + ip_hash text not null, + first_seen_at text not null, + last_seen_at text not null, + page_path text not null, + page_title text not null, + referrer text not null default '', + user_agent text not null default '', + browser text not null default 'Unknown', + os text not null default 'Unknown', + device text not null default 'Desktop', + screen text not null default '', + language text not null default '', + timezone text not null default '' + ); + + create index if not exists viewer_events_occurred_at_idx + on viewer_events (occurred_at desc); + + create index if not exists viewer_events_page_path_idx + on viewer_events (page_path, occurred_at desc); + + create index if not exists active_viewers_last_seen_at_idx + on active_viewers (last_seen_at desc); + `) + + return analyticsDb +} + function ensureUptimeDb() { if (uptimeDb) return uptimeDb @@ -308,6 +376,221 @@ function clientIp(req) { return req.socket.remoteAddress || 'unknown' } +function hashIp(ip) { + return crypto.createHash('sha256').update(`${ANALYTICS_SALT}:${ip}`).digest('hex') +} + +function sanitizeText(value, maxLength = 200) { + return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength) +} + +function sanitizePath(value) { + const raw = sanitizeText(value, 300) + if (!raw.startsWith('/')) return '/' + return raw +} + +function parseClient(userAgent = '') { + const ua = String(userAgent) + let browser = 'Unknown' + let osName = 'Unknown' + let device = 'Desktop' + + if (/Edg\//.test(ua)) browser = 'Microsoft Edge' + else if (/OPR\//.test(ua)) browser = 'Opera' + else if (/Firefox\//.test(ua)) browser = 'Firefox' + else if (/Chrome\//.test(ua) && !/Chromium\//.test(ua)) browser = 'Chrome' + else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = 'Safari' + + if (/Windows NT/.test(ua)) osName = 'Windows' + else if (/Android/.test(ua)) osName = 'Android' + else if (/(iPhone|iPad|iPod)/.test(ua)) osName = 'iOS' + else if (/Mac OS X/.test(ua)) osName = 'macOS' + else if (/Linux/.test(ua)) osName = 'Linux' + + if (/Mobi|Android|iPhone|iPod/.test(ua)) device = 'Mobile' + else if (/iPad|Tablet/.test(ua)) device = 'Tablet' + + return { browser, os: osName, device } +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + const chunks = [] + let size = 0 + + req.on('data', (chunk) => { + size += chunk.length + if (size > MAX_ANALYTICS_BODY_BYTES) { + reject(new Error('Request body too large')) + req.destroy() + return + } + chunks.push(chunk) + }) + + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString('utf8') + resolve(body ? JSON.parse(body) : {}) + } catch { + reject(new Error('Invalid JSON body')) + } + }) + req.on('error', reject) + }) +} + +function purgeOldAnalytics(db) { + db.prepare(` + delete from viewer_events + where occurred_at < datetime('now', ?) + `).run(`-${ANALYTICS_RETENTION_DAYS} days`) + + db.prepare(` + delete from active_viewers + where last_seen_at < datetime('now', ?) + `).run(`-${ANALYTICS_ACTIVE_WINDOW_SECONDS * 3} seconds`) +} + +function recordViewerEvent(req, payload) { + const db = ensureAnalyticsDb() + purgeOldAnalytics(db) + + const serverClient = parseClient(req.headers['user-agent'] || '') + const event = { + visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(), + session_id: sanitizeText(payload.session_id, 80) || crypto.randomUUID(), + ip_hash: hashIp(clientIp(req)), + event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type) + ? payload.event_type + : 'heartbeat', + page_path: sanitizePath(payload.page_path), + page_title: sanitizeText(payload.page_title, 160), + referrer: sanitizeText(payload.referrer, 300), + user_agent: sanitizeText(req.headers['user-agent'] || payload.user_agent, 500), + browser: sanitizeText(payload.browser || serverClient.browser, 80), + os: sanitizeText(payload.os || serverClient.os, 80), + device: sanitizeText(payload.device || serverClient.device, 80), + screen: sanitizeText(payload.screen, 40), + language: sanitizeText(payload.language, 40), + timezone: sanitizeText(payload.timezone, 80), + consent: payload.consent === 'analytics' ? 'analytics' : '', + metadata: JSON.stringify(payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}), + } + + if (event.consent !== 'analytics') { + throw new Error('Analytics consent is required') + } + + const now = new Date().toISOString() + db.prepare(` + insert into viewer_events + (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title, + referrer, user_agent, browser, os, device, screen, language, timezone, consent, metadata) + values + (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title, + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @consent, @metadata) + `).run({ ...event, occurred_at: now }) + + db.prepare(` + insert into active_viewers + (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title, + referrer, user_agent, browser, os, device, screen, language, timezone) + values + (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone) + on conflict(session_id) do update set + last_seen_at = excluded.last_seen_at, + page_path = excluded.page_path, + page_title = excluded.page_title, + referrer = excluded.referrer, + user_agent = excluded.user_agent, + browser = excluded.browser, + os = excluded.os, + device = excluded.device, + screen = excluded.screen, + language = excluded.language, + timezone = excluded.timezone + `).run({ ...event, now }) +} + +function viewerDashboard() { + const db = ensureAnalyticsDb() + purgeOldAnalytics(db) + + const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds` + const active = db.prepare(` + select session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title, + referrer, browser, os, device, screen, language, timezone + from active_viewers + where last_seen_at >= datetime('now', ?) + order by last_seen_at desc + limit 100 + `).all(activeSince).map((row) => ({ + session: row.session_id.slice(0, 8), + visitor: row.visitor_id.slice(0, 8), + first_seen_at: row.first_seen_at, + last_seen_at: row.last_seen_at, + page_path: row.page_path, + page_title: row.page_title, + referrer: row.referrer, + browser: row.browser, + os: row.os, + device: row.device, + screen: row.screen, + language: row.language, + timezone: row.timezone, + })) + + const topPages = db.prepare(` + select page_path, page_title, count(*) as views + from viewer_events + where event_type = 'page_view' + and occurred_at >= datetime('now', '-24 hours') + group by page_path, page_title + order by views desc, page_path asc + limit 12 + `).all() + + const clients = db.prepare(` + select browser, os, device, count(*) as events + from viewer_events + where occurred_at >= datetime('now', '-24 hours') + group by browser, os, device + order by events desc + limit 12 + `).all() + + const totals = db.prepare(` + select + count(*) as events_24h, + count(distinct visitor_id) as visitors_24h, + sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h + from viewer_events + where occurred_at >= datetime('now', '-24 hours') + `).get() + + return { + active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS, + generated_at: new Date().toISOString(), + active, + top_pages: topPages, + clients, + totals: { + active_now: active.length, + events_24h: totals?.events_24h || 0, + visitors_24h: totals?.visitors_24h || 0, + page_views_24h: totals?.page_views_24h || 0, + }, + privacy: { + retention_days: ANALYTICS_RETENTION_DAYS, + stores_ip_hashes: true, + exposes_raw_ip: false, + }, + } +} + function isRateLimited(req) { const now = Date.now() const ip = clientIp(req) @@ -476,36 +759,66 @@ function serveStatic(req, res) { }) } -http - .createServer((req, res) => { - if (req.url === '/health') { - sendJson(res, 200, { ok: true }) +const server = http.createServer((req, res) => { + if (req.url === '/health') { + sendJson(res, 200, { ok: true }) + return + } + + if (req.method === 'GET' && req.url === '/api/uptime') { + uptimeHistory() + .then((data) => sendJson(res, 200, data)) + .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message })) + return + } + + if (req.method === 'GET' && req.url === '/api/viewers') { + try { + sendJson(res, 200, viewerDashboard()) + } catch (error) { + sendJson(res, 500, { error: 'Viewer analytics unavailable', detail: error.message }) + } + return + } + + if (req.method === 'POST' && req.url === '/api/viewers/event') { + if (!isSameOriginRequest(req)) { + sendJson(res, 403, { error: 'Analytics events are restricted to this site' }) return } - if (req.method === 'GET' && req.url === '/api/uptime') { - uptimeHistory() - .then((data) => sendJson(res, 200, data)) - .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message })) + if (isRateLimited(req)) { + sendJson(res, 429, { error: 'Too many analytics events' }) return } - if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { - sendJson(res, 403, { error: 'CORS requests are not allowed' }) - return - } + readJsonBody(req) + .then((payload) => { + recordViewerEvent(req, payload) + send(res, 204, '', { 'cache-control': 'no-store' }) + }) + .catch((error) => sendJson(res, 400, { error: error.message })) + return + } - if (req.url.startsWith('/api/')) { - proxyRequest(req, res) - return - } + if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { + sendJson(res, 403, { error: 'CORS requests are not allowed' }) + return + } - serveStatic(req, res) - }) - .listen(PORT, '0.0.0.0', () => { - console.log(`tssbot-web serving http://localhost:${PORT}`) - console.log(`proxying API requests to ${API_UPSTREAM}`) - console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`) - console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`) - startUptimeSampler() - }) + if (req.url.startsWith('/api/')) { + proxyRequest(req, res) + return + } + + serveStatic(req, res) +}) + +server.listen(PORT, '0.0.0.0', () => { + console.log(`tssbot-web serving http://localhost:${PORT}`) + console.log(`proxying API requests to ${API_UPSTREAM}`) + console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`) + console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`) + console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`) + startUptimeSampler() +}) diff --git a/src/App.jsx b/src/App.jsx index ec3a1f9..1489e53 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,8 @@ const dateFormat = new Intl.DateTimeFormat('en-GB', { const apiEndpoints = { health: '/health', uptime: '/api/uptime', + viewers: '/api/viewers', + viewerEvent: '/api/viewers/event', teams: '/api/tss/leaderboard/teams?limit=100', teamsHealth: '/api/tss/leaderboard/teams?limit=1', resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, @@ -23,8 +25,12 @@ const navItems = [ { path: '/', label: 'Home' }, { path: '/teams', label: 'Team leaderboard' }, { path: '/battle-logs', label: 'Battle Logs' }, + { path: '/viewers', label: 'Viewers' }, ] +const analyticsConsentKey = 'tssbot.analyticsConsent' +const analyticsVisitorKey = 'tssbot.analyticsVisitor' + async function fetchJson(path, signal) { const response = await fetch(path, { signal, @@ -43,6 +49,7 @@ function parseRoute(pathname = window.location.pathname) { if (pathname === '/') return { page: 'home', teamName: '' } if (pathname === '/teams') return { page: 'teams', teamName: '' } if (pathname === '/uptime') return { page: 'uptime', teamName: '' } + if (pathname === '/viewers') return { page: 'viewers', teamName: '' } if (pathname.startsWith('/teams/')) { const teamName = decodeURIComponent(pathname.slice('/teams/'.length)) return { page: 'team', teamName } @@ -68,6 +75,69 @@ function bestTeamName(team) { return team?.tag_name || team?.short_name || team?.long_name || '' } +function storedConsent() { + try { + return window.localStorage.getItem(analyticsConsentKey) || '' + } catch { + return '' + } +} + +function stableId(storageKey) { + try { + const existing = window.localStorage.getItem(storageKey) + if (existing) return existing + + const id = + window.crypto?.randomUUID?.() || + `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` + window.localStorage.setItem(storageKey, id) + return id + } catch { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` + } +} + +const analyticsSessionId = + window.crypto?.randomUUID?.() || + `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` + +function browserName() { + const ua = navigator.userAgent + if (ua.includes('Edg/')) return 'Microsoft Edge' + if (ua.includes('OPR/')) return 'Opera' + if (ua.includes('Firefox/')) return 'Firefox' + if (ua.includes('Chrome/') && !ua.includes('Chromium/')) return 'Chrome' + if (ua.includes('Safari/') && ua.includes('Version/')) return 'Safari' + return 'Unknown' +} + +function operatingSystem() { + const ua = navigator.userAgent + if (ua.includes('Windows NT')) return 'Windows' + if (ua.includes('Android')) return 'Android' + if (/iPhone|iPad|iPod/.test(ua)) return 'iOS' + if (ua.includes('Mac OS X')) return 'macOS' + if (ua.includes('Linux')) return 'Linux' + return 'Unknown' +} + +function deviceType() { + const ua = navigator.userAgent + if (/Mobi|Android|iPhone|iPod/.test(ua)) return 'Mobile' + if (/iPad|Tablet/.test(ua)) return 'Tablet' + return 'Desktop' +} + +function routeLabel(route) { + if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}` + if (route.page === 'teams') return 'Team leaderboard' + if (route.page === 'battle-logs') return 'Battle Logs' + if (route.page === 'uptime') return 'Uptime' + if (route.page === 'viewers') return 'viewers' + return 'Home' +} + async function fetchRecentTssGames(teams, signal) { const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12) @@ -122,6 +192,8 @@ function App() { const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null }) const [live, setLive] = useState({ status: 'idle', data: null, error: null }) const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) + const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null }) + const [analyticsConsent, setAnalyticsConsent] = useState(() => storedConsent()) const [teamQuery, setTeamQuery] = useState('') const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' }) const [profile, setProfile] = useState({ @@ -157,11 +229,64 @@ function App() { ? "Battle Logs | Toothless' TSS Bot" : route.page === 'uptime' ? "Uptime | Toothless' TSS Bot" + : route.page === 'viewers' + ? "viewers | Toothless' TSS Bot" : "Toothless' TSS Bot" document.title = title }, [route.page, route.teamName]) + useEffect(() => { + if (analyticsConsent !== 'analytics') return + + const visitorId = stableId(analyticsVisitorKey) + let stopped = false + + function sendViewerEvent(eventType) { + if (stopped) return + + const body = { + consent: 'analytics', + event_type: eventType, + visitor_id: visitorId, + session_id: analyticsSessionId, + page_path: window.location.pathname, + page_title: routeLabel(route), + referrer: document.referrer, + browser: browserName(), + os: operatingSystem(), + device: deviceType(), + screen: `${window.screen.width}x${window.screen.height}`, + language: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + metadata: { + color_depth: window.screen.colorDepth, + viewport: `${window.innerWidth}x${window.innerHeight}`, + }, + } + + fetch(apiEndpoints.viewerEvent, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + keepalive: true, + }).catch(() => {}) + } + + sendViewerEvent('page_view') + const timer = window.setInterval(() => sendViewerEvent('heartbeat'), 30000) + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') sendViewerEvent('heartbeat') + } + document.addEventListener('visibilitychange', onVisibilityChange) + + return () => { + stopped = true + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisibilityChange) + } + }, [analyticsConsent, route]) + useEffect(() => { const onKeyDown = (event) => { if (event.key === 'Escape') { @@ -417,6 +542,44 @@ function App() { } }, [route.page]) + useEffect(() => { + if (route.page !== 'viewers') return + + const controller = new AbortController() + + function loadViewers() { + setViewers((current) => ({ + status: current.status === 'ready' ? 'refreshing' : 'loading', + data: current.data, + error: null, + updatedAt: current.updatedAt, + })) + + fetchJson(apiEndpoints.viewers, controller.signal) + .then((data) => { + setViewers({ + status: 'ready', + data, + error: null, + updatedAt: Date.now(), + }) + }) + .catch((error) => { + if (!controller.signal.aborted) { + setViewers((current) => ({ ...current, status: 'error', error: error.message })) + } + }) + } + + loadViewers() + const timer = window.setInterval(loadViewers, 5000) + + return () => { + window.clearInterval(timer) + controller.abort() + } + }, [route.page]) + useEffect(() => { if (route.page !== 'team' || !route.teamName) return @@ -470,11 +633,22 @@ function App() { } } + function chooseAnalyticsConsent(value) { + try { + window.localStorage.setItem(analyticsConsentKey, value) + } catch { + // Local storage can be blocked; the in-memory choice still controls this session. + } + setAnalyticsConsent(value) + } + const activeNavPath = route.page === 'team' ? '/teams' : route.page === 'battle-logs' ? '/battle-logs' + : route.page === 'viewers' + ? '/viewers' : window.location.pathname return ( @@ -541,8 +715,10 @@ function App() { ) : null} {route.page === 'battle-logs' ? : null} {route.page === 'uptime' ? : null} + {route.page === 'viewers' ? : null}