Files
TSSBOT-web/frontend/src/App.jsx
T

4022 lines
145 KiB
React

import { useEffect, useMemo, useRef, useState } from 'react'
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves'
const numberFormat = new Intl.NumberFormat('en-GB')
const dateFormat = new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
})
const apiEndpoints = {
health: '/health',
uptime: '/api/uptime',
viewers: '/api/viewers',
viewerEvent: '/api/viewers/event',
viewerDelete: '/api/viewers/delete',
teams: '/api/tss/leaderboard/teams?limit=100',
homeTeams: '/api/tss/leaderboard/teams?limit=4',
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
recentGames: '/api/tss/games/recent?limit=50',
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
gameLogs: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}/logs`,
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
}
const navItems = [
{ path: '/', label: 'Home' },
{ path: '/teams', label: 'Team Leaderboard' },
{ path: '/battle-logs', label: 'Battle Logs' },
{ path: '/viewers', label: 'Viewers' },
{ path: '/docs', label: 'Setup' },
]
const analyticsConsentKey = 'tssbot.analyticsConsent'
const analyticsPreferencesKey = 'tssbot.analyticsPreferences'
const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
const analyticsConsentVersion = 3
const themePreferenceKey = 'tssbot.theme'
const themePreferenceCookie = 'tssbot_theme'
const liveRefreshMs = 15000
const siteVersion = 'v1'
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
const defaultAnalyticsPreferences = {
chosen: false,
analytics: false,
device: false,
display: false,
locale: false,
referrer: false,
diagnostics: false,
version: analyticsConsentVersion,
}
async function fetchJson(path, signal) {
const response = await fetch(path, {
signal,
headers: { Accept: 'application/json' },
})
const body = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(body?.error || `Request failed with ${response.status}`)
}
return body
}
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 === '/privacy') return { page: 'privacy', teamName: '' }
if (pathname === '/docs') return { page: 'docs', teamName: '' }
if (pathname.startsWith('/players/')) {
const uid = decodeURIComponent(pathname.slice('/players/'.length))
return { page: 'player', teamName: '', uid }
}
if (pathname.startsWith('/games/')) {
const gameId = decodeURIComponent(pathname.slice('/games/'.length))
return { page: 'game', teamName: '', gameId }
}
if (pathname.startsWith('/teams/')) {
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
return { page: 'team', teamName }
}
if (pathname === '/battle-logs' || pathname === '/live') return { page: 'battle-logs', teamName: '' }
return { page: 'home', teamName: '' }
}
function teamPath(name) {
return `/teams/${encodeURIComponent(name)}`
}
function gamePath(gameId) {
return `/games/${encodeURIComponent(gameId)}`
}
function formatNumber(value) {
return numberFormat.format(Number(value || 0))
}
function formatMatchSize(playerCount) {
const count = formatNumber(playerCount)
return `${count}v${count}`
}
function formatDate(timestamp) {
if (!timestamp) return 'Unknown time'
return dateFormat.format(new Date(Number(timestamp) * 1000))
}
function formatDuration(seconds) {
const total = Math.round(Number(seconds || 0))
if (!total) return ''
const m = Math.floor(total / 60)
const s = total % 60
if (!m) return `${s}s`
return `${m}m ${s}s`
}
function gameParticipants(game) {
const winner = displayTeamName(game?.winning_team)
const loser = displayTeamName(game?.losing_team)
if (winner || loser) {
return [
winner ? { name: winner, result: 'win' } : null,
loser ? { name: loser, result: 'loss' } : null,
].filter(Boolean)
}
const fallbackName = game?.team_name || ''
if (!fallbackName) return []
return [
{
name: fallbackName,
result: String(game?.result || '').toLowerCase() === 'win' ? 'win' : 'loss',
},
]
}
function displayTeamName(value) {
return String(value || '').trim()
}
function ParticipantNames({ participants }) {
if (!participants.length) {
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
}
return (
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">
{participants.map((participant) => (
<span
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-fury-violet' : 'text-text-soft'}`}
key={`${participant.result}-${participant.name}`}
>
{participant.name}
</span>
))}
</div>
)
}
function bestTeamName(team) {
return team?.name || ''
}
function teamDetailLooksReal(detail) {
if (!detail || typeof detail !== 'object') return false
const summary = detail.team_summary || detail.squadron_summary || null
const players = Array.isArray(detail.players) ? detail.players : []
const hasStableId = Boolean(detail.clan_id || detail.id || detail.team_id || detail.squadron_id)
const hasRoster = players.length > 0
const hasActivity = Boolean(
Number(summary?.player_count || 0) > 0 ||
Number(summary?.total_battles || 0) > 0 ||
Number(summary?.wins || 0) > 0 ||
Number(summary?.points?.total_points || summary?.total_points || 0) > 0,
)
return hasStableId || hasRoster || hasActivity
}
function canonicalTeamName(detail, fallback = '') {
return detail?.name || fallback
}
function normalizeAnalyticsPreferences(value) {
if (!value || typeof value !== 'object') return { ...defaultAnalyticsPreferences }
return {
chosen: Boolean(value.chosen) && value.version === analyticsConsentVersion,
analytics: Boolean(value.analytics),
device: Boolean(value.device),
display: Boolean(value.display),
locale: Boolean(value.locale),
referrer: Boolean(value.referrer),
diagnostics: Boolean(value.diagnostics),
version: analyticsConsentVersion,
}
}
function browserPrivacySignalEnabled() {
return Boolean(
navigator.globalPrivacyControl ||
navigator.doNotTrack === '1' ||
window.doNotTrack === '1',
)
}
function browserConnectionDetails() {
const connection =
navigator.connection || navigator.mozConnection || navigator.webkitConnection || null
if (!connection) return {}
return {
effective_type: connection.effectiveType || '',
downlink_mbps: Number.isFinite(connection.downlink) ? connection.downlink : null,
rtt_ms: Number.isFinite(connection.rtt) ? connection.rtt : null,
save_data: Boolean(connection.saveData),
}
}
function browserDiagnosticsMetadata() {
return {
cookies_enabled: navigator.cookieEnabled,
do_not_track: navigator.doNotTrack || window.doNotTrack || '',
global_privacy_control: Boolean(navigator.globalPrivacyControl),
online: navigator.onLine,
platform: navigator.platform || '',
vendor: navigator.vendor || '',
max_touch_points: navigator.maxTouchPoints || 0,
hardware_concurrency: navigator.hardwareConcurrency || null,
device_memory_gb: navigator.deviceMemory || null,
webdriver: Boolean(navigator.webdriver),
history_length: window.history.length,
visibility_state: document.visibilityState,
connection: browserConnectionDetails(),
}
}
function readCookie(name) {
try {
const match = document.cookie
.split('; ')
.find((item) => item.startsWith(`${encodeURIComponent(name)}=`))
return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : ''
} catch {
return ''
}
}
function writeCookie(name, value) {
try {
const sixMonths = 60 * 60 * 24 * 180
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Max-Age=${sixMonths}; Path=/; SameSite=Lax`
} catch {
// Some browsers block cookies; local storage and in-memory state still work.
}
}
function normalizeThemePreference(value) {
return value === 'dark' ? 'dark' : 'light'
}
function storedThemePreference() {
const bootTheme = window.__TSS_BOOT_PREFERENCES__?.theme
if (bootTheme) return normalizeThemePreference(bootTheme)
const cookieValue = readCookie(themePreferenceCookie)
if (cookieValue) return normalizeThemePreference(cookieValue)
try {
return normalizeThemePreference(window.localStorage.getItem(themePreferenceKey))
} catch {
return 'light'
}
}
function persistThemePreference(theme) {
const normalized = normalizeThemePreference(theme)
writeCookie(themePreferenceCookie, normalized)
try {
window.localStorage.setItem(themePreferenceKey, normalized)
} catch {
// Cookies still remember the theme when local storage is blocked.
}
return normalized
}
function storedAnalyticsPreferences() {
const bootPreferences = window.__TSS_BOOT_PREFERENCES__?.analyticsPreferences
if (bootPreferences) return normalizeAnalyticsPreferences(bootPreferences)
const cookieValue = readCookie(analyticsPreferencesCookie)
if (cookieValue) {
try {
return normalizeAnalyticsPreferences(JSON.parse(cookieValue))
} catch {
// Fall back through the older storage formats below.
}
}
try {
const stored = window.localStorage.getItem(analyticsPreferencesKey)
if (stored) return normalizeAnalyticsPreferences(JSON.parse(stored))
} catch {
// Fall back through the older consent flag below.
}
try {
const legacyConsent = window.localStorage.getItem(analyticsConsentKey) || ''
if (legacyConsent === 'analytics') {
return {
...defaultAnalyticsPreferences,
chosen: true,
analytics: true,
device: true,
display: true,
locale: true,
referrer: true,
diagnostics: true,
}
}
if (legacyConsent === 'declined') {
return { ...defaultAnalyticsPreferences, chosen: true, analytics: false }
}
} catch {
// Use the default prompt state.
}
return { ...defaultAnalyticsPreferences }
}
function persistAnalyticsPreferences(preferences) {
const normalized = normalizeAnalyticsPreferences(preferences)
const serialized = JSON.stringify(normalized)
writeCookie(analyticsPreferencesCookie, serialized)
try {
window.localStorage.setItem(analyticsPreferencesKey, serialized)
window.localStorage.setItem(
analyticsConsentKey,
normalized.analytics ? 'analytics' : 'declined',
)
} catch {
// Local storage can be blocked; the cookie and in-memory choice still apply.
}
return normalized
}
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)}`
}
}
function storedVisitorId(storageKey) {
try {
return window.localStorage.getItem(storageKey) || ''
} catch {
return ''
}
}
function forgetVisitorId(storageKey) {
try {
window.localStorage.removeItem(storageKey)
} catch {
// Storage may be unavailable; nothing else to clear locally.
}
}
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 === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
if (route.page === 'uptime') return 'Uptime'
if (route.page === 'viewers') return 'viewers'
if (route.page === 'privacy') return 'Privacy notice'
if (route.page === 'docs') return 'Docs'
if (route.page === 'player') return route.uid ? `Player ${route.uid}` : 'Player'
return 'Home'
}
function currentPublicOrigin() {
return window.location.origin.replace(/\/$/, '')
}
function canonicalPathForRoute(route) {
if (route.page === 'team' && route.teamName) return teamPath(route.teamName)
if (route.page === 'teams') return '/teams'
if (route.page === 'battle-logs') return '/battle-logs'
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
if (route.page === 'uptime') return '/uptime'
if (route.page === 'viewers') return '/viewers'
if (route.page === 'privacy') return '/privacy'
if (route.page === 'docs') return '/docs'
if (route.page === 'player' && route.uid) return `/players/${encodeURIComponent(route.uid)}`
return '/'
}
function seoForRoute(route, profileDetail = null) {
const teamName =
route.page === 'team'
? canonicalTeamName(profileDetail, route.teamName).trim() || route.teamName
: ''
const teamSummary = profileDetail?.team_summary || profileDetail?.squadron_summary || null
const teamPoints = Number(teamSummary?.points?.total_points || teamSummary?.total_points || 0)
const teamBattles = Number(teamSummary?.total_battles || 0)
if (route.page === 'team' && teamName) {
const stats = [
teamPoints ? `${formatNumber(teamPoints)} points` : '',
teamBattles ? `${formatNumber(teamBattles)} battles` : '',
].filter(Boolean)
return {
title: `${teamName} TSS Team Profile | Toothless' TSS Bot`,
description: stats.length
? `${teamName} TSS team profile with ${stats.join(', ')}, roster details, battle history, and recent War Thunder squadron activity.`
: `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`,
robots: 'index, follow',
path: canonicalPathForRoute({ ...route, teamName }),
}
}
if (route.page === 'player' && route.uid) {
return {
title: `Player ${route.uid} | Toothless' TSS Bot`,
description: `TSS career stats for player ${route.uid} — battles, win rate, kills, and teams seen with.`,
robots: 'noindex, follow',
path: canonicalPathForRoute(route),
}
}
if (route.page === 'game' && route.gameId) {
return {
title: `Game ${route.gameId} | Toothless' TSS Bot`,
description: `TSS battle log details for game ${route.gameId}, including participants, map, player counts, and combat stats.`,
robots: 'noindex, follow',
path: canonicalPathForRoute(route),
}
}
const byPage = {
teams: {
title: "TSS Team Leaderboard | Toothless' TSS Bot",
description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.',
robots: 'index, follow',
path: '/teams',
},
'battle-logs': {
title: "TSS Battle Logs | Toothless' TSS Bot",
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
robots: 'index, follow',
path: '/battle-logs',
},
uptime: {
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
robots: 'index, follow',
path: '/uptime',
},
viewers: {
title: "Viewer Analytics | Toothless' TSS Bot",
description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
robots: 'noindex, follow',
path: '/viewers',
},
privacy: {
title: "Privacy Notice | Toothless' TSS Bot",
description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
robots: 'index, follow',
path: '/privacy',
},
docs: {
title: "Docs | Toothless' TSS Bot",
description: 'Documentation and command reference for Toothless TSS Bot.',
robots: 'noindex, follow',
path: '/docs',
},
}
return byPage[route.page] || {
title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.',
robots: 'index, follow',
path: '/',
}
}
function upsertMeta(selector, createAttributes, valueAttribute, value) {
let element = document.head.querySelector(selector)
if (!element) {
element = document.createElement('meta')
Object.entries(createAttributes).forEach(([key, attributeValue]) => {
element.setAttribute(key, attributeValue)
})
document.head.appendChild(element)
}
element.setAttribute(valueAttribute, value)
}
function upsertLink(rel, href) {
let element = document.head.querySelector(`link[rel="${rel}"]`)
if (!element) {
element = document.createElement('link')
element.setAttribute('rel', rel)
document.head.appendChild(element)
}
element.setAttribute('href', href)
}
function structuredDataForSeo(seo, canonicalUrl) {
const base = currentPublicOrigin()
const items = [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: "Toothless' TSS Bot",
url: `${base}/`,
description: "Live War Thunder TSS leaderboards, battle logs, and team profiles.",
potentialAction: {
'@type': 'SearchAction',
target: `${base}/teams/{search_term_string}`,
'query-input': 'required name=search_term_string',
},
},
{
'@context': 'https://schema.org',
'@type': 'WebPage',
name: seo.title,
url: canonicalUrl,
description: seo.description,
isPartOf: {
'@type': 'WebSite',
name: "Toothless' TSS Bot",
url: `${base}/`,
},
},
]
return JSON.stringify(items)
}
function applySeo(route, profileDetail = null) {
const seo = seoForRoute(route, profileDetail)
const canonicalUrl = `${currentPublicOrigin()}${seo.path}`
document.title = seo.title
upsertMeta('meta[name="description"]', { name: 'description' }, 'content', seo.description)
upsertMeta('meta[name="robots"]', { name: 'robots' }, 'content', seo.robots)
upsertMeta('meta[property="og:title"]', { property: 'og:title' }, 'content', seo.title)
upsertMeta('meta[property="og:description"]', { property: 'og:description' }, 'content', seo.description)
upsertMeta('meta[property="og:url"]', { property: 'og:url' }, 'content', canonicalUrl)
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, 'content', seo.title)
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, 'content', seo.description)
upsertLink('canonical', canonicalUrl)
let structuredData = document.getElementById('site-structured-data')
if (!structuredData) {
structuredData = document.createElement('script')
structuredData.id = 'site-structured-data'
structuredData.type = 'application/ld+json'
document.head.appendChild(structuredData)
}
structuredData.textContent = structuredDataForSeo(seo, canonicalUrl)
}
async function fetchRecentTssGames(signal) {
return fetchJson(apiEndpoints.recentGames, signal)
}
function Stat({ label, value }) {
return (
<div className="border-l border-border pl-4">
<p className="text-xs font-semibold uppercase tracking-wide text-text-soft">
{label}
</p>
<p className="mt-1 text-2xl font-semibold text-text">{value}</p>
</div>
)
}
function ThemeToggle({ theme, onThemeChange }) {
const isDark = theme === 'dark'
return (
<button
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-pressed={isDark}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-border bg-surface text-sm font-semibold text-text-soft transition hover:border-ring hover:text-text"
onClick={() => onThemeChange(isDark ? 'light' : 'dark')}
title={isDark ? 'Light mode' : 'Dark mode'}
type="button"
>
<span aria-hidden="true">{isDark ? '☾' : '☼'}</span>
</button>
)
}
function defaultThemeTogglePosition() {
const inset = window.innerWidth >= 640 ? 32 : 20
return {
x: window.innerWidth - inset - 36,
y: inset,
}
}
function themeToggleDockPosition(navPill) {
if (!navPill) return defaultThemeTogglePosition()
const width = navPill.offsetWidth
const height = navPill.offsetHeight
const left = (window.innerWidth - width) / 2
const top = 16
return {
x: Math.round(left + width - 48),
y: Math.round(top + (height - 36) / 2),
}
}
function ThemeToggleMover({ dockPosition, homePosition, isHome, theme, onThemeChange }) {
const moverRef = useRef(null)
useEffect(() => {
const mover = moverRef.current
if (!mover) return undefined
let cancelled = false
let tween = null
async function animate() {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import('gsap'),
import('gsap/ScrollTrigger'),
])
if (cancelled) return
gsap.registerPlugin(ScrollTrigger)
if (!isHome) {
tween = gsap.to(mover, {
x: dockPosition.x,
y: dockPosition.y,
duration: 0.56,
ease: 'power3.out',
overwrite: true,
})
return
}
tween = gsap.fromTo(
mover,
{ x: homePosition.x, y: homePosition.y },
{
x: dockPosition.x,
y: dockPosition.y,
ease: 'none',
overwrite: true,
scrollTrigger: {
end: '+=96',
scrub: 0.35,
start: 'top top',
trigger: document.documentElement,
},
},
)
}
animate()
return () => {
cancelled = true
tween?.scrollTrigger?.kill()
tween?.kill()
}
}, [dockPosition.x, dockPosition.y, homePosition.x, homePosition.y, isHome])
const initialPosition = isHome ? homePosition : dockPosition
return (
<div
className="theme-toggle-mover fixed left-0 top-0 z-[60]"
ref={moverRef}
style={{ transform: `translate3d(${initialPosition.x}px, ${initialPosition.y}px, 0)` }}
>
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
</div>
)
}
function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onVerify, onExpire, onError, resetSignal = 0 }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
const callbacksRef = useRef({ onVerify, onExpire, onError })
useEffect(() => {
callbacksRef.current = { onVerify, onExpire, onError }
}, [onVerify, onExpire, onError])
useEffect(() => {
if (!siteKey) return undefined
const container = containerRef.current
if (!container) return undefined
let cancelled = false
let pollTimer
const renderWidget = () => {
if (cancelled) return
if (!window.turnstile || typeof window.turnstile.render !== 'function') {
pollTimer = window.setTimeout(renderWidget, 150)
return
}
const options = {
sitekey: siteKey,
theme,
size,
callback: (token) => callbacksRef.current.onVerify?.(token),
'expired-callback': () => callbacksRef.current.onExpire?.(),
'error-callback': () => callbacksRef.current.onError?.(),
}
if (action) options.action = action
widgetIdRef.current = window.turnstile.render(container, options)
}
renderWidget()
return () => {
cancelled = true
window.clearTimeout(pollTimer)
const widgetId = widgetIdRef.current
widgetIdRef.current = null
if (widgetId != null && window.turnstile?.remove) {
try { window.turnstile.remove(widgetId) } catch { /* widget already gone */ }
}
}
}, [siteKey, theme, size, action])
useEffect(() => {
if (!resetSignal) return
const widgetId = widgetIdRef.current
if (widgetId != null && window.turnstile?.reset) {
try { window.turnstile.reset(widgetId) } catch { /* widget gone or already reset */ }
}
}, [resetSignal])
if (!siteKey) return null
return <div ref={containerRef} />
}
function SiteGate({ onVerified }) {
const [status, setStatus] = useState('idle')
const [error, setError] = useState('')
const [resetSignal, setResetSignal] = useState(0)
const submittingRef = useRef(false)
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previousOverflow
}
}, [])
async function handleVerify(token) {
if (submittingRef.current) return
submittingRef.current = true
setStatus('verifying')
setError('')
try {
const response = await fetch('/api/turnstile/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!response.ok) {
setError('Verification failed — please try again.')
setStatus('idle')
setResetSignal((value) => value + 1)
return
}
setStatus('verified')
onVerified()
} catch {
setError('Network error — please retry.')
setStatus('idle')
setResetSignal((value) => value + 1)
} finally {
submittingRef.current = false
}
}
return (
<div
aria-labelledby="site-gate-title"
aria-modal="true"
className="fixed inset-0 z-[9999] grid place-items-center bg-bg/95 px-4 py-6 backdrop-blur-sm"
role="dialog"
>
<div className="flex w-full max-w-sm flex-col items-center gap-4 rounded-md border border-border bg-fury-white p-6 text-center text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)]">
<h1 id="site-gate-title" className="text-lg font-semibold">
Verifying you are human
</h1>
<p className="text-sm text-text-soft">
This quick check protects the site from automated abuse. It usually clears itself.
</p>
<TurnstileWidget
siteKey={turnstileSiteKey}
action="site-gate"
onVerify={handleVerify}
onExpire={() => setError('Challenge expired — please solve it again.')}
onError={() => setError('Challenge could not load. Refresh to retry.')}
resetSignal={resetSignal}
/>
{status === 'verifying' ? (
<p className="text-xs text-text-soft">Confirming with Cloudflare</p>
) : null}
{error ? <p className="text-xs font-semibold text-red-600">{error}</p> : null}
</div>
</div>
)
}
function App() {
const embeddedGateState = document
.querySelector('meta[name="tss-turnstile-session"]')
?.getAttribute('content')
const initialGateState = turnstileSiteKey
? ['verified', 'required'].includes(embeddedGateState)
? embeddedGateState
: 'checking'
: 'verified'
const [gateState, setGateState] = useState(initialGateState)
useEffect(() => {
if (!turnstileSiteKey || gateState !== 'checking') return undefined
let cancelled = false
fetch('/api/turnstile/session', { headers: { Accept: 'application/json' } })
.then((response) => (response.ok ? response.json() : { verified: false }))
.then((data) => {
if (cancelled) return
setGateState(data?.verified ? 'verified' : 'required')
})
.catch(() => {
if (!cancelled) setGateState('required')
})
return () => {
cancelled = true
}
}, [gateState])
if (gateState === 'checking') {
return <div className="fixed inset-0 bg-bg" aria-hidden="true" />
}
if (gateState === 'required') {
return <SiteGate onVerified={() => setGateState('verified')} />
}
return <AppContent />
}
function AppContent() {
const [route, setRoute] = useState(() => parseRoute())
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
const [homeTeams, setHomeTeams] = useState({ status: 'idle', data: null, error: null })
const [live, setLive] = useState({ status: 'idle', data: null, error: null, updatedAt: 0 })
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
const [theme, setTheme] = useState(() => storedThemePreference())
const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
const [themeTogglePositions, setThemeTogglePositions] = useState(() => {
const position = defaultThemeTogglePosition()
return { dock: position, home: position }
})
const [teamQuery, setTeamQuery] = useState('')
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
const [teamSearchResults, setTeamSearchResults] = useState([])
const [profile, setProfile] = useState({
teamName: '',
detail: { status: 'idle', data: null, error: null },
games: { status: 'idle', data: null, error: null },
})
const teams = useMemo(
() => leaderboard.data?.teams || leaderboard.data?.squadrons || [],
[leaderboard.data],
)
const teamsToWatch = useMemo(
() => homeTeams.data?.teams || homeTeams.data?.squadrons || teams.slice(0, 4),
[homeTeams.data, teams],
)
const matches = live.data?.matches || []
const liveRef = useRef(live)
const navPillRef = useRef(null)
const themeRef = useRef(theme)
const themeTransitionTimerRef = useRef(null)
useEffect(() => {
liveRef.current = live
}, [live])
useEffect(() => {
themeRef.current = theme
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
document.querySelector('meta[name="theme-color"]')?.setAttribute(
'content',
theme === 'dark' ? '#101211' : '#e82517',
)
}, [theme])
useEffect(() => () => {
window.clearTimeout(themeTransitionTimerRef.current)
document.documentElement.classList.remove('theme-transition')
}, [])
function navigate(path, { replace = false } = {}) {
if (replace) {
window.history.replaceState({}, '', path)
} else {
window.history.pushState({}, '', path)
}
setRoute(parseRoute(path))
}
useEffect(() => {
const onPopState = () => setRoute(parseRoute())
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
}, [])
useEffect(() => {
const onScroll = () => setShowFloatingNav(window.scrollY > 40)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
if (route.page === 'home') return
const prewarm = () => {
renderPixelMountainsCanvas()
prewarmTreeCanvas()
}
const requestIdle = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 250))
const cancelIdle = window.cancelIdleCallback || window.clearTimeout
const id = requestIdle(prewarm)
return () => cancelIdle(id)
}, [route.page])
useEffect(() => {
applySeo(
route,
route.page === 'team' && profile.detail.status === 'ready'
? profile.detail.data
: null,
)
}, [profile.detail.data, profile.detail.status, route])
useEffect(() => {
if (!analyticsPreferences.analytics) return
if (
route.page === 'team' &&
(profile.teamName !== route.teamName || profile.detail.status !== 'ready')
) {
return
}
const visitorId = stableId(analyticsVisitorKey)
let stopped = false
const deviceDetails = analyticsPreferences.device
const displayDetails = analyticsPreferences.display
const localeDetails = analyticsPreferences.locale
const referrerDetails = analyticsPreferences.referrer
const diagnosticsDetails = analyticsPreferences.diagnostics
function sendViewerEvent(eventType) {
if (stopped) return
const metadata = {
preferences: {
device: deviceDetails,
display: displayDetails,
locale: localeDetails,
referrer: referrerDetails,
diagnostics: diagnosticsDetails,
theme: themeRef.current,
},
}
if (deviceDetails) {
metadata.device = {
user_agent_length: navigator.userAgent.length,
platform: navigator.platform || '',
vendor: navigator.vendor || '',
max_touch_points: navigator.maxTouchPoints || 0,
}
}
if (displayDetails) {
metadata.display = {
color_depth: window.screen.colorDepth,
pixel_depth: window.screen.pixelDepth,
viewport: `${window.innerWidth}x${window.innerHeight}`,
device_pixel_ratio: window.devicePixelRatio || 1,
orientation: window.screen.orientation?.type || '',
}
}
if (localeDetails) {
metadata.locale = {
languages: Array.isArray(navigator.languages) ? navigator.languages.slice(0, 8) : [],
timezone_offset_minutes: new Date().getTimezoneOffset(),
}
}
if (diagnosticsDetails) {
metadata.diagnostics = browserDiagnosticsMetadata()
}
const body = {
consent: 'analytics',
event_type: eventType,
visitor_id: visitorId,
session_id: analyticsSessionId,
page_path: window.location.pathname,
page_title: routeLabel(route),
referrer: referrerDetails ? document.referrer : '',
user_agent: deviceDetails ? navigator.userAgent : 'Not shared',
browser: deviceDetails ? browserName() : 'Not shared',
os: deviceDetails ? operatingSystem() : 'Not shared',
device: deviceDetails ? deviceType() : 'Not shared',
screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '',
language: localeDetails ? navigator.language : '',
timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
theme: themeRef.current,
metadata,
}
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)
}
}, [analyticsPreferences, profile.detail.status, profile.teamName, route])
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'Escape') {
navigate('/')
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
const query = teamQuery.trim()
if (query.length < 2) {
setSearchHint({ status: 'idle', name: '' })
setTeamSearchResults([])
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
setSearchHint({ status: 'loading', name: '' })
fetchJson(apiEndpoints.searchTeams(query), controller.signal)
.then((data) => {
const results = (data.teams || data.results || [])
.map((team) => ({
name: bestTeamName(team),
detail: '',
aliases: [team.name].filter(Boolean),
}))
.filter((team) => team.name)
.slice(0, 10)
setTeamSearchResults(results)
setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' })
})
.catch(() => {
if (!controller.signal.aborted) {
fetchJson(apiEndpoints.resolve(query), controller.signal)
.then((data) => {
const name = data.name || ''
setTeamSearchResults(name ? [{ name, detail: '', aliases: [name] }] : [])
setSearchHint(name ? { status: 'ready', name } : { status: 'error', name: '' })
})
.catch(() => {
if (!controller.signal.aborted) {
setTeamSearchResults([])
setSearchHint({ status: 'error', name: '' })
}
})
}
})
}, 350)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [teamQuery])
useEffect(() => {
if (!['teams', 'team', 'battle-logs'].includes(route.page)) return
if (leaderboard.status === 'ready' || leaderboard.status === 'loading') return
const controller = new AbortController()
setLeaderboard({ status: 'loading', data: null, error: null })
fetchJson(apiEndpoints.teams, controller.signal)
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
.catch((error) => {
if (!controller.signal.aborted) {
setLeaderboard({ status: 'error', data: null, error: error.message })
}
})
return () => controller.abort()
}, [leaderboard.status, route.page])
useEffect(() => {
if (route.page !== 'home') return
if (homeTeams.status === 'ready' || homeTeams.status === 'loading') return
const controller = new AbortController()
setHomeTeams({ status: 'loading', data: null, error: null })
fetchJson(apiEndpoints.homeTeams, controller.signal)
.then((data) => setHomeTeams({ status: 'ready', data, error: null }))
.catch((error) => {
if (!controller.signal.aborted) {
setHomeTeams({ status: 'error', data: null, error: error.message })
}
})
return () => controller.abort()
}, [homeTeams.status, route.page])
useEffect(() => {
if (!['teams', 'team', 'battle-logs'].includes(route.page)) return
const controller = new AbortController()
const timer = window.setInterval(() => {
fetchJson(apiEndpoints.teams, controller.signal)
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
.catch((error) => {
if (!controller.signal.aborted) {
setLeaderboard((current) => ({ ...current, error: error.message }))
}
})
}, 60000)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [route.page])
useEffect(() => {
if (!['home', 'battle-logs'].includes(route.page)) return
const currentLive = liveRef.current
if (currentLive.status === 'ready' && Date.now() - currentLive.updatedAt < liveRefreshMs) return
const controller = new AbortController()
setLive((current) =>
current.status === 'ready'
? current
: { status: 'loading', data: null, error: null, updatedAt: current.updatedAt || 0 },
)
fetchRecentTssGames(controller.signal)
.then((data) => setLive({ status: 'ready', data, error: null, updatedAt: Date.now() }))
.catch((error) => {
if (!controller.signal.aborted) {
setLive({ status: 'error', data: null, error: error.message, updatedAt: Date.now() })
}
})
return () => controller.abort()
}, [route.page])
useEffect(() => {
if (!['home', 'battle-logs'].includes(route.page)) return
const controller = new AbortController()
const timer = window.setInterval(() => {
fetchRecentTssGames(controller.signal)
.then((data) => setLive({ status: 'ready', data, error: null, updatedAt: Date.now() }))
.catch((error) => {
if (!controller.signal.aborted) {
setLive((current) => ({ ...current, error: error.message }))
}
})
}, liveRefreshMs)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [route.page])
useEffect(() => {
if (route.page !== 'team' || !route.teamName) return
const controller = new AbortController()
setProfile({
teamName: route.teamName,
detail: { status: 'loading', data: null, error: null },
games: { status: 'loading', data: null, error: null },
})
Promise.allSettled([
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
])
.then(([detailResult, gamesResult]) => {
if (controller.signal.aborted) return
if (detailResult.status !== 'fulfilled') {
navigate('/teams', { replace: true })
return
}
const detail = detailResult.value
if (!teamDetailLooksReal(detail)) {
navigate('/teams', { replace: true })
return
}
setProfile({
teamName: route.teamName,
detail: { status: 'ready', data: detail, error: null },
games:
gamesResult.status === 'fulfilled'
? { status: 'ready', data: gamesResult.value, error: null }
: { status: 'error', data: null, error: gamesResult.reason.message },
})
})
return () => controller.abort()
}, [route.page, route.teamName])
useEffect(() => {
if (route.page !== 'uptime') return
const controller = new AbortController()
async function loadUptime() {
setUptime((current) => ({
status: current.status === 'ready' ? 'refreshing' : 'loading',
checks: current.checks,
history: current.history,
updatedAt: current.updatedAt,
}))
fetchJson(apiEndpoints.uptime, controller.signal)
.then((data) => {
const latest = data.latest
const checks = latest
? [
{
name: 'Website',
detail: 'App shell and static assets',
ok: latest.website_ok,
label: latest.details?.website?.label || (latest.website_ok ? 'Online' : 'Issue'),
latency: latest.latency_ms,
},
{
name: 'Health endpoint',
detail: apiEndpoints.health,
ok: latest.health_ok,
label: latest.details?.health?.label || (latest.health_ok ? 'Operational' : 'Issue'),
latency: latest.latency_ms,
},
{
name: 'TSS data proxy',
detail: apiEndpoints.teamsHealth,
ok: latest.tss_ok,
label: latest.details?.tss?.label || (latest.tss_ok ? 'Operational' : 'Issue'),
latency: latest.details?.tss?.latency_ms || latest.latency_ms,
},
]
: []
setUptime({
status: 'ready',
checks,
history: (data.history || []).map((sample) => ({
timestamp: new Date(sample.checked_at).getTime(),
onlineChecks: [sample.website_ok, sample.health_ok, sample.tss_ok].filter(Boolean).length,
totalChecks: 3,
ok: sample.ok,
})),
updatedAt: latest ? new Date(latest.checked_at).getTime() : null,
configured: data.configured,
})
})
.catch((error) => {
if (controller.signal.aborted) return
setUptime({
status: 'error',
updatedAt: null,
configured: false,
history: [],
checks: [
{
name: 'Website',
detail: 'App shell and static assets',
ok: true,
label: 'Online',
latency: 0,
},
{
name: 'Health endpoint',
detail: apiEndpoints.health,
ok: false,
label: error.message,
latency: 0,
},
{
name: 'TSS data proxy',
detail: apiEndpoints.teamsHealth,
ok: false,
label: 'Uptime history unavailable',
latency: 0,
},
],
})
})
}
loadUptime()
const timer = window.setInterval(loadUptime, 60000)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [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
const controller = new AbortController()
const timer = window.setInterval(() => {
Promise.allSettled([
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
]).then(([detailResult, gamesResult]) => {
if (controller.signal.aborted) return
setProfile((current) => ({
teamName: route.teamName,
detail:
detailResult.status === 'fulfilled'
? { status: 'ready', data: detailResult.value, error: null }
: current.detail,
games:
gamesResult.status === 'fulfilled'
? { status: 'ready', data: gamesResult.value, error: null }
: current.games,
}))
})
}, 60000)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [route.page, route.teamName])
const topTeamName = bestTeamName(teams[0])
const localTeamSuggestions = useMemo(() => {
const query = teamQuery.trim().toLowerCase()
const seen = new Set()
return teams
.map((team) => {
const name = bestTeamName(team)
const aliases = [team.name].filter(Boolean)
return { name, detail: '', aliases }
})
.filter(({ name, aliases }) => {
if (!name || seen.has(name)) return false
seen.add(name)
if (!query) return true
return aliases.some((alias) => String(alias).toLowerCase().includes(query))
})
.slice(0, 10)
}, [teamQuery, teams])
const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions
const searchPlaceholder =
searchHint.status === 'ready'
? `Found ${searchHint.name}`
: searchHint.status === 'error'
? 'Team not found'
: topTeamName || 'Search teams'
async function handleTeamSearch(event) {
event.preventDefault()
const name = teamQuery.trim()
if (!name) return
try {
const resolved = await fetchJson(apiEndpoints.resolve(name))
const resolvedName = resolved.name
if (!resolvedName) throw new Error('Team not found')
const detail = await fetchJson(apiEndpoints.detail(resolvedName))
if (!teamDetailLooksReal(detail)) throw new Error('Team not found')
navigate(teamPath(canonicalTeamName(detail, resolvedName)))
setTeamQuery('')
} catch {
setSearchHint({ status: 'error', name: '' })
}
}
function chooseAnalyticsConsent(preferences) {
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
if (!nextPreferences.analytics) {
forgetVisitorId(analyticsVisitorKey)
if (previousVisitorId) {
fetch(apiEndpoints.viewerDelete, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
visitor_id: previousVisitorId,
session_id: analyticsSessionId,
}),
keepalive: true,
}).catch(() => {})
}
}
setAnalyticsPreferences(nextPreferences)
}
function chooseTheme(nextTheme) {
if (nextTheme === themeRef.current) return
window.clearTimeout(themeTransitionTimerRef.current)
document.documentElement.classList.add('theme-transition')
themeTransitionTimerRef.current = window.setTimeout(() => {
document.documentElement.classList.remove('theme-transition')
}, 460)
setTheme(persistThemePreference(nextTheme))
}
const activeNavPath =
route.page === 'team'
? '/teams'
: route.page === 'battle-logs' || route.page === 'game'
? '/battle-logs'
: route.page === 'viewers'
? '/viewers'
: window.location.pathname
const shouldShowFloatingNav = route.page !== 'home' || showFloatingNav
useEffect(() => {
let frame = 0
function updateThemeTogglePositions() {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(() => {
setThemeTogglePositions({
dock: themeToggleDockPosition(navPillRef.current),
home: defaultThemeTogglePosition(),
})
})
}
updateThemeTogglePositions()
window.addEventListener('resize', updateThemeTogglePositions)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('resize', updateThemeTogglePositions)
}
}, [route.page])
return (
<main className="min-h-screen bg-bg text-text">
<header
className={`fixed inset-x-0 top-4 z-50 flex justify-center px-4 transition duration-300 ${shouldShowFloatingNav
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-8 opacity-0'
}`}
>
<div
className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full border border-border bg-fury-white/92 py-2 pl-2 pr-13 shadow-[0_12px_36px_rgba(0,0,0,0.16)] backdrop-blur sm:pl-3"
ref={navPillRef}
>
<button
aria-label="Go to Toothless' TSS Bot home"
className="hidden h-10 w-10 shrink-0 place-items-center rounded-full transition hover:bg-surface sm:grid"
onClick={() => navigate('/')}
type="button"
>
<img
alt=""
className="h-8 w-8 rounded-full"
src="/embed-icon.svg"
/>
</button>
<nav className="flex min-w-0 items-center gap-1 overflow-x-auto">
{navItems.map((item) => (
<button
className={`shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition ${activeNavPath === item.path
? 'bg-text text-bg apricot-button-text'
: 'text-text-soft hover:bg-surface hover:text-text'
}`}
key={item.path}
onClick={() => navigate(item.path)}
type="button"
>
{item.label}
</button>
))}
</nav>
</div>
</header>
<ThemeToggleMover
dockPosition={themeTogglePositions.dock}
homePosition={themeTogglePositions.home}
isHome={route.page === 'home'}
theme={theme}
onThemeChange={chooseTheme}
/>
<section className="mx-auto w-full max-w-7xl px-5 sm:px-8">
{route.page === 'home' ? (
<Landing
live={live}
matches={matches}
navigate={navigate}
onTeamSearch={handleTeamSearch}
searchPlaceholder={searchPlaceholder}
setTeamQuery={setTeamQuery}
teamSuggestions={teamSuggestions}
teams={teams}
teamsToWatch={teamsToWatch}
teamQuery={teamQuery}
/>
) : null}
{route.page === 'teams' ? (
<TeamsPage leaderboard={leaderboard} navigate={navigate} teams={teams} />
) : null}
{route.page === 'team' ? (
<TeamProfilePage
navigate={navigate}
profile={profile}
requestedTeam={route.teamName}
teams={teams}
/>
) : null}
{route.page === 'battle-logs' ? (
<BattleLogsPage live={live} matches={matches} navigate={navigate} />
) : null}
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
{route.page === 'privacy' ? <PrivacyPage /> : null}
{route.page === 'docs' ? <DocsPage /> : null}
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
</section>
<Footer navigate={navigate} />
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
</main>
)
}
function Footer({ navigate }) {
return (
<footer className="mt-14 border-t border-border bg-fury-white">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 px-5 py-6 text-sm text-text-soft sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p>Toothless&apos; TSS Bot · {siteVersion}</p>
<div className="flex flex-wrap gap-4">
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/uptime')}
type="button"
>
Uptime
</button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/viewers')}
type="button"
>
Viewers
</button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/privacy')}
type="button"
>
Privacy
</button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/docs')}
type="button"
>
Documentation
</button>
<a
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
href="https://github.com/Clippii/tssbot.web"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
<a
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
href="https://clippi.dev/"
target="_blank"
rel="noopener noreferrer"
>
clippi.dev
</a>
</div>
</div>
</footer>
)
}
function ConsentBanner({ preferences, onChoose }) {
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
const privacySignalEnabled = browserPrivacySignalEnabled()
useEffect(() => {
setDraft(normalizeAnalyticsPreferences(preferences))
if (!preferences.chosen) {
setIsOpen(true)
setIsConfiguring(false)
}
}, [preferences])
useEffect(() => {
if (!isOpen) return undefined
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previousOverflow
}
}, [isOpen])
function updateDraft(key, value) {
setDraft((current) => ({ ...current, [key]: value }))
}
function savePreferences(nextPreferences) {
onChoose(normalizeAnalyticsPreferences(nextPreferences))
setIsOpen(false)
}
if (!isOpen) {
return (
<button
className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:border-ring hover:bg-surface hover:text-text"
onClick={() => {
setIsConfiguring(true)
setIsOpen(true)
}}
type="button"
>
Cookie settings
</button>
)
}
return (
<div
aria-labelledby="consent-title"
aria-modal="true"
className="fixed inset-0 z-50 grid place-items-center bg-black/45 px-4 py-6 backdrop-blur-[2px]"
role="dialog"
>
<div className="w-full max-w-2xl rounded-md border border-border bg-fury-white p-5 text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)] sm:p-6">
<div className="max-w-xl">
<h2 id="consent-title" className="text-xl font-semibold">
Cookie and analytics settings
</h2>
<p className="mt-2 text-sm leading-6 text-text-soft">
We use a necessary cookie to remember these choices. You can also allow
analytics for the public viewers page and choose which details are included.
</p>
{privacySignalEnabled ? (
<p className="mt-2 text-sm font-semibold text-text">
Your browser is signalling a privacy preference, so optional analytics stay
off unless you explicitly allow them.
</p>
) : null}
</div>
{isConfiguring ? (
<>
<div className="mt-5 space-y-3">
<PreferenceToggle
checked
description="Stores this consent choice so the popup does not appear on every page."
disabled
label="Necessary cookie"
/>
<PreferenceToggle
checked={draft.analytics}
description="Sends page views, live viewing state, session ID, and pseudonymous visitor ID."
label="Viewer analytics"
onChange={(value) => updateDraft('analytics', value)}
/>
<PreferenceToggle
checked={draft.device}
description="Includes browser, operating system, and broad device type."
disabled={!draft.analytics}
label="Browser and device details"
onChange={(value) => updateDraft('device', value)}
/>
<PreferenceToggle
checked={draft.display}
description="Includes screen size, viewport size, and colour depth."
disabled={!draft.analytics}
label="Screen details"
onChange={(value) => updateDraft('display', value)}
/>
<PreferenceToggle
checked={draft.locale}
description="Includes browser language and timezone."
disabled={!draft.analytics}
label="Language and timezone"
onChange={(value) => updateDraft('locale', value)}
/>
<PreferenceToggle
checked={draft.referrer}
description="Includes the page that linked you here when the browser provides it."
disabled={!draft.analytics}
label="Referrer"
onChange={(value) => updateDraft('referrer', value)}
/>
<PreferenceToggle
checked={draft.diagnostics}
description="Includes HTTP version, protocol headers, cache/debug headers, network quality, privacy signals, touch support, CPU/memory hints, and other browser diagnostics."
disabled={!draft.analytics}
label="Technical diagnostics"
onChange={(value) => updateDraft('diagnostics', value)}
/>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
type="button"
>
Decline all
</button>
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
onClick={() => savePreferences(draft)}
type="button"
>
Save choices
</button>
<button
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
onClick={() =>
savePreferences({
chosen: true,
analytics: true,
device: true,
display: true,
locale: true,
referrer: true,
diagnostics: true,
version: analyticsConsentVersion,
})
}
type="button"
>
Allow all
</button>
</div>
</>
) : (
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
onClick={() => setIsConfiguring(true)}
type="button"
>
Configure
</button>
<button
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
onClick={() =>
savePreferences({
chosen: true,
analytics: true,
device: true,
display: true,
locale: true,
referrer: true,
diagnostics: true,
version: analyticsConsentVersion,
})
}
type="button"
>
Allow all
</button>
</div>
)}
</div>
</div>
)
}
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
return (
<label
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${
disabled ? 'opacity-65' : 'cursor-pointer'
}`}
>
<input
checked={checked}
className="mt-1 h-4 w-4 accent-fury-cyan"
disabled={disabled}
onChange={(event) => onChange?.(event.target.checked)}
type="checkbox"
/>
<span>
<span className="block text-sm font-semibold text-text">{label}</span>
<span className="mt-1 block text-xs leading-5 text-text-soft">{description}</span>
</span>
</label>
)
}
function PrivacyPage() {
return (
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
<div className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Privacy notice
</p>
<h1 className="mt-2 text-4xl font-bold">How we handle your data</h1>
<p className="mt-3 text-text-soft">
Controller: Heidi
</p>
<p className="mt-3 text-text-soft">
Contact through Discord: <span className="font-semibold text-text">clippiii</span>
</p>
</div>
<div className="mt-8 grid gap-6 text-sm leading-6 text-text-soft">
<PrivacySection title="What we collect">
Necessary cookies store your cookie, theme, and analytics choices. If you opt in to
viewer analytics, we collect page views, live viewing status, a pseudonymous
visitor ID, a session ID, and the active light/dark theme. Optional settings control whether browser/device,
screen, language/timezone, referrer, and technical diagnostics are included.
Technical diagnostics can include HTTP version, request protocol details,
content negotiation headers, browser privacy signals, network quality, touch
support, CPU/memory hints, and similar debugging fields.
</PrivacySection>
<PrivacySection title="Why we collect it">
Necessary cookies are used to remember your privacy and theme choices. Optional viewer
analytics are used only to power the public viewers page, understand basic site
activity, and keep abuse low.
</PrivacySection>
<PrivacySection title="Legal basis">
Necessary cookies are used because they are needed to remember your consent
preferences. Optional analytics only run with your consent, and you can withdraw
that consent from the Cookie settings button at any time.
</PrivacySection>
<PrivacySection title="Retention and deletion">
Analytics events are automatically deleted after the configured retention period,
currently documented as 30 days by default. Active viewer sessions expire after a
short inactivity window. If you decline analytics after previously allowing them,
the site asks the server to delete records linked to your current visitor and
session IDs, and removes the local visitor ID from your browser.
</PrivacySection>
<PrivacySection title="Sharing and transfers">
Viewer analytics are stored by this site and are not sold. Public viewer pages show
only consented analytics fields and never show raw IP addresses. Hosting providers
may process server logs as part of running the site.
</PrivacySection>
<PrivacySection title="Your rights">
You can ask for access, correction, deletion, restriction, objection, portability,
or withdrawal of consent by contacting Heidi on Discord at clippiii. If you are in
the UK, you can also complain to the ICO. If you are in the EU or EEA, you can
complain to your local data protection authority.
</PrivacySection>
</div>
</section>
)
}
function PlayerStatCard({ label, value }) {
return (
<div className="rounded-xl border border-border bg-fury-white p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-text-soft">{label}</p>
<p className="mt-1 text-2xl font-bold">{value}</p>
</div>
)
}
function PlayerPage({ uid, navigate }) {
const [state, setState] = useState({ status: 'loading', data: null, error: '' })
useEffect(() => {
if (!uid) {
setState({ status: 'error', data: null, error: 'No player specified.' })
return
}
let cancelled = false
setState({ status: 'loading', data: null, error: '' })
fetchJson(apiEndpoints.player(uid))
.then((data) => {
if (!cancelled) setState({ status: 'ready', data, error: '' })
})
.catch((err) => {
if (!cancelled) setState({ status: 'error', data: null, error: err?.message || 'Failed to load player.' })
})
return () => {
cancelled = true
}
}, [uid])
const { status, data, error } = state
return (
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
<div className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Player</p>
<h1 className="mt-2 text-4xl font-bold">
{status === 'ready' ? (data.nick || `Player ${uid}`) : `Player ${uid || ''}`}
</h1>
<p className="mt-3 text-text-soft">UID {uid} · TSS career</p>
</div>
{status === 'loading' ? <p className="mt-8 text-text-soft">Loading</p> : null}
{status === 'error' ? <p className="mt-8 text-text-soft">{error}</p> : null}
{status === 'ready' ? (
<div className="mt-8 grid gap-8">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<PlayerStatCard label="Battles" value={formatNumber(data.career.total_battles)} />
<PlayerStatCard label="Win rate" value={`${(data.career.win_rate || 0).toFixed(1)}%`} />
<PlayerStatCard label="K/D" value={(data.career.kdr || 0).toFixed(2)} />
<PlayerStatCard label="Total kills" value={formatNumber(data.career.total_kills)} />
<PlayerStatCard label="Wins / Losses" value={`${formatNumber(data.career.wins)} / ${formatNumber(data.career.losses)}`} />
<PlayerStatCard label="Deaths" value={formatNumber(data.career.deaths)} />
<PlayerStatCard label="Ground kills" value={formatNumber(data.career.ground_kills)} />
<PlayerStatCard label="Air kills" value={formatNumber(data.career.air_kills)} />
<PlayerStatCard label="Assists" value={formatNumber(data.career.assists)} />
</div>
{Array.isArray(data.teams) && data.teams.length ? (
<div>
<h2 className="text-lg font-semibold text-text">Teams seen with</h2>
<div className="mt-3 flex flex-col gap-2">
{data.teams.map((team) => {
const label = team.team_tag || team.team_name || 'Unknown'
return (
<button
key={`${team.team_tag}-${team.team_id ?? ''}`}
className="flex items-center justify-between rounded-lg border border-border bg-fury-white px-4 py-3 text-left transition hover:border-fury-cyan"
onClick={() => navigate(teamPath(label))}
type="button"
>
<span className="font-semibold text-text">{label}</span>
<span className="text-sm text-text-soft">{formatNumber(team.games)} games</span>
</button>
)
})}
</div>
</div>
) : null}
</div>
) : null}
</section>
)
}
function DocsPage() {
return (
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
<div className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Docs
</p>
<h1 className="mt-2 text-4xl font-bold">Documentation</h1>
<p className="mt-3 text-text-soft">
Coming soon.
</p>
</div>
</section>
)
}
function PrivacySection({ children, title }) {
return (
<article className="border-l border-border pl-4">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<p className="mt-2">{children}</p>
</article>
)
}
function Landing({
live,
matches,
navigate,
onTeamSearch,
searchPlaceholder,
setTeamQuery,
teamSuggestions,
teams,
teamsToWatch,
teamQuery,
}) {
const treeRef = useRef(null)
return (
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
<div className="relative min-h-screen overflow-hidden px-5 sm:px-8">
<PixelMountains />
<section className="relative z-10 mx-auto grid min-h-screen w-full max-w-7xl gap-8 pt-16 pb-16 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div className="max-w-3xl">
<p className="text-base font-semibold uppercase tracking-wide text-fury-cyan">
BorisBot got nothin on THIS
</p>
<h1 className="mt-3 text-6xl font-bold tracking-normal sm:text-7xl lg:text-8xl">
Toothless&apos; TSS Bot
</h1>
<p className="mt-6 max-w-2xl text-xl leading-9 text-text-soft">
Powered by Spectra. TSS analytics.
</p>
<div className="mt-8 w-full max-w-xl">
<div className="grid gap-4 sm:grid-cols-3">
<button
className="apricot-button-text min-h-15 rounded-lg bg-text px-5 py-4 text-base font-semibold text-bg transition hover:bg-fury-cyan"
onClick={() => navigate('/teams')}
type="button"
>
Team Leaderboard
</button>
<button
className="min-h-15 rounded-lg border-2 border-ring px-5 py-4 text-base font-semibold text-fury-cyan transition hover:bg-surface hover:text-text"
onClick={() => navigate('/battle-logs')}
type="button"
>
Battle Logs
</button>
<a
className="flex min-h-15 items-center justify-center rounded-lg border-2 border-text bg-fury-white px-5 py-4 text-base font-semibold text-text transition hover:bg-surface"
href="https://sre.pawjob.us/"
>
Visit SREBOT
</a>
</div>
<form
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
onSubmit={onTeamSearch}
>
<input
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
list="team-search-suggestions"
placeholder={searchPlaceholder}
value={teamQuery}
onChange={(event) => setTeamQuery(event.target.value)}
/>
<datalist id="team-search-suggestions">
{teamSuggestions.map((team) => (
<option key={team.name} label={team.detail} value={team.name} />
))}
</datalist>
<button
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
type="submit"
>
Search teams
</button>
</form>
</div>
</div>
<div className="relative min-h-[520px] overflow-hidden">
<FallingLeaves treeRef={treeRef} />
<div className="absolute inset-0 z-[1] flex items-end justify-center pb-10 lg:pb-0">
<Tree ref={treeRef} />
</div>
</div>
<div className="absolute bottom-5 left-1/2 z-20 hidden -translate-x-1/2 flex-col items-center gap-2 text-xs font-semibold uppercase tracking-wide text-text-soft sm:flex">
<span>Scroll</span>
<span className="h-10 w-[2px] overflow-hidden rounded-full bg-border">
<span className="block h-4 w-full animate-[scrollPulse_1.5s_ease-in-out_infinite] rounded-full bg-fury-cyan" />
</span>
</div>
</section>
</div>
<LandingOverview teams={teams} teamsToWatch={teamsToWatch} matches={matches} navigate={navigate} />
<RecentGamesSection live={live} matches={matches} navigate={navigate} />
<LandingTrustSection navigate={navigate} />
</div>
)
}
function LandingOverview({ teams, teamsToWatch, matches, navigate }) {
const activeTeams = teamsToWatch.slice(0, 4)
const visibleTeamCount = teams.length || teamsToWatch.length
const totalPlayers = matches.reduce((sum, match) => sum + Number(match.player_count || 0), 0)
const latestMatch = matches[0]
return (
<section className="relative z-20 border-t border-border bg-bg px-5 py-12 sm:px-8">
<div className="mx-auto grid w-full max-w-7xl gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-start">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
What it does
</p>
<h2 className="mt-2 text-3xl font-bold">A clean window into TSS activity</h2>
<p className="mt-4 max-w-2xl text-base leading-7 text-text-soft">
Track teams, inspect recent battles, and jump from a leaderboard name to a
profile without touching the god awful TSS website.
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-3">
<LandingMetric label="Teams indexed" value={formatNumber(visibleTeamCount)} />
<LandingMetric label="Recent games" value={formatNumber(matches.length)} />
<LandingMetric label="Players seen" value={formatNumber(totalPlayers)} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<LandingFeature
action="Open leaderboard"
description="We track teams so you don't have to."
onClick={() => navigate('/teams')}
title="Team discovery"
/>
<LandingFeature
action="Read battle logs"
description="Recent games, map names, player counts, and combat stat summaries."
onClick={() => navigate('/battle-logs')}
title="Battle context"
/>
<LandingFeature
action="Check uptime"
description="Track if TSS data not showing up is our fault or Gaijins, wink wink."
onClick={() => navigate('/uptime')}
title="Operational status"
/>
<LandingFeature
action="View analytics"
description="Track what other people are watching utilising opt in data analytics."
onClick={() => navigate('/viewers')}
title="Viewer pulse"
/>
</div>
</div>
<div className="mx-auto mt-10 grid w-full max-w-7xl gap-4 lg:grid-cols-[1fr_1fr]">
<div className="border-l border-border bg-fury-white px-5 py-5">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Latest signal
</p>
<h3 className="mt-2 text-2xl font-bold">
{latestMatch?.map_name || 'Waiting for the next battle'}
</h3>
<p className="mt-2 text-sm leading-6 text-text-soft">
{latestMatch
? `${latestMatch.team_name || 'A TSS team'} played with ${formatNumber(latestMatch.player_count)} players.`
: 'Latest match data will appear here once the data proxy returns games.'}
</p>
</div>
<div className="border-l border-border bg-fury-white px-5 py-5">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Teams to watch
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{activeTeams.length ? (
activeTeams.map((team) => {
const name = bestTeamName(team)
return (
<button
className="truncate rounded-md border border-border bg-bg px-3 py-2 text-left text-sm font-semibold transition hover:border-ring hover:bg-surface"
key={name}
onClick={() => navigate(teamPath(name))}
type="button"
>
{name}
</button>
)
})
) : (
<p className="text-sm text-text-soft">Team data is still loading.</p>
)}
</div>
</div>
</div>
</section>
)
}
function LandingMetric({ label, value }) {
return (
<div className="border-l border-border pl-4">
<p className="text-2xl font-bold text-text">{value}</p>
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-text-soft">{label}</p>
</div>
)
}
function LandingFeature({ action, description, onClick, title }) {
return (
<article className="rounded-lg border border-border bg-fury-white p-5 shadow-sm">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-2 min-h-12 text-sm leading-6 text-text-soft">{description}</p>
<button
className="mt-4 text-sm font-semibold text-fury-cyan transition hover:text-text"
onClick={onClick}
type="button"
>
{action}
</button>
</article>
)
}
function LandingTrustSection({ navigate }) {
return (
<section className="relative z-20 border-t border-border bg-bg px-5 py-10 sm:px-8">
<div className="mx-auto grid w-full max-w-7xl gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Brought to you by...
</p>
<h2 className="mt-2 text-3xl font-bold">Built by the team behind SREBot</h2>
<p className="mt-3 max-w-3xl text-base leading-7 text-text-soft">
TSSBot is built alongside SREBot, both providing different views into different aspects of "competitive" War Thunder.
</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<a
className="apricot-button-text rounded-lg bg-text px-5 py-3 text-sm font-semibold text-bg transition hover:bg-fury-cyan"
href="https://sre.pawjob.us/"
>
Visit SREBOT
</a>
<button
className="rounded-lg border border-ring px-5 py-3 text-sm font-semibold text-fury-cyan transition hover:bg-surface"
onClick={() => navigate('/privacy')}
type="button"
>
Privacy notice
</button>
</div>
</div>
</section>
)
}
function RecentGamesSection({ live, matches, navigate }) {
const recentMatches = matches.slice(0, 6)
return (
<section className="relative z-20 border-t border-border bg-fury-white px-5 py-10 sm:px-8">
<div className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Recent activity
</p>
<h2 className="mt-1 text-3xl font-bold">Latest games</h2>
</div>
<button
className="w-fit rounded-lg border border-ring px-4 py-2 text-sm font-semibold text-fury-cyan transition hover:bg-surface"
onClick={() => navigate('/battle-logs')}
type="button"
>
View Battle Logs
</button>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{recentMatches.map((match) => (
<button
className="rounded-lg border border-border bg-bg p-4 text-left shadow-sm transition hover:border-ring hover:bg-surface"
key={match.session_id}
onClick={() => navigate(gamePath(match.session_id))}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold">
{match.map_name || 'Unknown map'}
</h3>
<p className="mt-1 text-xs text-text-soft">{formatDate(match.timestamp)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-3 text-sm">
<ParticipantNames participants={gameParticipants(match)} />
<p className="text-text-soft">
{formatNumber(match.player_count)} players
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-text-soft">
<span>{formatNumber(match.stats?.deaths)} deaths</span>
</div>
</button>
))}
</div>
{!recentMatches.length ? (
<p className="mt-6 rounded-lg border border-border bg-bg px-5 py-6 text-sm text-text-soft">
{live.status === 'loading' ? 'Loading latest games' : live.error || 'No games returned'}
</p>
) : null}
</div>
</section>
)
}
const cachedPixelMountainsCanvases = new Map()
function renderPixelMountainsCanvas(theme = 'light') {
const isDark = theme === 'dark'
const palette =
isDark
? ['#332614', '#4c341b', '#68431f', '#8a5824']
: ['#fcfbcf', '#fff2e6', '#fee5cd', '#fdca9b']
const cached = cachedPixelMountainsCanvases.get(theme)
if (cached) return cached
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const WORLD_W = 1920
const WORLD_H = 900
function interpolate(points, x) {
for (let i = 0; i < points.length - 1; i++) {
const [x0, y0] = points[i]
const [x1, y1] = points[i + 1]
if (x >= x0 && x <= x1) {
const t = (x - x0) / Math.max(1, x1 - x0)
return y0 + (y1 - y0) * t
}
}
return points.at(-1)[1]
}
function drawMountain(points, color, jitter = 0) {
const width = WORLD_W
const height = WORLD_H
ctx.fillStyle = color
for (let x = 0; x < width; x++) {
const wave = jitter
? Math.sin(x * 0.08) * jitter + Math.sin(x * 0.021) * jitter * 1.8
: 0
const y = Math.round(interpolate(points, x) + wave)
ctx.fillRect(x, y, 1, height - y)
}
}
function draw() {
const width = WORLD_W
const height = WORLD_H
canvas.width = WORLD_W
canvas.height = WORLD_H
ctx.imageSmoothingEnabled = false
ctx.clearRect(0, 0, WORLD_W, WORLD_H)
drawMountain(
[
[0, height * 0.82],
[width * 0.12, height * 0.73],
[width * 0.26, height * 0.66],
[width * 0.39, height * 0.76],
[width * 0.55, height * 0.58],
[width * 0.64, height * 0.46],
[width * 0.73, height * 0.62],
[width * 0.86, height * 0.56],
[width, height * 0.64],
],
palette[0],
1.1,
)
drawMountain(
[
[0, height * 0.86],
[width * 0.15, height * 0.78],
[width * 0.31, height * 0.81],
[width * 0.43, height * 0.69],
[width * 0.52, height * 0.76],
[width * 0.62, height * 0.43],
[width * 0.69, height * 0.31],
[width * 0.78, height * 0.48],
[width, height * 0.5],
],
palette[1],
1.6,
)
drawMountain(
[
[0, height * 0.94],
[width * 0.17, height * 0.9],
[width * 0.32, height * 0.92],
[width * 0.47, height * 0.83],
[width * 0.58, height * 0.89],
[width * 0.66, height * 0.61],
[width * 0.71, height * 0.5],
[width * 0.84, height * 0.7],
[width, height * 0.68],
],
palette[2],
2.1,
)
drawMountain(
[
[0, height * 0.98],
[width * 0.17, height * 0.96],
[width * 0.34, height * 0.99],
[width * 0.48, height * 0.93],
[width * 0.62, height * 0.97],
[width * 0.72, height * 0.88],
[width * 0.86, height * 0.95],
[width, height * 0.93],
],
palette[3],
1.4,
)
}
draw()
cachedPixelMountainsCanvases.set(theme, canvas)
return canvas
}
function PixelMountains() {
const [activeTheme, setActiveTheme] = useState(() =>
document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light',
)
const [previousTheme, setPreviousTheme] = useState(null)
const [skyTransition, setSkyTransition] = useState('')
const [skyPaths, setSkyPaths] = useState({})
const activeCanvasRef = useRef(null)
const previousCanvasRef = useRef(null)
const skyRef = useRef(null)
const fadeTimerRef = useRef(null)
const skyTimerRef = useRef(null)
function drawCanvas(canvas, theme) {
if (!canvas || !theme) return
const source = renderPixelMountainsCanvas(theme)
const ctx = canvas.getContext('2d')
canvas.width = source.width
canvas.height = source.height
ctx.imageSmoothingEnabled = false
ctx.drawImage(source, 0, 0)
}
useEffect(() => {
drawCanvas(activeCanvasRef.current, activeTheme)
}, [activeTheme])
useEffect(() => {
drawCanvas(previousCanvasRef.current, previousTheme)
}, [previousTheme])
useEffect(() => {
function syncTheme() {
const theme = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
setActiveTheme((current) => {
if (theme === current) return current
window.clearTimeout(fadeTimerRef.current)
window.clearTimeout(skyTimerRef.current)
setPreviousTheme(current)
setSkyTransition(`${current}-to-${theme}`)
fadeTimerRef.current = window.setTimeout(() => setPreviousTheme(null), 440)
skyTimerRef.current = window.setTimeout(() => setSkyTransition(''), 900)
return theme
})
}
syncTheme()
const observer = new MutationObserver(syncTheme)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
observer.disconnect()
window.clearTimeout(fadeTimerRef.current)
window.clearTimeout(skyTimerRef.current)
}
}, [])
useEffect(() => {
function quadraticPath(start, control, end) {
return `path("M ${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${control.x.toFixed(1)} ${control.y.toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}")`
}
function updateSkyPaths() {
const rect = skyRef.current?.getBoundingClientRect()
const width = rect?.width || window.innerWidth || 1920
const height = rect?.height || window.innerHeight || 900
const display = { x: width * 0.78, y: height * 0.18 }
const westSet = { x: width * 0.08, y: height * 0.67 }
const eastRise = { x: width * 0.94, y: height * 0.67 }
const westControl = { x: width * 0.46, y: height * -0.05 }
const eastControl = { x: width * 0.98, y: height * -0.03 }
setSkyPaths({
'--celestial-exit-path': quadraticPath(display, westControl, westSet),
'--celestial-enter-path': quadraticPath(eastRise, eastControl, display),
})
}
updateSkyPaths()
window.addEventListener('resize', updateSkyPaths)
return () => window.removeEventListener('resize', updateSkyPaths)
}, [])
return (
<div className="pixel-mountains" aria-hidden="true">
<div
className={`pixel-sky pixel-sky-${skyTransition || activeTheme}`}
ref={skyRef}
style={skyPaths}
>
<span className="pixel-sun" />
<span className="pixel-moon">
<span className="pixel-star pixel-star-a" />
<span className="pixel-star pixel-star-b" />
<span className="pixel-star pixel-star-c" />
</span>
</div>
{previousTheme ? (
<canvas
className="pixel-mountains-previous"
ref={previousCanvasRef}
/>
) : null}
<canvas className="pixel-mountains-active" ref={activeCanvasRef} />
</div>
)
}
function TeamsPage({ leaderboard, navigate, teams }) {
return (
<section className="space-y-6 pt-24 sm:pt-28">
<div>
<h1 className="text-3xl font-bold">Team Leaderboard</h1>
<p className="mt-2 text-sm text-text-soft">
{leaderboard.status === 'loading'
? 'Loading leaderboard'
: leaderboard.error || `${teams.length} teams returned`}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{teams.map((team, index) => {
const name = bestTeamName(team)
return (
<button
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[4rem_1fr_repeat(4,auto)] md:items-center"
key={`${name}-${team.clan_id || index}`}
onClick={() => navigate(teamPath(name))}
type="button"
>
<span className="text-sm font-semibold text-fury-cyan">#{index + 1}</span>
<span className="min-w-0">
<span className="block truncate text-lg font-semibold">{name}</span>
</span>
<span className="text-sm">{formatNumber(team.player_count)} players</span>
<span className="text-sm">{formatNumber(team.total_battles)} battles</span>
<span className="text-sm">{Number(team.win_rate || 0).toFixed(1)}% WR</span>
<span className="text-sm font-semibold">
{formatNumber(team.points?.total_points || team.total_kills)}
</span>
</button>
)
})}
{!teams.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{leaderboard.status === 'loading' ? 'Loading leaderboard' : 'No teams returned'}
</p>
) : null}
</div>
</section>
)
}
function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
const detail = profile.detail.data
const summary = detail?.team_summary || detail?.squadron_summary
const players = detail?.players || []
const games = profile.games.data?.games || []
const leaderboardTeam = teams.find((team) => bestTeamName(team) === requestedTeam)
const displayName = detail?.name || bestTeamName(leaderboardTeam) || requestedTeam
return (
<section className="space-y-6 pt-24 sm:pt-28">
<button
className="text-sm font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/teams')}
type="button"
>
Back to leaderboard
</button>
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Team profile
</p>
<h1 className="mt-1 text-4xl font-bold">{displayName}</h1>
<p className="mt-2 text-sm text-text-soft">
{profile.detail.error || ''}
</p>
</div>
</div>
<div className="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Roster" value={formatNumber(summary?.player_count)} />
<Stat label="Battles" value={formatNumber(summary?.total_battles)} />
<Stat label="Wins" value={formatNumber(summary?.wins)} />
<Stat label="KDR" value={Number(summary?.kdr || 0).toFixed(1)} />
</div>
</div>
<RosterTable players={players} status={profile.detail.status} />
<BattleResults games={games} navigate={navigate} status={profile.games.status} />
</section>
)
}
const SCOREBOARD_GRID = 'grid-cols-[minmax(220px,1fr)_repeat(6,minmax(52px,72px))]'
function GamePage({ gameId, navigate }) {
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
const [logs, setLogs] = useState({ chat_log: [], battle_log: [] })
useEffect(() => {
if (!gameId) return
const controller = new AbortController()
setGameState({ status: 'loading', data: null, error: null })
setLogs({ chat_log: [], battle_log: [] })
fetchJson(apiEndpoints.game(gameId), controller.signal)
.then((data) => {
if (!controller.signal.aborted) {
setGameState({ status: 'ready', data, error: null })
}
})
.catch((error) => {
if (!controller.signal.aborted) {
setGameState({ status: 'error', data: null, error: error.message })
}
})
fetchJson(apiEndpoints.gameLogs(gameId), controller.signal)
.then((data) => {
if (!controller.signal.aborted) {
setLogs({
chat_log: Array.isArray(data?.chat_log) ? data.chat_log : [],
battle_log: Array.isArray(data?.battle_log) ? data.battle_log : [],
})
}
})
.catch(() => {
// Logs are non-critical; leave them empty on failure.
})
return () => controller.abort()
}, [gameId])
const game = gameState.data?.game
const participants = gameState.data?.participants || []
const participantNames = participants.length
? participants.map((participant) => ({
name: participant.team_name,
result: String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss',
}))
: gameParticipants(game)
const subtitle = [game?.mission_mode, game?.tournament_name].filter(Boolean).join(' · ')
const duration = formatDuration(game?.duration)
return (
<section className="space-y-6 pt-24 sm:pt-28">
<button
className="text-sm font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/battle-logs')}
type="button"
>
Back to battle logs
</button>
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-wrap items-baseline gap-x-3">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
<span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
{game?.draw ? (
<span className="rounded bg-surface px-2 py-1 text-xs font-semibold uppercase tracking-wide text-text-soft">
Draw
</span>
) : null}
</div>
{subtitle ? <p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p> : null}
<p className="mt-2 text-sm text-text-soft">
{game ? formatDate(game.timestamp) : ''}
{duration ? ` · ${duration}` : ''}
</p>
<div className="mt-3">
<ParticipantNames participants={participantNames} />
</div>
{gameState.status === 'error' ? (
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
) : null}
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="overflow-x-auto">
<div className="min-w-[720px]">
{participants.length ? (
<div className={`grid ${SCOREBOARD_GRID} gap-3 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft`}>
<p>Team / player</p>
<p className="text-center">Air</p>
<p className="text-center">Ground</p>
<p className="text-center">Assists</p>
<p className="text-center">Deaths</p>
<p className="text-center">Caps</p>
<p className="text-center">Score</p>
</div>
) : null}
{participants.map((participant) => {
const won = String(participant.result || '').toLowerCase() === 'win'
const accent = won ? 'border-l-win' : 'border-l-loss'
const nameColor = won ? 'text-win' : 'text-loss'
return (
<div className={`border-b-4 border-border border-l-4 ${accent}`} key={participant.team_name}>
<button
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 border-b border-border bg-surface/60 px-5 py-3 text-left transition hover:bg-surface`}
onClick={() => navigate(teamPath(participant.team_name))}
type="button"
>
<div className="min-w-0">
<p className={`truncate font-bold ${nameColor}`}>
{participant.team_name}
</p>
<p className={`text-xs font-semibold uppercase tracking-wide ${nameColor}`}>
{won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
</p>
</div>
<p className="text-center text-sm">{formatNumber(participant.stats?.air_kills)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.assists)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.deaths)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.captures)}</p>
<p className="text-center text-sm font-semibold">{formatNumber(participant.stats?.score)}</p>
</button>
<div className="divide-y divide-surface py-1">
{(participant.players || []).map((player) => (
<button
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface`}
key={player.uid}
onClick={() => navigate(`/players/${encodeURIComponent(player.uid)}`)}
type="button"
>
<div className="min-w-0 pl-4 sm:pl-8">
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
{(player.vehicles || []).length ? (
player.vehicles.map((vehicle) => (
<span
className="vehicle-name inline-flex items-center gap-1 text-xs text-text-soft"
key={vehicle.cdk}
>
<img
alt=""
className="h-4 w-4 object-contain"
loading="lazy"
onError={(event) => { event.currentTarget.style.display = 'none' }}
src={`/vehicle-icons/${vehicle.icon}`}
/>
{vehicle.name}
</span>
))
) : (
<span className="text-xs text-text-muted">{player.uid}</span>
)}
</div>
</div>
<p className="text-center">{formatNumber(player.stats?.air_kills)}</p>
<p className="text-center">{formatNumber(player.stats?.ground_kills)}</p>
<p className="text-center">{formatNumber(player.stats?.assists)}</p>
<p className="text-center">{formatNumber(player.stats?.deaths)}</p>
<p className="text-center">{formatNumber(player.stats?.captures)}</p>
<p className="text-center font-semibold">{formatNumber(player.stats?.score)}</p>
</button>
))}
</div>
</div>
)
})}
</div>
{!participants.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{gameState.status === 'loading' ? 'Loading game' : 'No participants returned'}
</p>
) : null}
</div>
</div>
{logs.battle_log.length ? (
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
<summary className="cursor-pointer px-5 py-4 font-semibold">Battle Log</summary>
<pre className="overflow-x-auto whitespace-pre-wrap px-5 py-3 text-xs leading-relaxed text-text-soft">
{logs.battle_log.join('\n')}
</pre>
</details>
) : null}
{logs.chat_log.length ? (
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
<summary className="cursor-pointer px-5 py-4 font-semibold">Chat Log</summary>
<pre className="overflow-x-auto whitespace-pre-wrap px-5 py-3 text-xs leading-relaxed text-text-soft">
{logs.chat_log.join('\n')}
</pre>
</details>
) : null}
</section>
)
}
function RosterTable({ players, status }) {
const sortedPlayers = [...players].sort((a, b) => {
return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '')
})
return (
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Roster</h2>
<p className="mt-1 text-sm text-text-soft">{formatNumber(sortedPlayers.length)} players</p>
</div>
<div className="max-h-[520px] overflow-auto">
{sortedPlayers.map((player) => (
<div
className="grid gap-3 border-b border-surface px-5 py-3 text-sm md:grid-cols-[1fr_repeat(5,auto)] md:items-center"
key={player.uid}
>
<div className="min-w-0">
<p className="truncate font-semibold">{player.nick || player.uid}</p>
<p className="text-xs text-text-soft">
{player.uid} · {formatNumber(player.points || player.sqb_points)} pts
</p>
</div>
<p>{formatNumber(player.total_battles)} battles</p>
<p>{formatNumber(player.total_kills)} kills</p>
<p>{Number(player.win_rate || 0).toFixed(1)}% WR</p>
<p>{Number(player.kdr || 0).toFixed(1)} KDR</p>
<p>{formatNumber(player.assists)} assists</p>
</div>
))}
{!sortedPlayers.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{status === 'loading' ? 'Loading roster' : 'No roster rows returned'}
</p>
) : null}
</div>
</div>
)
}
function BattleResults({ games, navigate, status }) {
return (
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Battle results</h2>
<p className="mt-1 text-sm text-text-soft">{formatNumber(games.length)} battles returned</p>
</div>
<div className="max-h-[560px] overflow-auto">
{games.map((game) => (
<button
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_repeat(3,auto)] md:items-center"
key={game.session_id}
onClick={() => navigate(gamePath(game.session_id))}
type="button"
>
<div className="min-w-0">
<p className="truncate font-semibold">{game.map_name || 'Unknown map'}</p>
<p className="text-xs text-text-soft">
{formatDate(game.timestamp)} · {game.session_id}
</p>
</div>
<ParticipantNames participants={gameParticipants(game)} />
<p className="text-sm">{formatNumber(game.player_count)} players</p>
<p className="text-sm">{formatNumber(game.stats?.assists)} assists</p>
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
</button>
))}
{!games.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{status === 'loading' ? 'Loading battle results' : 'No battle results returned'}
</p>
) : null}
</div>
</div>
)
}
function BattleLogsPage({ live, matches, navigate }) {
return (
<section className="space-y-6 pt-24 sm:pt-28">
<div>
<h1 className="text-3xl font-bold">Battle Logs</h1>
<p className="mt-2 text-sm text-text-soft">
{live.status === 'loading' ? 'Loading battles' : live.error || `${matches.length} battles returned`}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{matches.map((match) => (
<button
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_auto] md:items-center"
key={match.session_id}
onClick={() => navigate(gamePath(match.session_id))}
type="button"
>
<div className="min-w-0">
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
<p className="text-xs text-text-soft">
{formatDate(match.timestamp)} · {match.session_id}
</p>
</div>
<ParticipantNames participants={gameParticipants(match)} />
<p className="text-sm">{formatMatchSize(match.player_count)}</p>
</button>
))}
{!matches.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{live.status === 'loading' ? 'Loading battles' : 'No battles returned'}
</p>
) : null}
</div>
</section>
)
}
function relativeSeconds(timestamp) {
if (!timestamp) return 'unknown'
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
if (seconds < 60) return `${seconds}s ago`
return `${Math.round(seconds / 60)}m ago`
}
const shortDateFormat = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
})
const shortTimeFormat = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
})
const countryNames = {
AR: 'Argentina',
AU: 'Australia',
BR: 'Brazil',
CA: 'Canada',
CL: 'Chile',
CN: 'China',
DE: 'Germany',
ES: 'Spain',
FR: 'France',
GB: 'United Kingdom',
ID: 'Indonesia',
IN: 'India',
IT: 'Italy',
JP: 'Japan',
KR: 'South Korea',
MX: 'Mexico',
NL: 'Netherlands',
PL: 'Poland',
RU: 'Russia',
SE: 'Sweden',
SG: 'Singapore',
US: 'United States',
ZA: 'South Africa',
}
function repairMojibake(value) {
const text = String(value || '')
if (!/[ÃÂâ]/.test(text) || typeof TextDecoder === 'undefined') return text
try {
const bytes = Uint8Array.from(text, (character) => character.charCodeAt(0))
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
return text
}
}
function filledLast30Days(rows) {
const byDate = new Map(rows.map((row) => [row.date, row]))
return Array.from({ length: 30 }, (_, index) => {
const date = new Date()
date.setDate(date.getDate() - (29 - index))
const key = date.toISOString().slice(0, 10)
return byDate.get(key) || {
date: key,
events: 0,
visitors: 0,
page_views: 0,
clients: 0,
locations: 0,
}
})
}
function filledLast24Hours(rows) {
const byDate = new Map(rows.map((row) => [row.date, row]))
return Array.from({ length: 24 }, (_, index) => {
const date = new Date()
date.setMinutes(0, 0, 0)
date.setHours(date.getHours() - (23 - index))
const key = date.toISOString().slice(0, 13) + ':00:00.000Z'
return byDate.get(key) || {
date: key,
events: 0,
visitors: 0,
page_views: 0,
clients: 0,
locations: 0,
client_labels: [],
location_labels: [],
}
})
}
function MiniLineChart({
accent = 'text-fury-cyan',
bucketLabel = 'Daily count',
data,
label,
metric,
pointFormat = shortDateFormat,
stroke = '#e82517',
}) {
const [hoveredPoint, setHoveredPoint] = useState(null)
const width = 320
const height = 112
const padding = 12
const maxValue = Math.max(1, ...data.map((item) => Number(item[metric] || 0)))
const midValue = Math.round(maxValue / 2)
const points = data.map((item, index) => {
const x = padding + (index / Math.max(1, data.length - 1)) * (width - padding * 2)
const y = height - padding - (Number(item[metric] || 0) / maxValue) * (height - padding * 2)
return { ...item, x, y, value: Number(item[metric] || 0) }
})
const pathData = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
.join(' ')
const latest = points.at(-1)?.value || 0
return (
<div className="relative rounded-md border border-border bg-bg p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs text-text-soft">{bucketLabel}</p>
</div>
<p className={`text-sm font-semibold ${accent}`}>{formatNumber(latest)}</p>
</div>
<div className="relative mt-3">
<div className="absolute left-0 top-0 flex h-28 w-8 flex-col justify-between text-[10px] font-semibold text-text-muted">
<span>{formatNumber(maxValue)}</span>
<span>{formatNumber(midValue)}</span>
<span>0</span>
</div>
<svg className="ml-8 h-28 w-[calc(100%-2rem)] overflow-visible" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${label} line chart`}>
<path d={`M ${padding} ${height - padding} H ${width - padding}`} fill="none" stroke="var(--color-border)" strokeWidth="1" />
<path d={`M ${padding} ${padding} H ${width - padding}`} fill="none" stroke="var(--color-border)" strokeDasharray="4 5" strokeWidth="1" />
<path d={`M ${padding} ${height / 2} H ${width - padding}`} fill="none" stroke="var(--color-border)" strokeDasharray="4 5" strokeWidth="1" />
<path d={pathData} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" />
{points.map((point) => (
<circle
cx={point.x}
cy={point.y}
fill={stroke}
key={`${metric}-${point.date}`}
onBlur={() => setHoveredPoint(null)}
onFocus={() => setHoveredPoint(point)}
onMouseEnter={() => setHoveredPoint(point)}
onMouseLeave={() => setHoveredPoint(null)}
r="4"
tabIndex="0"
/>
))}
</svg>
</div>
{hoveredPoint ? (
<div
className="pointer-events-none absolute z-20 w-max max-w-52 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg"
style={{
left: `${(hoveredPoint.x / width) * 100}%`,
top: `${52 + (hoveredPoint.y / height) * 112}px`,
transform: 'translate(-50%, -115%)',
}}
>
{pointFormat.format(new Date(hoveredPoint.date))}: {formatNumber(hoveredPoint.value)} {label.toLowerCase()}
<span className="block font-normal text-bg/80">
{formatNumber(hoveredPoint.visitors || 0)} visitors
</span>
{metric === 'clients' && hoveredPoint.client_labels?.length ? (
<span className="mt-1 block max-w-56 whitespace-normal font-normal text-bg/80">
{hoveredPoint.client_labels
.map((client) => `${client.label}: ${formatNumber(client.visitors)} visitors`)
.join(', ')}
</span>
) : null}
{metric === 'locations' ? (
<span className="mt-1 block max-w-64 whitespace-normal font-normal text-bg/80">
{hoveredPoint.location_labels?.length
? hoveredPoint.location_labels
.map((location) => `${location.label} (${formatNumber(location.visitors)})`)
.join(', ')
: 'No location labels stored for this day'}
</span>
) : null}
</div>
) : null}
</div>
)
}
function AnalyticsPeriodSection({
activity,
bucketLabel,
description,
pointFormat,
title,
totals,
}) {
return (
<div className="rounded-lg border border-border bg-fury-white p-5 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-text-soft">{description}</p>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.visitors)} visitors
</span>
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.sessions)} sessions
</span>
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.pageViews)} page views
</span>
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
{formatNumber(totals.events)} events
</span>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-5">
<MiniLineChart
bucketLabel={bucketLabel}
data={activity}
label="Events"
metric="events"
pointFormat={pointFormat}
stroke="#e82517"
/>
<MiniLineChart
accent="text-fury-violet"
bucketLabel={bucketLabel}
data={activity}
label="Visitors"
metric="visitors"
pointFormat={pointFormat}
stroke="#fb7b04"
/>
<MiniLineChart
accent="text-fury-cyan"
bucketLabel={bucketLabel}
data={activity}
label="Page views"
metric="page_views"
pointFormat={pointFormat}
stroke="#ed5145"
/>
<MiniLineChart
accent="text-text"
bucketLabel={bucketLabel}
data={activity}
label="Clients"
metric="clients"
pointFormat={pointFormat}
stroke="var(--color-text)"
/>
<MiniLineChart
accent="text-fury-cyan"
bucketLabel={bucketLabel}
data={activity}
label="Locations"
metric="locations"
pointFormat={pointFormat}
stroke="#009ccc"
/>
</div>
</div>
)
}
function ViewersPage({ viewers }) {
const [analyticsWindow, setAnalyticsWindow] = useState('24h')
const data = viewers.data || {}
const active = data.active || []
const activePages = data.active_pages || []
const activity24h = filledLast24Hours(data.activity_24h || [])
const activity30d = filledLast30Days(data.activity_30d || [])
const totals = data.totals || {}
const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data'
const periods = {
'24h': {
label: '24hr',
title: 'Last 24 hours',
description: 'Consented analytics grouped by hour',
bucketLabel: 'Hourly count',
pointFormat: shortTimeFormat,
activity: activity24h,
topPages: data.top_pages || [],
clients: data.clients || [],
themes: data.themes || [],
countries: data.countries_24h || [],
locations: data.locations_24h || [],
totals: {
events: totals.events_24h,
visitors: totals.visitors_24h,
sessions: totals.sessions_24h,
pageViews: totals.page_views_24h,
},
},
'30d': {
label: '30d',
title: 'Last 30 days',
description: 'Retained consented analytics over the current privacy window',
bucketLabel: 'Daily count',
pointFormat: shortDateFormat,
activity: activity30d,
topPages: data.top_pages_30d || data.top_pages || [],
clients: data.clients_30d || [],
themes: data.themes_30d || data.themes || [],
countries: data.countries || [],
locations: data.locations || [],
totals: {
events: totals.events_30d,
visitors: totals.visitors_30d,
sessions: totals.sessions_30d,
pageViews: totals.page_views_30d,
},
},
}
const periodData = periods[analyticsWindow]
return (
<section className="space-y-6 pt-10 sm:pt-14">
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Public analytics
</p>
<h1 className="mt-1 text-4xl font-bold">viewers</h1>
<p className="mt-2 text-sm text-text-soft">
Live consented browser sessions and page activity. Last refreshed {generatedAt}.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-md border border-border bg-surface p-1 text-sm font-semibold" aria-label="Analytics time window">
{Object.entries(periods).map(([key, period]) => (
<button
className={`rounded px-3 py-1.5 transition ${
analyticsWindow === key ? 'bg-text text-bg apricot-button-text shadow-sm' : 'text-text-soft hover:text-text'
}`}
key={key}
onClick={() => setAnalyticsWindow(key)}
type="button"
>
{period.label}
</button>
))}
</div>
<span className="w-fit rounded-md bg-surface px-3 py-2 text-sm font-semibold text-fury-cyan">
{viewers.status === 'loading' ? 'Loading' : `${formatNumber(totals.active_now)} active now`}
</span>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Active now" value={formatNumber(totals.active_now)} />
<Stat label={`Visitors ${periodData.label}`} value={formatNumber(periodData.totals.visitors)} />
<Stat label={`Page views ${periodData.label}`} value={formatNumber(periodData.totals.pageViews)} />
<Stat label={`Events ${periodData.label}`} value={formatNumber(periodData.totals.events)} />
</div>
<AnalyticsPeriodSection
activity={periodData.activity}
bucketLabel={periodData.bucketLabel}
description={periodData.description}
pointFormat={periodData.pointFormat}
title={periodData.title}
totals={periodData.totals}
/>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold">Currently viewing</h2>
<p className="mt-1 text-sm text-text-soft">
Pages with active heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
</p>
</div>
<p className="text-sm font-semibold text-fury-cyan">
{formatNumber(active.length)} active visitors
</p>
</div>
</div>
{activePages.map((page) => (
<div
className="grid gap-4 border-b border-surface px-5 py-4 text-sm lg:grid-cols-[1fr_auto_auto]"
key={`${page.page_path}-${page.page_title}`}
>
<div className="min-w-0">
<p className="truncate text-lg font-semibold">{page.page_title || page.page_path}</p>
<p className="truncate text-xs text-text-soft">{page.page_path}</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-text-soft">
{(page.clients || []).map((client) => (
<span className="rounded-md bg-surface px-2 py-1" key={client.label}>
{client.label} ({formatNumber(client.count)})
</span>
))}
{(page.countries || []).map((country) => (
<span className="rounded-md bg-surface px-2 py-1" key={country}>
{country}
</span>
))}
{(page.themes || []).map((theme) => (
<span className="rounded-md bg-surface px-2 py-1" key={theme.theme}>
{theme.theme} theme ({formatNumber(theme.count)})
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-right sm:grid-cols-1">
<p className="font-semibold text-fury-cyan">{formatNumber(page.viewers)} viewing</p>
<p className="text-text-soft">{formatNumber(page.visitors)} visitors</p>
</div>
<p className="text-right text-text-soft">Seen {relativeSeconds(page.last_seen_at)}</p>
</div>
))}
{!activePages.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{viewers.error || 'No pages are actively being viewed right now'}
</p>
) : null}
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Active visitors</h2>
<p className="mt-1 text-sm text-text-soft">
Visitors currently contributing to the active page counts
</p>
</div>
{active.map((viewer) => (
<div
className="grid gap-3 border-b border-surface px-5 py-4 text-sm lg:grid-cols-[1fr_1fr_auto_auto_auto]"
key={viewer.session}
>
<div className="min-w-0">
<p className="truncate font-semibold">{viewer.page_title || viewer.page_path}</p>
<p className="truncate text-xs text-text-soft">{viewer.page_path}</p>
</div>
<div className="min-w-0">
<p className="truncate font-semibold">
{viewer.browser} on {viewer.os}
</p>
<p className="truncate text-xs text-text-soft">
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.theme || 'light'} theme · {viewer.country || viewer.timezone || 'unknown location'}
</p>
</div>
<p className="text-text-soft">{viewer.language || 'unknown language'}</p>
<p className="text-text-soft">Seen {relativeSeconds(viewer.last_seen_at)}</p>
<p className="font-mono text-xs text-text-muted">
{formatNumber(viewer.sessions || 1)} {(viewer.sessions || 1) === 1 ? 'tab' : 'tabs'} · #{viewer.visitor}
</p>
</div>
))}
{!active.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{viewers.error || 'No consented viewers are active right now'}
</p>
) : null}
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Top pages</h2>
<p className="mt-1 text-sm text-text-soft">Page views over {periodData.title.toLowerCase()}</p>
</div>
{periodData.topPages.map((page) => (
<div className="grid grid-cols-[1fr_auto] gap-3 border-b border-surface px-5 py-3 text-sm" key={page.page_path}>
<div className="min-w-0">
<p className="truncate font-semibold">{page.page_title || page.page_path}</p>
<p className="truncate text-xs text-text-soft">{page.page_path}</p>
</div>
<p className="font-semibold text-fury-cyan">{formatNumber(page.views)}</p>
</div>
))}
{!periodData.topPages.length ? <p className="px-5 py-10 text-sm text-text-soft">No page views recorded yet</p> : null}
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Clients</h2>
<p className="mt-1 text-sm text-text-soft">Browsers, devices, and operating systems seen over {periodData.title.toLowerCase()}</p>
</div>
{periodData.clients.map((client) => (
<div
className="grid grid-cols-[1fr_auto_auto] gap-3 border-b border-surface px-5 py-3 text-sm"
key={`${client.browser}-${client.os}-${client.device}`}
>
<div className="min-w-0">
<p className="truncate font-semibold">{client.browser} on {client.os}</p>
<p className="truncate text-xs text-text-soft">{client.device}</p>
</div>
<p className="text-text-soft">{formatNumber(client.visitors || 0)} visitors</p>
<p className="font-semibold text-fury-cyan">{formatNumber(client.events)} events</p>
</div>
))}
{!periodData.clients.length ? <p className="px-5 py-10 text-sm text-text-soft">No client data recorded yet</p> : null}
</div>
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Themes</h2>
<p className="mt-1 text-sm text-text-soft">Light and dark mode usage over {periodData.title.toLowerCase()}</p>
</div>
<div className="grid gap-3 p-5 sm:grid-cols-2">
{periodData.themes.map((theme) => {
const totalEvents = periodData.themes.reduce((sum, item) => sum + Number(item.events || 0), 0) || 1
const percent = Math.round((Number(theme.events || 0) / totalEvents) * 100)
return (
<div className="rounded-md border border-border bg-surface p-4" key={theme.theme}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold capitalize">{theme.theme} mode</p>
<p className="text-sm font-semibold text-fury-cyan">{percent}%</p>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-bg">
<div className="h-full rounded-full bg-fury-cyan" style={{ width: `${percent}%` }} />
</div>
<p className="mt-3 text-xs text-text-soft">
{formatNumber(theme.visitors || 0)} visitors · {formatNumber(theme.events || 0)} events
</p>
</div>
)
})}
{!periodData.themes.length ? <p className="text-sm text-text-soft">No theme data recorded yet</p> : null}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Locations</h2>
<p className="mt-1 text-sm text-text-soft">Cities and locations seen over {periodData.title.toLowerCase()}</p>
</div>
<LocationSignalTable countries={periodData.countries} locations={periodData.locations} />
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Location signals</h2>
<p className="mt-1 text-sm text-text-soft">
Cloudflare edge geolocation from {periodData.title.toLowerCase()}
</p>
</div>
<LocationSignalMap countries={periodData.countries} locations={periodData.locations} />
</div>
</div>
<p className="text-xs text-text-soft">
Analytics are opt-in, retained for {formatNumber(data.privacy?.retention_days || 30)} days,
and public output excludes raw IP addresses and precise location.
</p>
</section>
)
}
function LocationSignalTable({ countries, locations }) {
const rows = locations.length
? locations.map((location) => {
const city = repairMojibake(location.city)
const region = repairMojibake(location.region)
const timezone = repairMojibake(location.timezone)
return {
key: `${location.country}-${location.region}-${location.city}-${location.latitude}-${location.longitude}-${location.timezone}`,
place: city || region || timezone || 'Unknown city',
region: region || 'Not shared',
country: countryNames[location.country] || location.country || 'Not shared',
visitors: location.visitors,
events: location.events,
}
})
: countries.map((country) => ({
key: country.country,
place: countryNames[country.country] || country.country || 'Unknown country',
region: 'Country signal',
country: countryNames[country.country] || country.country || 'Not shared',
visitors: country.visitors,
events: country.events,
}))
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-left text-sm">
<thead className="border-b border-surface text-xs uppercase tracking-wide text-text-soft">
<tr>
<th className="px-5 py-3 font-semibold">City/location</th>
<th className="px-5 py-3 font-semibold">Region</th>
<th className="px-5 py-3 font-semibold">Country</th>
<th className="px-5 py-3 text-right font-semibold">Visitors</th>
<th className="px-5 py-3 text-right font-semibold">Events</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr className="border-b border-surface" key={row.key}>
<td className="px-5 py-3 font-semibold">{row.place}</td>
<td className="px-5 py-3 text-text-soft">{row.region}</td>
<td className="px-5 py-3 text-text-soft">{row.country}</td>
<td className="px-5 py-3 text-right font-semibold text-fury-cyan">{formatNumber(row.visitors)}</td>
<td className="px-5 py-3 text-right text-text-soft">{formatNumber(row.events)}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="px-5 py-10 text-sm text-text-soft">No location data recorded yet</p> : null}
</div>
)
}
function LocationSignalMap({ countries, locations }) {
const mapRef = useRef(null)
const markersRef = useRef(null)
const leafletRef = useRef(null)
const [mapReady, setMapReady] = useState(false)
const countryMarkerColor = '#e82517'
const maxMarkerVisitors = Math.max(
1,
...countries.map((country) => Number(country.visitors || 0)),
...locations.map((location) => Number(location.visitors || 0)),
)
const countryMarkers = countries
.map((country) => {
const lat = Number(country.latitude)
const lon = Number(country.longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
return {
...country,
lat,
lon,
label: countryNames[country.country] || country.country,
}
})
.filter(Boolean)
const cityMarkers = locations
.map((location) => {
const lat = Number(location.latitude)
const lon = Number(location.longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
const place = [repairMojibake(location.city), repairMojibake(location.region), countryNames[location.country] || location.country]
.filter(Boolean)
.join(', ')
return {
...location,
lat,
lon,
label: place || location.country || repairMojibake(location.timezone) || 'Unknown location',
}
})
.filter(Boolean)
const topLocations = cityMarkers.length
? cityMarkers.slice(0, 8).map((location) => ({
key: `${location.country}-${location.region}-${location.city}-${location.lat}-${location.lon}`,
label: location.label,
detail: 'Cloudflare edge location',
visitors: location.visitors,
events: location.events,
}))
: countries.slice(0, 8).map((country) => ({
key: country.country,
label: countryNames[country.country] || country.country,
detail: 'country signal',
visitors: country.visitors,
events: country.events,
}))
useEffect(() => {
if (!mapRef.current || markersRef.current) return undefined
let cancelled = false
let map = null
async function initializeMap() {
const [{ default: L }] = await Promise.all([
import('leaflet'),
import('leaflet/dist/leaflet.css'),
])
if (cancelled || !mapRef.current) return
leafletRef.current = L
map = L.map(mapRef.current, {
center: [25, 0],
maxBounds: [[-85, -220], [85, 220]],
minZoom: 1,
scrollWheelZoom: true,
worldCopyJump: true,
zoom: 1,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
maxZoom: 8,
}).addTo(map)
markersRef.current = {
layer: L.layerGroup().addTo(map),
map,
}
setMapReady(true)
}
initializeMap()
return () => {
cancelled = true
markersRef.current = null
leafletRef.current = null
map?.remove()
}
}, [])
useEffect(() => {
const L = leafletRef.current
if (!markersRef.current || !L) return
const { layer } = markersRef.current
layer.clearLayers()
const scaleMarkerRadius = (visitors, minRadius, maxRadius) => {
const count = Math.max(0, Number(visitors || 0))
const normalized = Math.log1p(count) / Math.log1p(maxMarkerVisitors)
return minRadius + normalized * (maxRadius - minRadius)
}
const mapMarkers = cityMarkers.length ? cityMarkers : countryMarkers
mapMarkers.forEach((location) => {
const isCityMarker = cityMarkers.length > 0
const radius = isCityMarker
? scaleMarkerRadius(location.visitors, 5, 14)
: scaleMarkerRadius(location.visitors, 8, 24)
L.circleMarker([location.lat, location.lon], {
color: countryMarkerColor,
fillColor: countryMarkerColor,
fillOpacity: 0.4,
opacity: 0,
radius,
stroke: false,
})
.bindTooltip(`${location.label}: ${formatNumber(location.visitors)} visitors`, {
direction: 'top',
opacity: 0.95,
})
.addTo(layer)
})
}, [cityMarkers, countryMarkerColor, countryMarkers, mapReady, maxMarkerVisitors])
return (
<div className="p-5">
<div className="location-signal-map relative z-0 h-[340px] overflow-hidden rounded-md border border-border bg-surface">
<div ref={mapRef} className="h-full w-full" />
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{topLocations.map((location) => (
<div className="grid grid-cols-[1fr_auto] gap-3 rounded-md bg-surface px-3 py-2 text-sm" key={`${location.key}-row`}>
<div className="min-w-0">
<p className="truncate font-semibold">{location.label}</p>
<p className="truncate text-xs text-text-soft">{location.detail}</p>
</div>
<p className="font-semibold text-fury-cyan">{formatNumber(location.visitors)}</p>
</div>
))}
</div>
{!countries.length && !locations.length ? (
<p className="mt-4 text-sm text-text-soft">
No Cloudflare location signals have been shared yet.
</p>
) : null}
</div>
)
}
function UptimePage({ uptime }) {
const [hoveredSnapshot, setHoveredSnapshot] = useState(null)
const checks = uptime.checks
const history = uptime.history
const operationalCount = checks.filter((check) => check.ok).length
const allOperational = checks.length > 0 && operationalCount === checks.length
const updatedAt = uptime.updatedAt ? dateFormat.format(new Date(uptime.updatedAt)) : 'Not checked yet'
const onlineSamples = history.filter((sample) => sample.ok).length
const availability = history.length ? (onlineSamples / history.length) * 100 : 0
return (
<section className="space-y-6 pt-24 sm:pt-28">
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Website uptime
</p>
<h1 className="mt-1 text-4xl font-bold">
{allOperational ? 'All systems operational' : 'Status check'}
</h1>
<p className="mt-2 text-sm text-text-soft">
Last server snapshot {updatedAt}. The page refreshes once a minute.
</p>
</div>
<span
className={`w-fit rounded-md px-3 py-2 text-sm font-semibold ${allOperational
? 'bg-surface text-fury-cyan'
: 'bg-fury-ice text-fury-violet'
}`}
>
{uptime.status === 'loading' ? 'Checking' : `${operationalCount}/${checks.length || 3} online`}
</span>
</div>
</div>
<div className="rounded-lg border border-border bg-fury-white p-5 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold">Availability timeline</h2>
<p className="mt-1 text-sm text-text-soft">
Last {formatNumber(history.length)} persisted server snapshots
</p>
</div>
<p className="text-sm font-semibold text-fury-cyan">
{history.length ? `${availability.toFixed(1)}% uptime` : 'Waiting for first sample'}
</p>
</div>
<div
className="relative mt-5 grid h-28 items-end gap-px rounded-md border border-border bg-bg p-3"
style={{
gridTemplateColumns: history.length
? `repeat(${history.length}, minmax(0, 1fr))`
: '1fr',
}}
>
{history.map((sample, index) => {
const height = `${Math.max(12, (sample.onlineChecks / sample.totalChecks) * 100)}%`
const left = history.length > 1 ? (index / (history.length - 1)) * 100 : 50
return (
<div
aria-label={`${sample.ok ? 'Uptime' : 'Downtime'} at ${dateFormat.format(new Date(sample.timestamp))}`}
className={`w-full min-w-0 rounded-sm ${sample.ok ? 'bg-success' : 'bg-danger'}`}
key={sample.timestamp}
onBlur={() => setHoveredSnapshot(null)}
onFocus={() => setHoveredSnapshot({ ...sample, left })}
onMouseEnter={() => setHoveredSnapshot({ ...sample, left })}
onMouseLeave={() => setHoveredSnapshot(null)}
style={{ height }}
tabIndex="0"
/>
)
})}
{hoveredSnapshot ? (
<div
className="pointer-events-none absolute bottom-full z-20 mb-2 w-max max-w-64 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg"
style={{
left: `${hoveredSnapshot.left}%`,
transform: 'translateX(-50%)',
}}
>
{dateFormat.format(new Date(hoveredSnapshot.timestamp))}
<span className="block font-normal text-bg/80">
{hoveredSnapshot.ok ? 'Uptime' : 'Downtime or degraded'} · {formatNumber(hoveredSnapshot.onlineChecks)}/{formatNumber(hoveredSnapshot.totalChecks)} online
</span>
</div>
) : null}
{!history.length ? (
<div className="flex h-full w-full items-center justify-center text-sm text-text-soft">
Checking website status
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-4 text-xs text-text-soft">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm bg-success" />
Uptime
</span>
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm bg-danger" />
Downtime or degraded
</span>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{checks.map((check) => (
<article className="rounded-lg border border-border bg-fury-white p-5 shadow-sm" key={check.name}>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{check.name}</h2>
<p className="mt-1 text-sm text-text-soft">{check.detail}</p>
</div>
<span
className={`shrink-0 rounded-md px-2 py-1 text-xs font-semibold ${check.ok
? 'bg-surface text-fury-cyan'
: 'bg-fury-ice text-fury-violet'
}`}
>
{check.ok ? 'Online' : 'Issue'}
</span>
</div>
<p className="mt-5 text-sm font-semibold">{check.label}</p>
<p className="mt-1 text-xs text-text-soft">{formatNumber(check.latency)} ms response window</p>
</article>
))}
{!checks.length ? (
<p className="rounded-lg border border-border bg-fury-white px-5 py-10 text-sm text-text-soft shadow-sm lg:col-span-3">
Checking website status
</p>
) : null}
</div>
</section>
)
}
export default App