5804 lines
212 KiB
React
5804 lines
212 KiB
React
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
|
||
import FallingLeaves from '../Tree/FallingLeaves'
|
||
import ReplayCanvasPanel from './ReplayCanvas'
|
||
import { buildBracket, computeBracketLayout, feederParent, layoutKey } from './bracket'
|
||
|
||
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',
|
||
players: '/api/tss/leaderboard/players?limit=100',
|
||
tournaments: '/api/tss/tournaments',
|
||
tournament: (id) => `/api/tss/tournaments/${encodeURIComponent(id)}`,
|
||
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)}`,
|
||
searchPlayers: (name) => `/api/tss/players/search?q=${encodeURIComponent(name)}&limit=10`,
|
||
}
|
||
|
||
const primaryNavItems = [
|
||
{ path: '/', label: 'Home' },
|
||
{ path: '/teams', label: 'Team' },
|
||
{ path: '/players', label: 'Players' },
|
||
{ path: '/battle-logs', label: 'Battles' },
|
||
{ path: '/tournaments', label: 'Tournaments' },
|
||
{ path: '/blog', label: 'Blog' },
|
||
]
|
||
|
||
const utilityNavItems = [
|
||
{ 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 = import.meta.env.VITE_SITE_VERSION || '1.0.0'
|
||
|
||
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||
const siteGateEnabled = String(import.meta.env.VITE_SITE_GATE || 'false').toLowerCase() === 'true'
|
||
const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '')
|
||
const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true'
|
||
const missingStaticDataPaths = new Set()
|
||
const blogPostFiles = import.meta.glob('./blog/posts/*.md', {
|
||
eager: true,
|
||
import: 'default',
|
||
query: '?raw',
|
||
})
|
||
// Rendered inline (not via <img src="data:...">) so the SVGs live in the page's
|
||
// CSS scope and can resolve currentColor and the --sprite-* / --color-* theme
|
||
// variables. An external SVG image renders in an isolated document and would
|
||
// fall back to black for every themed fill.
|
||
function PixelSun(props) {
|
||
return (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 16 16"
|
||
shapeRendering="crispEdges"
|
||
{...props}
|
||
>
|
||
<rect width="16" height="16" fill="none" />
|
||
<path fill="currentColor" d="M5 1h6v1h2v2h1v2h1v4h-1v2h-1v2h-2v1H5v-1H3v-2H2v-2H1V6h1V4h1V2h2z" />
|
||
<path fill="var(--sprite-light)" d="M5 3h5v1h2v2h1v3h-1v2h-2v1H5v-1H4V9H3V6h1V4h1z" />
|
||
<path fill="var(--sprite-shine)" d="M5 3h3v1h2v2H8v1H5z" />
|
||
<path fill="var(--sprite-shadow)" d="M11 9h2v2h-2v1H6v-1h3v-1h2z" />
|
||
</svg>
|
||
)
|
||
}
|
||
function PixelMoon(props) {
|
||
return (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 16 16"
|
||
shapeRendering="crispEdges"
|
||
{...props}
|
||
>
|
||
<rect width="16" height="16" fill="none" />
|
||
<path fill="var(--sprite-light)" d="M7 1h5v1h2v2h1v7h-1v2h-2v1H6v-1H4v-2H3V5h1V3h3z" />
|
||
<path fill="var(--sprite-mid)" d="M5 4h2v2H5zm5-1h2v1h1v2h-2V5h-1zm-4 6h2v2H6zm4 2h3v1h-1v1h-2zM4 7h1v3H4z" />
|
||
<path fill="var(--sprite-shadow)" d="M5 10h1v2h2v1H6v-1H5zm7-5h1v2h-2V6h1zM8 3h1v2H7V4h1z" />
|
||
<path fill="currentColor" d="M7 0h5v1h2v1h1v2h1v7h-1v2h-1v1h-2v1H6v-1H4v-1H3v-2H2V5h1V3h1V2h3zm0 1v1H5v1H4v2H3v6h1v2h2v1h6v-1h2v-2h1V4h-1V2h-2V1z" />
|
||
<path fill="var(--sprite-shine)" d="M6 3h2v1H7v2H5V5h1zm4 4h2v1h1v1h-3zM8 11h1v1H7v-1z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
const defaultAnalyticsPreferences = {
|
||
chosen: false,
|
||
analytics: false,
|
||
device: false,
|
||
display: false,
|
||
locale: false,
|
||
referrer: false,
|
||
diagnostics: false,
|
||
version: analyticsConsentVersion,
|
||
}
|
||
|
||
function staticDataPath(...segments) {
|
||
return [staticDataBase, ...segments].join('/')
|
||
}
|
||
|
||
function staticDataKey(value) {
|
||
return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~')
|
||
}
|
||
|
||
function dataSource(apiPath, staticPath = null) {
|
||
return { apiPath, staticPath }
|
||
}
|
||
|
||
const publicDataSources = {
|
||
teams: dataSource(apiEndpoints.teams, staticDataPath('leaderboard-teams.json')),
|
||
players: dataSource(apiEndpoints.players, staticDataPath('leaderboard-players.json')),
|
||
homeTeams: dataSource(apiEndpoints.homeTeams, staticDataPath('home-teams.json')),
|
||
recentGames: dataSource(apiEndpoints.recentGames, staticDataPath('recent-games.json')),
|
||
detail: (name) => dataSource(apiEndpoints.detail(name), staticDataPath('teams', `${staticDataKey(name)}.json`)),
|
||
games: (name) => dataSource(apiEndpoints.games(name), staticDataPath('teams', `${staticDataKey(name)}.games.json`)),
|
||
player: (uid) => dataSource(apiEndpoints.player(uid), staticDataPath('players', `${staticDataKey(uid)}.json`)),
|
||
game: (gameId) => dataSource(apiEndpoints.game(gameId), staticDataPath('games', `${staticDataKey(gameId)}.json`)),
|
||
gameLogs: (gameId) => dataSource(apiEndpoints.gameLogs(gameId), staticDataPath('games', `${staticDataKey(gameId)}.logs.json`)),
|
||
}
|
||
|
||
async function fetchJson(path, signal) {
|
||
const response = await fetch(path, {
|
||
signal,
|
||
headers: { Accept: 'application/json' },
|
||
})
|
||
const contentType = response.headers.get('content-type') || ''
|
||
const body = contentType.includes('application/json')
|
||
? await response.json().catch(() => null)
|
||
: null
|
||
|
||
if (!response.ok) {
|
||
const error = new Error(body?.error || `Request failed with ${response.status}`)
|
||
error.status = response.status
|
||
error.path = path
|
||
throw error
|
||
}
|
||
|
||
if (!body) {
|
||
const error = new Error(`Expected JSON from ${path}`)
|
||
error.status = response.status
|
||
error.path = path
|
||
error.contentType = contentType
|
||
throw error
|
||
}
|
||
|
||
return body
|
||
}
|
||
|
||
async function fetchPublicJson(source, signal) {
|
||
if (!source?.staticPath || !staticDataEnabled || missingStaticDataPaths.has(source.staticPath)) {
|
||
return fetchJson(source.apiPath, signal)
|
||
}
|
||
|
||
try {
|
||
return await fetchJson(source.staticPath, signal)
|
||
} catch (error) {
|
||
if (signal?.aborted || error?.name === 'AbortError') throw error
|
||
if (error?.status === 404 || error?.contentType) {
|
||
missingStaticDataPaths.add(source.staticPath)
|
||
return fetchJson(source.apiPath, signal)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
function parseRoute(pathname = window.location.pathname) {
|
||
if (pathname === '/') return { page: 'home', teamName: '' }
|
||
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||
if (pathname === '/players') return { page: 'players', teamName: '' }
|
||
if (pathname === '/blog') return { page: 'blog', teamName: '' }
|
||
if (pathname.startsWith('/blog/')) {
|
||
const slug = decodeURIComponent(pathname.slice('/blog/'.length))
|
||
return { page: 'blog-post', teamName: '', slug }
|
||
}
|
||
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 === '/tournaments') return { page: 'tournaments-list', teamName: '' }
|
||
if (pathname.startsWith('/tournaments/')) {
|
||
const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length))
|
||
return { page: 'tournament', teamName: '', tournamentId }
|
||
}
|
||
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 playerPath(uid) {
|
||
return `/players/${encodeURIComponent(uid)}`
|
||
}
|
||
|
||
function tournamentPath(tournamentId) {
|
||
return `/tournaments/${encodeURIComponent(tournamentId)}`
|
||
}
|
||
|
||
function blogPostPath(slug) {
|
||
return `/blog/${encodeURIComponent(slug)}`
|
||
}
|
||
|
||
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 slugify(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
}
|
||
|
||
function parseBlogFrontMatter(raw) {
|
||
const text = String(raw || '').replace(/^\uFEFF/, '')
|
||
if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) {
|
||
return { attributes: {}, content: text.trim() }
|
||
}
|
||
|
||
const normalized = text.replace(/\r\n/g, '\n')
|
||
const end = normalized.indexOf('\n---', 4)
|
||
if (end === -1) return { attributes: {}, content: normalized.trim() }
|
||
|
||
const frontMatter = normalized.slice(4, end).trim()
|
||
const content = normalized.slice(end + 4).trim()
|
||
const attributes = {}
|
||
|
||
frontMatter.split('\n').forEach((line) => {
|
||
const separator = line.indexOf(':')
|
||
if (separator === -1) return
|
||
const key = line.slice(0, separator).trim()
|
||
const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')
|
||
if (key) attributes[key] = value
|
||
})
|
||
|
||
return { attributes, content }
|
||
}
|
||
|
||
function excerptFromMarkdown(markdown) {
|
||
return String(markdown || '')
|
||
.replace(/```[\s\S]*?```/g, '')
|
||
.split('\n')
|
||
.map((line) => line.replace(/^#{1,6}\s+/, '').trim())
|
||
.find((line) => line && !line.startsWith('![') && !line.startsWith('>')) || ''
|
||
}
|
||
|
||
function createBlogPost(path, raw) {
|
||
const { attributes, content } = parseBlogFrontMatter(raw)
|
||
const filename = path.split('/').pop()?.replace(/\.md$/i, '') || ''
|
||
const slug = slugify(attributes.slug || filename)
|
||
const title = attributes.title || filename.replace(/[-_]+/g, ' ') || 'Untitled post'
|
||
const dateValue = attributes.date || ''
|
||
const timestamp = dateValue ? Date.parse(dateValue) : 0
|
||
|
||
return {
|
||
author: attributes.author || '',
|
||
content,
|
||
date: dateValue,
|
||
excerpt: attributes.excerpt || excerptFromMarkdown(content),
|
||
path: blogPostPath(slug),
|
||
slug,
|
||
timestamp: Number.isFinite(timestamp) ? timestamp : 0,
|
||
title,
|
||
}
|
||
}
|
||
|
||
const blogPosts = Object.entries(blogPostFiles)
|
||
.map(([path, raw]) => createBlogPost(path, raw))
|
||
.filter((post) => post.slug && post.title)
|
||
.sort((a, b) => b.timestamp - a.timestamp || a.title.localeCompare(b.title))
|
||
|
||
const blogPostsBySlug = new Map(blogPosts.map((post) => [post.slug, post]))
|
||
|
||
function formatBlogDate(dateValue) {
|
||
if (!dateValue) return 'Undated'
|
||
const timestamp = Date.parse(dateValue)
|
||
if (!Number.isFinite(timestamp)) return dateValue
|
||
return new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(new Date(timestamp))
|
||
}
|
||
|
||
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, spread = false }) {
|
||
if (!participants.length) {
|
||
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
|
||
}
|
||
|
||
// On wide rows (battle logs), spread the two teams to opposite ends with a
|
||
// centered "vs" so each side gets an equal share of the available width.
|
||
if (spread && participants.length === 2) {
|
||
const [first, second] = participants
|
||
return (
|
||
<div className="mx-auto grid w-full max-w-xl min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-4">
|
||
<span
|
||
className={`min-w-0 truncate text-right text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||
>
|
||
{first.name}
|
||
</span>
|
||
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||
vs
|
||
</span>
|
||
<span
|
||
className={`min-w-0 truncate text-left text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||
>
|
||
{second.name}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={`flex min-w-0 flex-wrap gap-x-3 gap-y-1${spread ? ' justify-between' : ''}`}>
|
||
{participants.map((participant) => (
|
||
<span
|
||
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||
key={`${participant.result}-${participant.name}`}
|
||
>
|
||
{participant.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function bestTeamName(team) {
|
||
return team?.name || ''
|
||
}
|
||
|
||
function searchKey(value) {
|
||
return String(value || '').trim().toLocaleLowerCase()
|
||
}
|
||
|
||
function teamSearchResult(team) {
|
||
const name = bestTeamName(team)
|
||
return {
|
||
kind: 'team',
|
||
name,
|
||
value: name,
|
||
detail: 'Team',
|
||
aliases: [team?.name].filter(Boolean),
|
||
}
|
||
}
|
||
|
||
function playerSearchResult(player) {
|
||
const uid = String(player?.uid || '').trim()
|
||
const nick = String(player?.nick || '').trim()
|
||
return {
|
||
kind: 'player',
|
||
name: nick || (uid ? `Player ${uid}` : ''),
|
||
value: nick || uid,
|
||
detail: uid ? `Player ${uid}` : 'Player',
|
||
uid,
|
||
aliases: [nick, uid].filter(Boolean),
|
||
}
|
||
}
|
||
|
||
function dedupeSearchResults(results) {
|
||
const seen = new Set()
|
||
return results.filter((result) => {
|
||
const key = result.kind === 'player' && result.uid
|
||
? `player:${result.uid}`
|
||
: `${result.kind}:${searchKey(result.name)}`
|
||
if (!result.name || seen.has(key)) return false
|
||
seen.add(key)
|
||
return true
|
||
})
|
||
}
|
||
|
||
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 }
|
||
|
||
const analytics = Boolean(value.analytics)
|
||
|
||
return {
|
||
chosen: Boolean(value.chosen) && value.version === analyticsConsentVersion,
|
||
analytics,
|
||
device: analytics && Boolean(value.device),
|
||
display: analytics && Boolean(value.display),
|
||
locale: analytics && Boolean(value.locale),
|
||
referrer: analytics && Boolean(value.referrer),
|
||
diagnostics: analytics && 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 === 'players') return 'Player Leaderboard'
|
||
if (route.page === 'blog') return 'Blog'
|
||
if (route.page === 'blog-post') return blogPostsBySlug.get(route.slug)?.title || 'Blog post'
|
||
if (route.page === 'battle-logs') return 'Battle Logs'
|
||
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
|
||
if (route.page === 'tournaments-list') return 'Tournaments'
|
||
if (route.page === 'tournament') return route.tournamentId ? `Tournament ${route.tournamentId}` : 'Tournament'
|
||
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 === 'players') return '/players'
|
||
if (route.page === 'blog') return '/blog'
|
||
if (route.page === 'blog-post' && route.slug) return blogPostPath(route.slug)
|
||
if (route.page === 'battle-logs') return '/battle-logs'
|
||
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
|
||
if (route.page === 'tournaments-list') return '/tournaments'
|
||
if (route.page === 'tournament' && route.tournamentId) return tournamentPath(route.tournamentId)
|
||
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),
|
||
}
|
||
}
|
||
|
||
if (route.page === 'tournament' && route.tournamentId) {
|
||
return {
|
||
title: `Tournament ${route.tournamentId} | Toothless' TSS Bot`,
|
||
description: `TSS tournament ${route.tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
|
||
robots: 'index, 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',
|
||
},
|
||
players: {
|
||
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
||
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
||
robots: 'index, follow',
|
||
path: '/players',
|
||
},
|
||
'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',
|
||
},
|
||
'tournaments-list': {
|
||
title: "TSS Tournaments | Toothless' TSS Bot",
|
||
description: 'Browse tracked TSS tournaments with authoritative brackets, standings, and linked replay availability.',
|
||
robots: 'index, follow',
|
||
path: '/tournaments',
|
||
},
|
||
blog: {
|
||
title: "TSS Bot Blog | Toothless' TSS Bot",
|
||
description: 'News, updates, and announcements from Toothless TSS Bot.',
|
||
robots: 'index, follow',
|
||
path: '/blog',
|
||
},
|
||
'blog-post': {
|
||
title: `${blogPostsBySlug.get(route.slug)?.title || 'Blog post'} | Toothless' TSS Bot`,
|
||
description: blogPostsBySlug.get(route.slug)?.excerpt || 'News and updates from Toothless TSS Bot.',
|
||
robots: blogPostsBySlug.has(route.slug) ? 'index, follow' : 'noindex, follow',
|
||
path: route.slug ? blogPostPath(route.slug) : '/blog',
|
||
},
|
||
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 fetchPublicJson(publicDataSources.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 - 42),
|
||
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
|
||
let script = document.querySelector('script[data-tss-turnstile]')
|
||
|
||
if (!script) {
|
||
script = document.createElement('script')
|
||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||
script.async = true
|
||
script.defer = true
|
||
script.dataset.tssTurnstile = 'true'
|
||
document.head.appendChild(script)
|
||
}
|
||
|
||
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() {
|
||
if (!siteGateEnabled) return <AppContent />
|
||
return <GatedAppContent />
|
||
}
|
||
|
||
function GatedAppContent() {
|
||
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 [playerLeaderboard, setPlayerLeaderboard] = 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 players = useMemo(
|
||
() => playerLeaderboard.data?.players || [],
|
||
[playerLeaderboard.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: '' })
|
||
Promise.allSettled([
|
||
fetchJson(apiEndpoints.searchTeams(query), controller.signal),
|
||
fetchJson(apiEndpoints.searchPlayers(query), controller.signal),
|
||
]).then(([teamResult, playerResult]) => {
|
||
if (controller.signal.aborted) return
|
||
|
||
const teamResults = teamResult.status === 'fulfilled'
|
||
? (teamResult.value.teams || teamResult.value.results || []).map(teamSearchResult)
|
||
: []
|
||
const playerResults = playerResult.status === 'fulfilled'
|
||
? (playerResult.value.players || []).map(playerSearchResult)
|
||
: []
|
||
const results = dedupeSearchResults([...teamResults, ...playerResults]).slice(0, 10)
|
||
|
||
setTeamSearchResults(results)
|
||
setSearchHint(results.length ? { status: 'ready', name: results[0].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
|
||
|
||
const controller = new AbortController()
|
||
setLeaderboard({ status: 'loading', data: null, error: null })
|
||
|
||
fetchPublicJson(publicDataSources.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()
|
||
}, [route.page])
|
||
|
||
useEffect(() => {
|
||
if (route.page !== 'players') return
|
||
|
||
const controller = new AbortController()
|
||
setPlayerLeaderboard({ status: 'loading', data: null, error: null })
|
||
|
||
fetchPublicJson(publicDataSources.players, controller.signal)
|
||
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||
.catch((error) => {
|
||
if (!controller.signal.aborted) {
|
||
setPlayerLeaderboard({ status: 'error', data: null, error: error.message })
|
||
}
|
||
})
|
||
|
||
return () => controller.abort()
|
||
}, [route.page])
|
||
|
||
useEffect(() => {
|
||
if (route.page !== 'home') return
|
||
|
||
const controller = new AbortController()
|
||
setHomeTeams({ status: 'loading', data: null, error: null })
|
||
|
||
fetchPublicJson(publicDataSources.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()
|
||
}, [route.page])
|
||
|
||
useEffect(() => {
|
||
if (!['teams', 'team', 'battle-logs'].includes(route.page)) return
|
||
|
||
const controller = new AbortController()
|
||
const timer = window.setInterval(() => {
|
||
fetchPublicJson(publicDataSources.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([
|
||
fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
|
||
fetchPublicJson(publicDataSources.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([
|
||
fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
|
||
fetchPublicJson(publicDataSources.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 = searchKey(teamQuery)
|
||
|
||
return dedupeSearchResults(teams.map(teamSearchResult))
|
||
.filter(({ name, aliases }) => {
|
||
if (!query) return true
|
||
return aliases.some((alias) => searchKey(alias).includes(query)) || searchKey(name).includes(query)
|
||
})
|
||
.slice(0, 10)
|
||
}, [teamQuery, teams])
|
||
const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions
|
||
const selectedSearchResult = teamSuggestions.find((result) => {
|
||
const query = searchKey(teamQuery)
|
||
return searchKey(result.value) === query || result.aliases.some((alias) => searchKey(alias) === query)
|
||
})
|
||
const searchPlaceholder =
|
||
searchHint.status === 'ready'
|
||
? `Found ${searchHint.name}`
|
||
: searchHint.status === 'error'
|
||
? 'No team or player found'
|
||
: topTeamName ? `${topTeamName}, or search players` : 'Search teams or players'
|
||
|
||
async function handleTeamSearch(event) {
|
||
event.preventDefault()
|
||
const name = teamQuery.trim()
|
||
if (!name) return
|
||
|
||
try {
|
||
if (selectedSearchResult?.kind === 'player' && selectedSearchResult.uid) {
|
||
navigate(playerPath(selectedSearchResult.uid))
|
||
setTeamQuery('')
|
||
return
|
||
}
|
||
|
||
const resolved = await fetchJson(apiEndpoints.resolve(name))
|
||
const resolvedName = resolved.name
|
||
if (!resolvedName) throw new Error('Team not found')
|
||
|
||
const detail = await fetchPublicJson(publicDataSources.detail(resolvedName))
|
||
if (!teamDetailLooksReal(detail)) throw new Error('Team not found')
|
||
|
||
navigate(teamPath(canonicalTeamName(detail, resolvedName)))
|
||
setTeamQuery('')
|
||
} catch (teamError) {
|
||
try {
|
||
const players = await fetchJson(apiEndpoints.searchPlayers(name))
|
||
const player = (players.players || []).find((candidate) => (
|
||
searchKey(candidate.nick) === searchKey(name) || searchKey(candidate.uid) === searchKey(name)
|
||
)) || players.players?.[0]
|
||
|
||
if (!player?.uid) throw teamError
|
||
navigate(playerPath(player.uid))
|
||
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 === 'player' || route.page === 'players'
|
||
? '/players'
|
||
: route.page === 'battle-logs' || route.page === 'game'
|
||
? '/battle-logs'
|
||
: route.page === 'tournaments-list' || route.page === 'tournament'
|
||
? '/tournaments'
|
||
: route.page === 'blog' || route.page === 'blog-post'
|
||
? '/blog'
|
||
: 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])
|
||
|
||
useEffect(() => {
|
||
if (route.page !== 'players') return
|
||
|
||
const controller = new AbortController()
|
||
const timer = window.setInterval(() => {
|
||
fetchPublicJson(publicDataSources.players, controller.signal)
|
||
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||
.catch((error) => {
|
||
if (!controller.signal.aborted) {
|
||
setPlayerLeaderboard((current) => ({ ...current, error: error.message }))
|
||
}
|
||
})
|
||
}, 60000)
|
||
|
||
return () => {
|
||
window.clearInterval(timer)
|
||
controller.abort()
|
||
}
|
||
}, [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-1.5 rounded-full border border-border bg-fury-white/92 py-1.5 pl-1.5 pr-11 shadow-[0_8px_24px_rgba(0,0,0,0.13)] backdrop-blur sm:pl-2"
|
||
ref={navPillRef}
|
||
>
|
||
<button
|
||
aria-label="Go to Toothless' TSS Bot home"
|
||
className="hidden h-8 w-8 shrink-0 place-items-center rounded-full transition hover:bg-surface sm:grid"
|
||
onClick={() => navigate('/')}
|
||
type="button"
|
||
>
|
||
<img
|
||
alt=""
|
||
className="h-7 w-7 rounded-full"
|
||
src="/embed-icon.svg"
|
||
/>
|
||
</button>
|
||
|
||
<nav className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||
{primaryNavItems.map((item) => (
|
||
<button
|
||
className={`shrink-0 rounded-full px-2.5 py-1.5 text-[13px] font-semibold leading-5 transition sm:px-3 ${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 className="h-5 w-px shrink-0 bg-border/80" aria-hidden="true" />
|
||
|
||
<nav className="flex shrink-0 items-center gap-0.5">
|
||
{utilityNavItems.map((item) => (
|
||
<button
|
||
className={`shrink-0 rounded-full px-2.5 py-1.5 text-[13px] font-semibold leading-5 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 === 'players' ? (
|
||
<PlayersPage leaderboard={playerLeaderboard} navigate={navigate} players={players} />
|
||
) : 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 === 'blog' ? <BlogPage navigate={navigate} posts={blogPosts} /> : null}
|
||
{route.page === 'blog-post' ? <BlogPostPage navigate={navigate} post={blogPostsBySlug.get(route.slug)} /> : null}
|
||
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
||
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
||
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
|
||
{route.page === 'tournament' ? <TournamentDetailPage tournamentId={route.tournamentId} 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' 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>
|
||
<button
|
||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||
onClick={() => navigate('/blog')}
|
||
type="button"
|
||
>
|
||
Blog
|
||
</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) => {
|
||
if (key === 'analytics' && !value) {
|
||
return {
|
||
...current,
|
||
analytics: false,
|
||
device: false,
|
||
display: false,
|
||
locale: false,
|
||
referrer: false,
|
||
diagnostics: false,
|
||
}
|
||
}
|
||
|
||
return { ...current, [key]: value }
|
||
})
|
||
}
|
||
|
||
function savePreferences(nextPreferences) {
|
||
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
||
setIsOpen(false)
|
||
}
|
||
|
||
function declineAll() {
|
||
savePreferences({ ...defaultAnalyticsPreferences, chosen: true })
|
||
}
|
||
|
||
function allowAll() {
|
||
savePreferences({
|
||
chosen: true,
|
||
analytics: true,
|
||
device: true,
|
||
display: true,
|
||
locale: true,
|
||
referrer: true,
|
||
diagnostics: true,
|
||
version: analyticsConsentVersion,
|
||
})
|
||
}
|
||
|
||
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 overflow-y-auto bg-black/45 px-4 py-4 backdrop-blur-[2px] sm:py-6"
|
||
role="dialog"
|
||
>
|
||
<div className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-md border border-border bg-fury-white p-5 text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)] sm:max-h-[calc(100dvh-3rem)]">
|
||
<div className="max-w-xl shrink-0">
|
||
<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-4 min-h-0 space-y-3 overflow-y-auto pr-1">
|
||
<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-4 flex shrink-0 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={declineAll}
|
||
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={allowAll}
|
||
type="button"
|
||
>
|
||
Allow all
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="mt-4 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-soft transition hover:bg-surface hover:text-text"
|
||
onClick={declineAll}
|
||
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={() => 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={allowAll}
|
||
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: '' })
|
||
fetchPublicJson(publicDataSources.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">
|
||
Documentation is being written.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function safeMarkdownLink(href) {
|
||
const value = String(href || '').trim()
|
||
if (/^(https?:|mailto:|\/)/i.test(value)) return value
|
||
return '#'
|
||
}
|
||
|
||
function safeMediaSource(src) {
|
||
const value = String(src || '').trim()
|
||
if (/^(https?:|\/)/i.test(value)) return value
|
||
return ''
|
||
}
|
||
|
||
function youtubeEmbedUrl(value) {
|
||
try {
|
||
const url = new URL(value)
|
||
const host = url.hostname.replace(/^www\./, '')
|
||
let id = ''
|
||
|
||
if (host === 'youtu.be') {
|
||
id = url.pathname.split('/').filter(Boolean)[0] || ''
|
||
} else if (host === 'youtube.com' || host === 'm.youtube.com') {
|
||
if (url.pathname.startsWith('/watch')) id = url.searchParams.get('v') || ''
|
||
if (url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')) {
|
||
id = url.pathname.split('/').filter(Boolean)[1] || ''
|
||
}
|
||
}
|
||
|
||
if (!/^[A-Za-z0-9_-]{6,32}$/.test(id)) return ''
|
||
return `https://www.youtube-nocookie.com/embed/${id}`
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function vimeoEmbedUrl(value) {
|
||
try {
|
||
const url = new URL(value)
|
||
const host = url.hostname.replace(/^www\./, '')
|
||
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return ''
|
||
const id = url.pathname.split('/').filter(Boolean).find((part) => /^\d+$/.test(part)) || ''
|
||
return id ? `https://player.vimeo.com/video/${id}` : ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function mediaEmbedFor(url) {
|
||
const src = safeMediaSource(url)
|
||
if (!src) return { kind: 'invalid', src: '' }
|
||
|
||
const youtube = youtubeEmbedUrl(src)
|
||
if (youtube) return { kind: 'iframe', src: youtube, title: 'YouTube video' }
|
||
|
||
const vimeo = vimeoEmbedUrl(src)
|
||
if (vimeo) return { kind: 'iframe', src: vimeo, title: 'Vimeo video' }
|
||
|
||
if (/\.(mp4|webm|ogg)(?:[?#].*)?$/i.test(src)) return { kind: 'video', src }
|
||
return { kind: 'link', src }
|
||
}
|
||
|
||
function MarkdownInline({ text }) {
|
||
const parts = []
|
||
const pattern = /(\[([^\]]+)\]\(([^)]+)\))|(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)/g
|
||
let lastIndex = 0
|
||
let match = pattern.exec(text)
|
||
|
||
while (match) {
|
||
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index))
|
||
|
||
if (match[2] && match[3]) {
|
||
const href = safeMarkdownLink(match[3])
|
||
parts.push(
|
||
<a
|
||
className="font-semibold text-fury-cyan underline-offset-4 hover:underline"
|
||
href={href}
|
||
key={`link-${match.index}`}
|
||
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||
target={href.startsWith('http') ? '_blank' : undefined}
|
||
>
|
||
{match[2]}
|
||
</a>,
|
||
)
|
||
} else if (match[5]) {
|
||
parts.push(
|
||
<code className="rounded bg-surface px-1.5 py-0.5 text-[0.9em] text-text" key={`code-${match.index}`}>
|
||
{match[5]}
|
||
</code>,
|
||
)
|
||
} else if (match[7]) {
|
||
parts.push(<strong key={`strong-${match.index}`}>{match[7]}</strong>)
|
||
} else if (match[9]) {
|
||
parts.push(<em key={`em-${match.index}`}>{match[9]}</em>)
|
||
}
|
||
|
||
lastIndex = pattern.lastIndex
|
||
match = pattern.exec(text)
|
||
}
|
||
|
||
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
|
||
return parts
|
||
}
|
||
|
||
function MarkdownContent({ markdown }) {
|
||
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n')
|
||
const blocks = []
|
||
let index = 0
|
||
|
||
while (index < lines.length) {
|
||
const line = lines[index]
|
||
if (!line.trim()) {
|
||
index += 1
|
||
continue
|
||
}
|
||
|
||
if (line.startsWith('```')) {
|
||
const language = line.slice(3).trim()
|
||
const code = []
|
||
index += 1
|
||
while (index < lines.length && !lines[index].startsWith('```')) {
|
||
code.push(lines[index])
|
||
index += 1
|
||
}
|
||
index += 1
|
||
blocks.push({ type: 'code', language, text: code.join('\n') })
|
||
continue
|
||
}
|
||
|
||
const mediaEmbed = line.match(/^@\[([^\]]*)\]\(([^)]+)\)$/)
|
||
if (mediaEmbed) {
|
||
blocks.push({ type: 'media', label: mediaEmbed[1].trim(), url: mediaEmbed[2].trim() })
|
||
index += 1
|
||
continue
|
||
}
|
||
|
||
const image = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
|
||
if (image) {
|
||
blocks.push({ type: 'image', alt: image[1].trim(), src: image[2].trim() })
|
||
index += 1
|
||
continue
|
||
}
|
||
|
||
const heading = line.match(/^(#{1,3})\s+(.+)$/)
|
||
if (heading) {
|
||
blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] })
|
||
index += 1
|
||
continue
|
||
}
|
||
|
||
if (/^>\s?/.test(line)) {
|
||
const quote = []
|
||
while (index < lines.length && /^>\s?/.test(lines[index])) {
|
||
quote.push(lines[index].replace(/^>\s?/, ''))
|
||
index += 1
|
||
}
|
||
blocks.push({ type: 'quote', text: quote.join(' ') })
|
||
continue
|
||
}
|
||
|
||
if (/^[-*]\s+/.test(line)) {
|
||
const items = []
|
||
while (index < lines.length && /^[-*]\s+/.test(lines[index])) {
|
||
items.push(lines[index].replace(/^[-*]\s+/, ''))
|
||
index += 1
|
||
}
|
||
blocks.push({ type: 'list', ordered: false, items })
|
||
continue
|
||
}
|
||
|
||
if (/^\d+\.\s+/.test(line)) {
|
||
const items = []
|
||
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) {
|
||
items.push(lines[index].replace(/^\d+\.\s+/, ''))
|
||
index += 1
|
||
}
|
||
blocks.push({ type: 'list', ordered: true, items })
|
||
continue
|
||
}
|
||
|
||
const paragraph = [line.trim()]
|
||
index += 1
|
||
while (
|
||
index < lines.length &&
|
||
lines[index].trim() &&
|
||
!lines[index].startsWith('```') &&
|
||
!/^@\[([^\]]*)\]\(([^)]+)\)$/.test(lines[index]) &&
|
||
!/^!\[([^\]]*)\]\(([^)]+)\)$/.test(lines[index]) &&
|
||
!/^(#{1,3})\s+/.test(lines[index]) &&
|
||
!/^>\s?/.test(lines[index]) &&
|
||
!/^[-*]\s+/.test(lines[index]) &&
|
||
!/^\d+\.\s+/.test(lines[index])
|
||
) {
|
||
paragraph.push(lines[index].trim())
|
||
index += 1
|
||
}
|
||
blocks.push({ type: 'paragraph', text: paragraph.join(' ') })
|
||
}
|
||
|
||
return (
|
||
<div className="blog-content mt-8 grid gap-5 text-base leading-8 text-text-soft">
|
||
{blocks.map((block, blockIndex) => {
|
||
if (block.type === 'heading') {
|
||
const className = block.level === 1
|
||
? 'mt-4 text-3xl font-bold leading-tight text-text'
|
||
: block.level === 2
|
||
? 'mt-3 text-2xl font-bold leading-tight text-text'
|
||
: 'mt-2 text-xl font-semibold leading-tight text-text'
|
||
const HeadingTag = `h${Math.min(block.level + 1, 4)}`
|
||
return (
|
||
<HeadingTag className={className} key={`heading-${blockIndex}`}>
|
||
<MarkdownInline text={block.text} />
|
||
</HeadingTag>
|
||
)
|
||
}
|
||
if (block.type === 'code') {
|
||
return (
|
||
<pre className="overflow-x-auto rounded-lg border border-border bg-surface p-4 text-sm leading-6 text-text" key={`code-${blockIndex}`}>
|
||
<code>{block.text}</code>
|
||
</pre>
|
||
)
|
||
}
|
||
if (block.type === 'image') {
|
||
const src = safeMediaSource(block.src)
|
||
if (!src) return null
|
||
return (
|
||
<figure className="overflow-hidden rounded-lg border border-border bg-surface" key={`image-${blockIndex}`}>
|
||
<img
|
||
alt={block.alt}
|
||
className="block max-h-[560px] w-full object-contain"
|
||
loading="lazy"
|
||
src={src}
|
||
/>
|
||
{block.alt ? (
|
||
<figcaption className="border-t border-border px-4 py-2 text-sm text-text-soft">
|
||
{block.alt}
|
||
</figcaption>
|
||
) : null}
|
||
</figure>
|
||
)
|
||
}
|
||
if (block.type === 'media') {
|
||
const embed = mediaEmbedFor(block.url)
|
||
if (embed.kind === 'iframe') {
|
||
return (
|
||
<div className="aspect-video overflow-hidden rounded-lg border border-border bg-surface shadow-sm" key={`media-${blockIndex}`}>
|
||
<iframe
|
||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||
allowFullScreen
|
||
className="h-full w-full"
|
||
loading="lazy"
|
||
referrerPolicy="strict-origin-when-cross-origin"
|
||
src={embed.src}
|
||
title={block.label || embed.title}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
if (embed.kind === 'video') {
|
||
return (
|
||
<video
|
||
className="aspect-video w-full rounded-lg border border-border bg-surface shadow-sm"
|
||
controls
|
||
key={`media-${blockIndex}`}
|
||
preload="metadata"
|
||
src={embed.src}
|
||
>
|
||
<a href={embed.src}>Open video</a>
|
||
</video>
|
||
)
|
||
}
|
||
if (embed.kind === 'link') {
|
||
return (
|
||
<p className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-text-soft" key={`media-${blockIndex}`}>
|
||
Media link:{' '}
|
||
<a
|
||
className="font-semibold text-fury-cyan underline-offset-4 hover:underline"
|
||
href={embed.src}
|
||
rel="noopener noreferrer"
|
||
target="_blank"
|
||
>
|
||
{block.label || embed.src}
|
||
</a>
|
||
</p>
|
||
)
|
||
}
|
||
return null
|
||
}
|
||
if (block.type === 'quote') {
|
||
return (
|
||
<blockquote className="border-l-4 border-fury-cyan bg-surface px-4 py-3 font-semibold text-text" key={`quote-${blockIndex}`}>
|
||
<MarkdownInline text={block.text} />
|
||
</blockquote>
|
||
)
|
||
}
|
||
if (block.type === 'list') {
|
||
const ListTag = block.ordered ? 'ol' : 'ul'
|
||
return (
|
||
<ListTag className={`${block.ordered ? 'list-decimal' : 'list-disc'} grid gap-2 pl-6`} key={`list-${blockIndex}`}>
|
||
{block.items.map((item, itemIndex) => (
|
||
<li key={`${blockIndex}-${itemIndex}`}>
|
||
<MarkdownInline text={item} />
|
||
</li>
|
||
))}
|
||
</ListTag>
|
||
)
|
||
}
|
||
return (
|
||
<p key={`paragraph-${blockIndex}`}>
|
||
<MarkdownInline text={block.text} />
|
||
</p>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BlogPage({ navigate, posts }) {
|
||
return (
|
||
<section className="mx-auto max-w-5xl 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">
|
||
Blog
|
||
</p>
|
||
<h1 className="mt-2 text-4xl font-bold">News and updates</h1>
|
||
<p className="mt-3 max-w-2xl text-text-soft">
|
||
Updates from the TSS bot, posted from Markdown files in the repo.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-8 grid gap-4">
|
||
{posts.map((post) => (
|
||
<article className="rounded-lg border border-border bg-fury-white p-5 shadow-sm" key={post.slug}>
|
||
<button
|
||
className="block w-full text-left"
|
||
onClick={() => navigate(post.path)}
|
||
type="button"
|
||
>
|
||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||
{formatBlogDate(post.date)}
|
||
</p>
|
||
<h2 className="mt-2 text-2xl font-bold text-text">{post.title}</h2>
|
||
{post.excerpt ? (
|
||
<p className="mt-3 text-sm leading-6 text-text-soft">{post.excerpt}</p>
|
||
) : null}
|
||
<span className="mt-4 inline-block text-sm font-semibold text-fury-cyan">
|
||
Read post
|
||
</span>
|
||
</button>
|
||
</article>
|
||
))}
|
||
|
||
{!posts.length ? (
|
||
<p className="rounded-lg border border-border bg-fury-white px-5 py-10 text-sm text-text-soft shadow-sm">
|
||
No posts yet. Add a Markdown file to frontend/src/blog and rebuild.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function BlogPostPage({ navigate, post }) {
|
||
if (!post) {
|
||
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">Blog</p>
|
||
<h1 className="mt-2 text-4xl font-bold">Post not found</h1>
|
||
<p className="mt-3 text-text-soft">That blog post does not exist.</p>
|
||
</div>
|
||
<button
|
||
className="mt-8 rounded-lg bg-text px-4 py-2 text-sm font-semibold text-bg apricot-button-text"
|
||
onClick={() => navigate('/blog')}
|
||
type="button"
|
||
>
|
||
Back to blog
|
||
</button>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<article className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
|
||
<button
|
||
className="mb-6 text-sm font-semibold text-fury-cyan transition hover:text-text"
|
||
onClick={() => navigate('/blog')}
|
||
type="button"
|
||
>
|
||
Back to blog
|
||
</button>
|
||
<header className="border-b border-border pb-6">
|
||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||
{formatBlogDate(post.date)}
|
||
</p>
|
||
<h1 className="mt-2 text-4xl font-bold leading-tight">{post.title}</h1>
|
||
{post.author ? <p className="mt-3 text-text-soft">By {post.author}</p> : null}
|
||
</header>
|
||
<MarkdownContent markdown={post.content} />
|
||
</article>
|
||
)
|
||
}
|
||
|
||
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)
|
||
const latestBlogPost = blogPosts[0]
|
||
|
||
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 lg:translate-y-[5px]">
|
||
<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' 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-text bg-fury-white px-5 py-4 text-base font-semibold text-text transition hover:bg-surface"
|
||
onClick={() => navigate('/players')}
|
||
type="button"
|
||
>
|
||
Player 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>
|
||
</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((result) => (
|
||
<option
|
||
key={`${result.kind}-${result.uid || result.name}`}
|
||
label={result.detail}
|
||
value={result.value}
|
||
/>
|
||
))}
|
||
</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
|
||
</button>
|
||
</form>
|
||
|
||
<button
|
||
className="mt-4 grid w-full gap-3 border-l-2 border-fury-cyan bg-fury-white/80 px-4 py-3 text-left shadow-sm backdrop-blur transition hover:bg-surface sm:grid-cols-[1fr_auto] sm:items-center"
|
||
onClick={() => navigate(latestBlogPost?.path || '/blog')}
|
||
type="button"
|
||
>
|
||
<span className="min-w-0">
|
||
<span className="block text-xs font-semibold uppercase tracking-wide text-fury-cyan">
|
||
Latest news
|
||
</span>
|
||
<span className="mt-1 block truncate text-sm font-semibold text-text">
|
||
{latestBlogPost?.title || 'News and updates'}
|
||
</span>
|
||
<span className="mt-1 block truncate text-xs text-text-soft">
|
||
{latestBlogPost?.excerpt || 'Read the latest notes from Toothless TSS Bot.'}
|
||
</span>
|
||
</span>
|
||
<span className="text-sm font-semibold text-fury-cyan">
|
||
{latestBlogPost ? 'Read post' : 'Open blog'}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative min-h-[500px] overflow-hidden">
|
||
<FallingLeaves treeRef={treeRef} />
|
||
<div className="absolute inset-0 z-[1] flex items-end justify-center">
|
||
<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)
|
||
const themeSyncedRef = useRef(false)
|
||
|
||
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
|
||
if (!themeSyncedRef.current) return theme
|
||
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()
|
||
themeSyncedRef.current = true
|
||
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}
|
||
>
|
||
<PixelSun className="pixel-sun" aria-hidden="true" />
|
||
<span className="pixel-moon-group">
|
||
<PixelMoon className="pixel-moon" aria-hidden="true" />
|
||
<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 PlayersPage({ leaderboard, navigate, players }) {
|
||
return (
|
||
<section className="space-y-6 pt-24 sm:pt-28">
|
||
<div>
|
||
<h1 className="text-3xl font-bold">Player Leaderboard</h1>
|
||
<p className="mt-2 text-sm text-text-soft">
|
||
{leaderboard.status === 'loading'
|
||
? 'Loading player leaderboard'
|
||
: leaderboard.error || `${players.length} players returned`}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||
<div className="overflow-x-auto">
|
||
<div className="min-w-[900px]">
|
||
{players.length ? (
|
||
<div className="grid grid-cols-[4rem_minmax(220px,1fr)_repeat(7,92px)] gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||
<p>Rank</p>
|
||
<p>Player</p>
|
||
<p className="text-right">Score</p>
|
||
<p className="text-right">Battles</p>
|
||
<p className="text-right">Kills</p>
|
||
<p className="text-right">Assists</p>
|
||
<p className="text-right">WR</p>
|
||
<p className="text-right">KDR</p>
|
||
<p className="text-right">Teams</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{players.map((player, index) => (
|
||
<button
|
||
className="grid w-full grid-cols-[4rem_minmax(220px,1fr)_repeat(7,92px)] gap-3 border-b border-surface px-5 py-4 text-left text-sm transition hover:bg-surface"
|
||
key={player.uid}
|
||
onClick={() => navigate(playerPath(player.uid))}
|
||
type="button"
|
||
>
|
||
<p className="font-semibold text-fury-cyan">#{index + 1}</p>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-base font-semibold">{player.nick || player.uid}</p>
|
||
<p className="truncate text-xs text-text-soft">
|
||
{player.uid} · last seen {formatDate(player.last_seen)}
|
||
</p>
|
||
</div>
|
||
<p className="text-right font-semibold">{formatNumber(player.score)}</p>
|
||
<p className="text-right">{formatNumber(player.total_battles)}</p>
|
||
<p className="text-right">{formatNumber(player.total_kills)}</p>
|
||
<p className="text-right">{formatNumber(player.assists)}</p>
|
||
<p className="text-right">{Number(player.win_rate || 0).toFixed(1)}%</p>
|
||
<p className="text-right">{Number(player.kdr || 0).toFixed(2)}</p>
|
||
<p className="text-right">{formatNumber(player.teams_seen)}</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{!players.length ? (
|
||
<p className="px-5 py-10 text-sm text-text-soft">
|
||
{leaderboard.status === 'loading'
|
||
? 'Loading player leaderboard'
|
||
: leaderboard.error || 'No players 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-5">
|
||
<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="Win rate" value={`${Number(summary?.win_rate || 0).toFixed(1)}%`} />
|
||
<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))]'
|
||
|
||
// Battle-log lines are prefixed +/-/<space> for winner/loser/neither (matching
|
||
// the Discord diff format), so colour each line by its acting team.
|
||
function battleLineColor(line) {
|
||
if (line.startsWith('+')) return 'text-win'
|
||
if (line.startsWith('-')) return 'text-loss'
|
||
return 'text-text-soft'
|
||
}
|
||
|
||
function formatLogTime(ms) {
|
||
const totalSeconds = Math.floor(Number(ms || 0) / 1000)
|
||
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0')
|
||
const seconds = String(totalSeconds % 60).padStart(2, '0')
|
||
return `${minutes}:${seconds}`
|
||
}
|
||
|
||
function deadVehicleKey(uid, cdk) {
|
||
return `${String(uid || '').trim()}:${String(cdk || '').trim()}`
|
||
}
|
||
|
||
function deadVehicleKeysFromEventLog(eventLog) {
|
||
const keys = new Set()
|
||
const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : []
|
||
kills.forEach((kill) => {
|
||
const uid = kill?.offended_uid
|
||
const cdk = kill?.offended_unit
|
||
if (uid !== undefined && uid !== null && cdk) {
|
||
keys.add(deadVehicleKey(uid, cdk))
|
||
}
|
||
})
|
||
return keys
|
||
}
|
||
|
||
function logLookups(participants) {
|
||
const players = new Map()
|
||
;(participants || []).forEach((participant) => {
|
||
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
||
;(participant.players || []).forEach((player) => {
|
||
const vehicles = new Map()
|
||
;(player.vehicles || []).forEach((vehicle) => {
|
||
vehicles.set(String(vehicle.cdk || ''), vehicle.name || vehicle.cdk || 'Unknown')
|
||
})
|
||
players.set(String(player.uid), {
|
||
name: player.nick || player.uid,
|
||
team: participant.team_name || '',
|
||
result,
|
||
className: result === 'win' ? 'text-win' : 'text-loss',
|
||
vehicles,
|
||
})
|
||
})
|
||
})
|
||
return players
|
||
}
|
||
|
||
function logNameLookups(participants) {
|
||
const players = new Map()
|
||
;(participants || []).forEach((participant) => {
|
||
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
||
;(participant.players || []).forEach((player) => {
|
||
const name = String(player.nick || '').trim()
|
||
if (!name) return
|
||
players.set(name.toLowerCase(), {
|
||
name,
|
||
team: participant.team_name || '',
|
||
result,
|
||
className: result === 'win' ? 'text-win' : 'text-loss',
|
||
})
|
||
})
|
||
})
|
||
return players
|
||
}
|
||
|
||
function logPlayer(players, uid) {
|
||
return players.get(String(uid)) || {
|
||
name: uid === undefined || uid === null ? 'Unknown' : `Player#${uid}`,
|
||
team: '',
|
||
result: '',
|
||
className: 'text-text-soft',
|
||
vehicles: new Map(),
|
||
}
|
||
}
|
||
|
||
function logVehicle(player, cdk) {
|
||
if (!cdk) return 'Unknown'
|
||
return player.vehicles.get(String(cdk)) || String(cdk)
|
||
}
|
||
|
||
function structuredBattleEvents(eventLog) {
|
||
const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : []
|
||
const damage = Array.isArray(eventLog?.damage) ? eventLog.damage : []
|
||
return [
|
||
...kills.map((event) => ({ ...event, kind: 'kill' })),
|
||
...damage.map((event) => ({ ...event, kind: 'damage' })),
|
||
].sort((a, b) => Number(a.time || 0) - Number(b.time || 0))
|
||
}
|
||
|
||
function chatTypeClass(type, senderClassName) {
|
||
return String(type || 'ALL').toUpperCase() === 'ALL' ? 'text-warning' : senderClassName
|
||
}
|
||
|
||
function parseFormattedChatLine(line) {
|
||
const match = String(line || '').match(/^([+-]?)(\[\d{2}:\d{2}\])\s+\[([^\]]+)\]\s+\[[^\]]*\]\s+`([^`]*)`:\s?(.*)$/)
|
||
if (!match) return null
|
||
return {
|
||
prefix: match[1],
|
||
time: match[2],
|
||
type: match[3],
|
||
name: match[4],
|
||
message: match[5],
|
||
}
|
||
}
|
||
|
||
function GamePage({ gameId, navigate }) {
|
||
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
|
||
const [logs, setLogs] = useState({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
||
|
||
useEffect(() => {
|
||
if (!gameId) return
|
||
|
||
const controller = new AbortController()
|
||
setGameState({ status: 'loading', data: null, error: null })
|
||
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
||
|
||
fetchPublicJson(publicDataSources.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 })
|
||
}
|
||
})
|
||
|
||
fetchPublicJson(publicDataSources.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 : [],
|
||
event_log: data?.event_log || { kills: [], damage: [], chat: [] },
|
||
})
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Logs are non-critical; leave them empty on failure.
|
||
})
|
||
|
||
return () => controller.abort()
|
||
}, [gameId])
|
||
|
||
const game = gameState.data?.game
|
||
const participants = useMemo(() => gameState.data?.participants || [], [gameState.data?.participants])
|
||
const deadVehicleKeys = useMemo(() => deadVehicleKeysFromEventLog(logs.event_log), [logs.event_log])
|
||
const playersByUid = useMemo(() => logLookups(participants), [participants])
|
||
const playersByName = useMemo(() => logNameLookups(participants), [participants])
|
||
const battleEvents = useMemo(() => structuredBattleEvents(logs.event_log), [logs.event_log])
|
||
const chatEvents = Array.isArray(logs.event_log?.chat) ? logs.event_log.chat : []
|
||
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 gap-y-1">
|
||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||
Game — <span className="break-all text-text-muted opacity-70">{game?.session_id || gameId}</span>
|
||
</p>
|
||
{game?.tournament_id ? (
|
||
<button
|
||
className="text-sm font-semibold uppercase tracking-wide text-fury-violet transition hover:text-text"
|
||
onClick={() => navigate(tournamentPath(game.tournament_id))}
|
||
type="button"
|
||
>
|
||
Tournament — {game.tournament_id}
|
||
</button>
|
||
) : null}
|
||
</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 ? (
|
||
game?.tournament_id ? (
|
||
<button
|
||
className="mt-1 block text-left text-sm font-medium text-fury-violet underline-offset-2 transition hover:text-text hover:underline"
|
||
onClick={() => navigate(tournamentPath(game.tournament_id))}
|
||
type="button"
|
||
>
|
||
{subtitle}
|
||
</button>
|
||
) : (
|
||
<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(playerPath(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) => {
|
||
const isDead = deadVehicleKeys.has(deadVehicleKey(player.uid, vehicle.cdk))
|
||
return (
|
||
<span
|
||
className={`vehicle-name inline-flex items-center gap-1 text-xs text-text-soft transition-opacity ${isDead ? 'vehicle-dead' : ''}`}
|
||
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>
|
||
|
||
<ReplayCanvasPanel gameId={gameId} />
|
||
|
||
{battleEvents.length || 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>
|
||
<div className="log-mono overflow-x-auto px-5 py-3 text-xs leading-relaxed">
|
||
{battleEvents.length ? (
|
||
battleEvents.map((event, i) => (
|
||
<BattleEventLine event={event} key={`${event.kind}-${event.time}-${i}`} players={playersByUid} />
|
||
))
|
||
) : (
|
||
logs.battle_log.map((line, i) => (
|
||
<div className={`whitespace-pre ${battleLineColor(line)}`} key={i}>{line}</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
|
||
{chatEvents.length || 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>
|
||
<div className="log-mono overflow-x-auto px-5 py-3 text-xs leading-relaxed">
|
||
{chatEvents.length ? (
|
||
chatEvents.map((event, i) => (
|
||
<ChatEventLine event={event} key={`${event.time}-${event.uid}-${i}`} players={playersByUid} />
|
||
))
|
||
) : (
|
||
logs.chat_log.map((line, i) => (
|
||
<FormattedChatLine key={i} line={line} players={playersByName} />
|
||
))
|
||
)}
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function FormattedChatLine({ line, players }) {
|
||
const parsed = parseFormattedChatLine(line)
|
||
if (!parsed) {
|
||
return <div className={`whitespace-pre-wrap ${battleLineColor(line)}`}>{line}</div>
|
||
}
|
||
|
||
const player = players.get(parsed.name.toLowerCase())
|
||
const className = player?.className || battleLineColor(parsed.prefix)
|
||
const team = player?.team || '??'
|
||
const name = player?.name || parsed.name
|
||
const typeClassName = chatTypeClass(parsed.type, className)
|
||
|
||
return (
|
||
<div className="whitespace-pre-wrap">
|
||
<span className="text-text-soft">{parsed.time} </span>
|
||
<span className={typeClassName}>[{parsed.type}]</span>
|
||
<span className={className}> [{team}] `{name}`: {parsed.message}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BattleEventLine({ event, players }) {
|
||
const offender = logPlayer(players, event.offender_uid)
|
||
const victim = logPlayer(players, event.offended_uid)
|
||
const ts = formatLogTime(event.time)
|
||
const victimLabel = `[${victim.team}] ${victim.name} (${logVehicle(victim, event.offended_unit)})`
|
||
|
||
if (event.kind === 'kill' && (event.crashed || event.offender_uid === undefined || event.offender_uid === null)) {
|
||
return (
|
||
<div className="whitespace-pre">
|
||
<span className="text-text-soft">[{ts}] </span>
|
||
<span className={victim.className}>[{victim.team}] {victimLabel}</span>
|
||
<span className="text-text-soft"> crashed</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const offenderLabel = `${offender.name} (${logVehicle(offender, event.offender_unit)})`
|
||
const action = event.kind === 'kill' ? 'destroyed' : `damaged ${event.afire ? '(FIRE) ' : ''}`
|
||
|
||
return (
|
||
<div className="whitespace-pre">
|
||
<span className="text-text-soft">[{ts}] </span>
|
||
<span className={offender.className}>[{offender.team}] {offenderLabel}</span>
|
||
<span className="text-text-soft"> {action} </span>
|
||
<span className={victim.className}>{victimLabel}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChatEventLine({ event, players }) {
|
||
const player = logPlayer(players, event.uid)
|
||
const type = event.type || 'ALL'
|
||
const typeClassName = chatTypeClass(type, player.className)
|
||
return (
|
||
<div className="whitespace-pre-wrap">
|
||
<span className="text-text-soft">[{formatLogTime(event.time)}] </span>
|
||
<span className={typeClassName}>[{type}]</span>
|
||
<span className={player.className}> [{player.team}] `{player.name}`: {event.message || ''}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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-x-6 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)_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)} spread />
|
||
<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 formatUnixDate(seconds) {
|
||
return seconds ? formatDate(seconds) : 'Unknown'
|
||
}
|
||
|
||
function tournamentDateRange(first, last) {
|
||
if (!first && !last) return 'No dates'
|
||
const a = formatUnixDate(first)
|
||
const b = formatUnixDate(last)
|
||
return a === b ? a : `${a} – ${b}`
|
||
}
|
||
|
||
function tournamentFormatMeta(format, matches = []) {
|
||
const raw = String(format || '').toLowerCase()
|
||
const sides = matches.map((match) => String(match.side || match.type_bracket || '').toLowerCase())
|
||
const hasSide = (needle) => sides.some((side) => side.includes(needle))
|
||
const hasGroup = raw === 'group' || hasSide('group')
|
||
const hasSwiss = raw === 'swiss' || hasSide('swiss')
|
||
const hasLoser = raw === 'double-elim' || hasSide('loser') || hasSide('looser')
|
||
const hasElim = hasLoser || raw === 'single-elim' || hasSide('winner') || hasSide('final')
|
||
|
||
if ((hasGroup || hasSwiss) && hasElim) {
|
||
return { label: hasSwiss ? 'Swiss + playoffs' : 'Group stage + playoffs', mode: 'mixed' }
|
||
}
|
||
if (hasSwiss) return { label: 'Swiss', mode: 'standings' }
|
||
if (hasGroup) return { label: 'Group stage', mode: 'standings' }
|
||
if (hasLoser) return { label: 'Double elimination', mode: 'bracket' }
|
||
if (raw === 'single-elim' || hasElim) return { label: 'Single elimination', mode: 'bracket' }
|
||
return { label: 'Matches', mode: 'matches' }
|
||
}
|
||
|
||
function tournamentStatusLabel(status) {
|
||
const raw = String(status || '').trim()
|
||
if (!raw) return 'Unknown'
|
||
return raw.charAt(0).toUpperCase() + raw.slice(1)
|
||
}
|
||
|
||
function TournamentsPage({ navigate }) {
|
||
const [state, setState] = useState({ status: 'loading', data: null, error: null })
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
setState({ status: 'loading', data: null, error: null })
|
||
fetchJson(apiEndpoints.tournaments, controller.signal)
|
||
.then((data) => setState({ status: 'ready', data, error: null }))
|
||
.catch((error) => {
|
||
if (!controller.signal.aborted) {
|
||
setState({ status: 'error', data: null, error: error.message })
|
||
}
|
||
})
|
||
return () => controller.abort()
|
||
}, [])
|
||
|
||
const tournaments = state.data?.tournaments || []
|
||
|
||
return (
|
||
<section className="space-y-6 pt-24 sm:pt-28">
|
||
<div>
|
||
<h1 className="text-3xl font-bold">Tournaments</h1>
|
||
<p className="mt-2 text-sm text-text-soft">
|
||
{state.status === 'loading'
|
||
? 'Loading tournaments'
|
||
: state.error || `${tournaments.length} tournaments returned`}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||
{tournaments.map((tournament) => (
|
||
<button
|
||
className="grid w-full gap-x-4 gap-y-1 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_repeat(3,auto)] md:items-center"
|
||
key={tournament.tournament_id}
|
||
onClick={() => navigate(tournamentPath(tournament.tournament_id))}
|
||
type="button"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<p className="truncate text-lg font-semibold">
|
||
{tournament.name || `Tournament ${tournament.tournament_id}`}
|
||
</p>
|
||
<span className="shrink-0 rounded bg-surface px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-fury-violet">
|
||
{tournamentFormatMeta(tournament.format).label}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-text-soft">
|
||
TID {tournament.tournament_id} · {tournamentDateRange(tournament.date_start, tournament.date_end)}
|
||
</p>
|
||
</div>
|
||
<span className="text-sm">{formatNumber(tournament.match_count)} matches</span>
|
||
<span className="text-sm">{formatNumber(tournament.team_count)} teams</span>
|
||
<span className="text-sm font-semibold text-fury-cyan">View</span>
|
||
</button>
|
||
))}
|
||
|
||
{!tournaments.length ? (
|
||
<p className="px-5 py-10 text-sm text-text-soft">
|
||
{state.status === 'loading' ? 'Loading tournaments' : 'No tournaments returned'}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function TournamentMatchCard({ match, navigate, onHover }) {
|
||
const winner = displayTeamName(match.winner_name).toLowerCase()
|
||
const teamA = displayTeamName(match.team_a_name)
|
||
const teamB = displayTeamName(match.team_b_name)
|
||
const aWon = winner && teamA && winner === teamA.toLowerCase()
|
||
const bWon = winner && teamB && winner === teamB.toLowerCase()
|
||
const decided = Boolean(winner)
|
||
const battles = Array.isArray(match.battles) ? match.battles : []
|
||
const fire = onHover || (() => {})
|
||
const lastKey = useRef('')
|
||
|
||
// Hover a team name → light just that team's run (green). Hover anywhere else on
|
||
// the card → light both teams' runs (winner green, loser red). A registered team
|
||
// that forfeited (technical walkover) shows gold rather than red.
|
||
const matchHighlight = () => {
|
||
const map = {}
|
||
if (teamA) map[teamA.toLowerCase()] = !decided || aWon ? 'win' : 'loss'
|
||
if (teamB) map[teamB.toLowerCase()] = !decided || bWon ? 'win' : 'loss'
|
||
return Object.keys(map).length ? map : null
|
||
}
|
||
const handleOver = (event) => {
|
||
const teamEl = event.target.closest('[data-team]')
|
||
const map = teamEl ? { [teamEl.dataset.team.toLowerCase()]: 'win' } : matchHighlight()
|
||
const key = map ? Object.entries(map).map(([k, v]) => `${k}:${v}`).sort().join('|') : ''
|
||
if (key === lastKey.current) return
|
||
lastKey.current = key
|
||
fire(map)
|
||
}
|
||
const handleLeave = () => {
|
||
lastKey.current = ''
|
||
fire(null)
|
||
}
|
||
|
||
const teamRow = (name, score, won, emptyLabel = 'TBD') => {
|
||
const lost = decided && name && !won
|
||
const noShow = lost && match.status === 'technical'
|
||
const nameColor = won ? 'text-win' : noShow ? 'text-fury-violet' : lost ? 'text-loss' : 'text-text'
|
||
const scoreColor = won ? 'text-win' : noShow ? 'text-fury-violet' : lost ? 'text-loss' : 'text-text-soft'
|
||
return (
|
||
<div className="flex items-center justify-between gap-2">
|
||
{name ? (
|
||
<button
|
||
className={`min-w-0 truncate text-left font-semibold transition hover:underline ${nameColor}`}
|
||
data-team={name}
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
if (bracketPan.dragged) return
|
||
navigate(teamPath(name))
|
||
}}
|
||
type="button"
|
||
>
|
||
{name}
|
||
</button>
|
||
) : (
|
||
<span className="min-w-0 truncate font-semibold text-text-muted">{emptyLabel}</span>
|
||
)}
|
||
<span className={`shrink-0 tabular-nums ${scoreColor}`}>{formatNumber(score)}</span>
|
||
</div>
|
||
)
|
||
}
|
||
const emptyLabel = match.status === 'bye' ? 'BYE' : 'TBD'
|
||
|
||
return (
|
||
<div
|
||
className="rounded-md border border-border bg-bg p-2.5 text-sm shadow-sm"
|
||
onMouseLeave={handleLeave}
|
||
onMouseOver={handleOver}
|
||
>
|
||
{teamRow(teamA, match.score_a, aWon, emptyLabel)}
|
||
<div className="mt-1">{teamRow(teamB, match.score_b, bWon, emptyLabel)}</div>
|
||
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
|
||
<span>{tournamentStatusLabel(match.status)}</span>
|
||
{match.position !== null && match.position !== undefined ? <span>Slot {Number(match.position) + 1}</span> : null}
|
||
</div>
|
||
{battles.length ? (
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{battles.map((battle, index) => (
|
||
battle.have_replay ? (
|
||
<button
|
||
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-soft transition hover:text-text"
|
||
key={battle.session_hex}
|
||
onClick={() => {
|
||
if (bracketPan.dragged) return
|
||
navigate(gamePath(battle.session_hex))
|
||
}}
|
||
title={`Game ${index + 1} — view replay`}
|
||
type="button"
|
||
>
|
||
G{index + 1}
|
||
</button>
|
||
) : (
|
||
<span
|
||
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted opacity-70"
|
||
key={battle.session_hex}
|
||
title={`Game ${index + 1} — no replay held`}
|
||
>
|
||
G{index + 1}
|
||
</span>
|
||
)
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Shared across the bracket: set true while a pan-drag is in progress so the
|
||
// team/replay buttons inside the canvas don't fire a navigation on release.
|
||
const bracketPan = { dragged: false }
|
||
|
||
function teamsOf(match) {
|
||
return [match?.team_a_name, match?.team_b_name]
|
||
.map((n) => displayTeamName(n).toLowerCase())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function byeAdvanceNames(match) {
|
||
return [match?.team_a_name, match?.team_b_name]
|
||
.map(displayTeamName)
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function noOpponentTooltip(match, roundLabel) {
|
||
const names = byeAdvanceNames(match)
|
||
const teamText = names.length > 1
|
||
? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||
: names[0] || 'this team'
|
||
const opener = names.length > 1
|
||
? 'One or more teams did not confirm in time'
|
||
: 'A team did not confirm in time'
|
||
return `${opener}, so Gaijin automatically advanced ${teamText} to ${roundLabel}.`
|
||
}
|
||
|
||
// Drag-anywhere pan surface. A tap still clicks; only motion past a small
|
||
// threshold pans (and suppresses the click). Move/up live on the window so a fast
|
||
// drag keeps panning even when the cursor leaves the canvas.
|
||
function BracketViewport({ children }) {
|
||
const ref = useRef(null)
|
||
|
||
const onPointerDown = (event) => {
|
||
const el = ref.current
|
||
if (!el || event.button !== 0) return
|
||
const start = { x: event.clientX, y: event.clientY, sl: el.scrollLeft, st: el.scrollTop, moved: false }
|
||
bracketPan.dragged = false
|
||
|
||
const onMove = (e) => {
|
||
const dx = e.clientX - start.x
|
||
const dy = e.clientY - start.y
|
||
if (!start.moved && Math.hypot(dx, dy) > 5) {
|
||
start.moved = true
|
||
bracketPan.dragged = true
|
||
el.classList.add('is-grabbing')
|
||
// Drop any text selection the initial press may have started.
|
||
window.getSelection()?.removeAllRanges()
|
||
}
|
||
if (start.moved) {
|
||
el.scrollLeft = start.sl - dx
|
||
el.scrollTop = start.st - dy
|
||
e.preventDefault()
|
||
}
|
||
}
|
||
const onUp = () => {
|
||
el.classList.remove('is-grabbing')
|
||
window.removeEventListener('pointermove', onMove)
|
||
window.removeEventListener('pointerup', onUp)
|
||
// Keep `dragged` true through the click that fires right after pointerup so
|
||
// that click is suppressed, then clear it.
|
||
window.setTimeout(() => { bracketPan.dragged = false }, 0)
|
||
}
|
||
window.addEventListener('pointermove', onMove)
|
||
window.addEventListener('pointerup', onUp)
|
||
}
|
||
|
||
return (
|
||
<div className="bracket-viewport" onPointerDown={onPointerDown} ref={ref}>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// What colour, if any, a team should glow: the highlight map is { teamLower:
|
||
// 'win' | 'loss' }. Winner-side runs glow green, loser runs red.
|
||
function traceClass(teams, highlight) {
|
||
if (!highlight) return ''
|
||
let result = ''
|
||
for (const team of teams) {
|
||
const tone = highlight[team]
|
||
if (tone === 'win') return ' is-traced'
|
||
if (tone === 'loss') result = ' is-traced-loss'
|
||
}
|
||
return result
|
||
}
|
||
|
||
function TournamentBracketSide({ side, navigate, highlight, onHover }) {
|
||
const gridRef = useRef(null)
|
||
const nodeRefs = useRef(new Map())
|
||
// `tops` places each match; once placed we read back the DOM to draw connectors.
|
||
const [layout, setLayout] = useState({ tops: new Map(), height: 0 })
|
||
const [connectors, setConnectors] = useState({ width: 0, height: 0, lines: [], byes: [] })
|
||
const [collapsed, setCollapsed] = useState(false)
|
||
|
||
// Pass 1: measure card heights, then position every match centred on its feeders.
|
||
useLayoutEffect(() => {
|
||
const grid = gridRef.current
|
||
if (!grid) return undefined
|
||
const relayout = () => {
|
||
const heights = new Map()
|
||
for (const [key, el] of nodeRefs.current) heights.set(key, el.offsetHeight)
|
||
const next = computeBracketLayout(side.columns, heights)
|
||
setLayout({ tops: next.tops, height: next.height })
|
||
}
|
||
relayout()
|
||
const observer = new ResizeObserver(relayout)
|
||
observer.observe(grid)
|
||
return () => observer.disconnect()
|
||
}, [side, collapsed])
|
||
|
||
// Pass 2: with matches positioned, read real edges and draw elbow connectors.
|
||
// Each connector carries the teams on either end so a hovered team's whole run
|
||
// can be lit. Matches with no incoming connector (all feeders were byes) get a
|
||
// bye stub so they don't read as appearing from nowhere.
|
||
useLayoutEffect(() => {
|
||
const grid = gridRef.current
|
||
if (!grid) return
|
||
const gridBox = grid.getBoundingClientRect()
|
||
const boxOf = (key) => {
|
||
const el = nodeRefs.current.get(key)
|
||
if (!el) return null
|
||
const b = el.getBoundingClientRect()
|
||
return {
|
||
left: b.left - gridBox.left,
|
||
right: b.right - gridBox.left,
|
||
mid: b.top - gridBox.top + b.height / 2,
|
||
}
|
||
}
|
||
const lines = []
|
||
const fed = new Set()
|
||
for (let c = 0; c < side.columns.length - 1; c += 1) {
|
||
for (const match of side.columns[c].matches) {
|
||
const parent = feederParent(match, c, side.columns)
|
||
if (!parent) continue
|
||
const from = boxOf(layoutKey(c, match))
|
||
const to = boxOf(layoutKey(c + 1, parent))
|
||
if (!from || !to) continue
|
||
fed.add(parent.match_id)
|
||
const midX = (from.right + to.left) / 2
|
||
lines.push({
|
||
d: `M ${from.right} ${from.mid} H ${midX} V ${to.mid} H ${to.left}`,
|
||
teams: teamsOf(match).filter((t) => teamsOf(parent).includes(t)),
|
||
})
|
||
}
|
||
}
|
||
const byes = []
|
||
for (let c = 1; c < side.columns.length; c += 1) {
|
||
for (const match of side.columns[c].matches) {
|
||
if (fed.has(match.match_id)) continue
|
||
const box = boxOf(layoutKey(c, match))
|
||
if (box) {
|
||
byes.push({
|
||
x: box.left,
|
||
y: box.mid,
|
||
title: noOpponentTooltip(match, side.columns[c].label),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, lines, byes })
|
||
}, [side, layout, collapsed])
|
||
|
||
const active = Boolean(highlight)
|
||
|
||
return (
|
||
<div className="bracket-side">
|
||
<button
|
||
aria-expanded={!collapsed}
|
||
className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-fury-violet transition hover:text-text"
|
||
onClick={() => setCollapsed((value) => !value)}
|
||
type="button"
|
||
>
|
||
{side.label}
|
||
<span aria-hidden="true" className={`bracket-caret${collapsed ? ' is-collapsed' : ''}`}>▾</span>
|
||
</button>
|
||
{collapsed ? null : (
|
||
<BracketViewport>
|
||
<div className={`tournament-bracket-grid${active ? ' has-trace' : ''}`} ref={gridRef}>
|
||
<svg
|
||
aria-hidden="true"
|
||
className="tournament-bracket-lines"
|
||
height={connectors.height}
|
||
width={connectors.width}
|
||
>
|
||
{connectors.lines.map((line, index) => {
|
||
const lit = traceClass(line.teams, highlight)
|
||
return (
|
||
<g key={index}>
|
||
<path className={`bracket-line-base${lit}`} d={line.d} />
|
||
<path className={`bracket-line-flow${lit}`} d={line.d} />
|
||
</g>
|
||
)
|
||
})}
|
||
{connectors.byes.map((bye, index) => (
|
||
<g className="bracket-bye-hover" key={`bye-${index}`}>
|
||
<title>{bye.title}</title>
|
||
<path className="bracket-bye-hit" d={`M ${bye.x} ${bye.y} h -18`} />
|
||
<path className="bracket-bye-stub" d={`M ${bye.x} ${bye.y} h -18`} />
|
||
<text className="bracket-bye-tag" textAnchor="end" x={bye.x - 24} y={bye.y + 3}>
|
||
no opponent
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
{side.columns.map((column, columnIndex) => (
|
||
<div className="tournament-round-column" key={column.id}>
|
||
<p className="mb-3 text-center text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||
{column.label}
|
||
</p>
|
||
<div className="tournament-round-track" style={{ height: layout.height || undefined }}>
|
||
{column.matches.map((match) => {
|
||
const key = layoutKey(columnIndex, match)
|
||
const traced = traceClass(teamsOf(match), highlight)
|
||
return (
|
||
<div
|
||
className={`tournament-match-node${traced}`}
|
||
key={`${match.type_bracket}-${match.match_id}`}
|
||
ref={(el) => {
|
||
if (el) nodeRefs.current.set(key, el)
|
||
else nodeRefs.current.delete(key)
|
||
}}
|
||
style={{ top: layout.tops.get(key) ?? 0 }}
|
||
>
|
||
<TournamentMatchCard match={match} navigate={navigate} onHover={onHover} />
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</BracketViewport>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TournamentStandings({ standings }) {
|
||
const rows = Array.isArray(standings) ? standings : []
|
||
if (!rows.length) return null
|
||
const grouped = new Map()
|
||
rows.forEach((row) => {
|
||
const group = row.group_index ?? 0
|
||
if (!grouped.has(group)) grouped.set(group, [])
|
||
grouped.get(group).push(row)
|
||
})
|
||
return (
|
||
<div className="space-y-4">
|
||
{[...grouped.entries()].map(([group, groupRows]) => (
|
||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm" key={group}>
|
||
{grouped.size > 1 ? (
|
||
<p className="border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-fury-cyan">
|
||
Group {Number(group) + 1}
|
||
</p>
|
||
) : null}
|
||
<div className="grid grid-cols-[3rem_1fr_4rem_3rem_3rem_3rem_5rem] gap-2 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||
<p>#</p>
|
||
<p>Team</p>
|
||
<p className="text-center">Pts</p>
|
||
<p className="text-center">W</p>
|
||
<p className="text-center">D</p>
|
||
<p className="text-center">L</p>
|
||
<p className="text-center">Buch.</p>
|
||
</div>
|
||
{groupRows.map((row, index) => (
|
||
<div
|
||
className="grid grid-cols-[3rem_1fr_4rem_3rem_3rem_3rem_5rem] items-center gap-2 border-b border-surface px-5 py-2.5 text-sm"
|
||
key={`${group}-${row.team_name || index}`}
|
||
>
|
||
<span className="font-semibold text-fury-cyan">#{row.rank || index + 1}</span>
|
||
<span className="min-w-0 truncate font-semibold">{row.team_name || 'Unknown team'}</span>
|
||
<span className="text-center">{formatNumber(row.points)}</span>
|
||
<span className="text-center">{formatNumber(row.wins)}</span>
|
||
<span className="text-center">{formatNumber(row.draws)}</span>
|
||
<span className="text-center">{formatNumber(row.losses)}</span>
|
||
<span className="text-center">{formatNumber(row.buchholz)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TournamentMatchList({ sides, navigate }) {
|
||
return (
|
||
<div className="space-y-6">
|
||
{sides.map((side) => {
|
||
const matches = side.columns.flatMap((column) => column.matches)
|
||
return (
|
||
<div key={side.key}>
|
||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{matches.map((match) => (
|
||
<TournamentMatchCard
|
||
key={`${match.type_bracket}-${match.match_id}`}
|
||
match={match}
|
||
navigate={navigate}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TournamentDetailPage({ tournamentId, navigate }) {
|
||
const [state, setState] = useState({ status: 'loading', data: null, error: null })
|
||
|
||
useEffect(() => {
|
||
if (!tournamentId) return undefined
|
||
const controller = new AbortController()
|
||
setState({ status: 'loading', data: null, error: null })
|
||
fetchJson(apiEndpoints.tournament(tournamentId), controller.signal)
|
||
.then((data) => {
|
||
if (!controller.signal.aborted) setState({ status: 'ready', data, error: null })
|
||
})
|
||
.catch((error) => {
|
||
if (!controller.signal.aborted) {
|
||
setState({ status: 'error', data: null, error: error.message })
|
||
}
|
||
})
|
||
return () => controller.abort()
|
||
}, [tournamentId])
|
||
|
||
const data = state.data
|
||
const matches = useMemo(() => data?.matches || [], [data])
|
||
const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches])
|
||
const { sides } = useMemo(() => buildBracket(matches), [matches])
|
||
const bracketSides = sides.filter((side) => side.kind === 'bracket')
|
||
const listSides = sides.filter((side) => side.kind === 'list')
|
||
const standings = data?.standings || []
|
||
const hasStandings = standings.length > 0
|
||
// Lit-up teams shared across both brackets, so a run lights in the winner
|
||
// bracket and wherever it dropped into the loser bracket at once. Shape:
|
||
// { teamLower: 'win' | 'loss' } or null.
|
||
const [highlight, setHighlight] = useState(null)
|
||
const onHover = useCallback((map) => setHighlight(map), [])
|
||
|
||
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('/tournaments')}
|
||
type="button"
|
||
>
|
||
Back to tournaments
|
||
</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 gap-y-1">
|
||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||
Tournament — <span className="text-text-muted opacity-70">{tournamentId}</span>
|
||
</p>
|
||
<span className="rounded bg-surface px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-fury-violet">
|
||
{format.label}
|
||
</span>
|
||
</div>
|
||
<h1 className="mt-1 text-4xl font-bold">
|
||
{data?.name || `Tournament ${tournamentId}`}
|
||
</h1>
|
||
{data ? (
|
||
<p className="mt-2 text-sm text-text-soft">
|
||
{formatNumber(data.match_count)} matches · {formatNumber(data.team_count)} teams ·{' '}
|
||
{tournamentDateRange(data.date_start, data.date_end)}
|
||
{data.status ? ` · ${tournamentStatusLabel(data.status)}` : ''}
|
||
</p>
|
||
) : null}
|
||
{state.status === 'error' ? (
|
||
<p className="mt-4 text-sm text-danger">{state.error}</p>
|
||
) : null}
|
||
{state.status === 'loading' ? (
|
||
<p className="mt-4 text-sm text-text-soft">Loading tournament</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{state.status === 'ready' && !matches.length ? (
|
||
<p className="rounded-lg border border-border bg-fury-white px-5 py-10 text-sm text-text-soft shadow-sm">
|
||
No games linked to this tournament yet.
|
||
</p>
|
||
) : null}
|
||
|
||
{matches.length ? (
|
||
<div className="space-y-8">
|
||
{hasStandings || listSides.length ? (
|
||
<div className="space-y-4">
|
||
{bracketSides.length ? <h2 className="text-lg font-semibold">Group stage</h2> : null}
|
||
{hasStandings ? <TournamentStandings standings={standings} /> : null}
|
||
{listSides.length ? <TournamentMatchList sides={listSides} navigate={navigate} /> : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{bracketSides.length ? (
|
||
<div className="space-y-12">
|
||
{hasStandings || listSides.length ? (
|
||
<h2 className="text-lg font-semibold">Playoffs</h2>
|
||
) : null}
|
||
{bracketSides.map((side) => (
|
||
<TournamentBracketSide
|
||
highlight={highlight}
|
||
key={side.key}
|
||
navigate={navigate}
|
||
onHover={onHover}
|
||
side={side}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</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: '© OpenStreetMap contributors © 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
|