2842 lines
103 KiB
React
2842 lines
103 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import L from 'leaflet'
|
|
import 'leaflet/dist/leaflet.css'
|
|
import Tree from '../Tree/Tree'
|
|
import FallingLeaves from '../Tree/FallingLeaves'
|
|
|
|
const numberFormat = new Intl.NumberFormat('en-GB')
|
|
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})
|
|
|
|
const apiEndpoints = {
|
|
health: '/health',
|
|
uptime: '/api/uptime',
|
|
viewers: '/api/viewers',
|
|
viewerEvent: '/api/viewers/event',
|
|
viewerDelete: '/api/viewers/delete',
|
|
teams: '/api/tss/leaderboard/teams?limit=100',
|
|
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
|
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
|
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
|
history: (name) => `/api/tss/teams/${encodeURIComponent(name)}/history`,
|
|
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
|
}
|
|
|
|
const navItems = [
|
|
{ path: '/', label: 'Home' },
|
|
{ path: '/teams', label: 'Team leaderboard' },
|
|
{ path: '/battle-logs', label: 'Battle Logs' },
|
|
{ path: '/viewers', label: 'Viewers' },
|
|
]
|
|
|
|
const analyticsConsentKey = 'tssbot.analyticsConsent'
|
|
const analyticsPreferencesKey = 'tssbot.analyticsPreferences'
|
|
const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
|
|
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
|
|
const analyticsConsentVersion = 3
|
|
|
|
const defaultAnalyticsPreferences = {
|
|
chosen: false,
|
|
analytics: false,
|
|
device: false,
|
|
display: false,
|
|
locale: false,
|
|
referrer: false,
|
|
diagnostics: false,
|
|
version: analyticsConsentVersion,
|
|
}
|
|
|
|
async function fetchJson(path, signal) {
|
|
const response = await fetch(path, {
|
|
signal,
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
const body = await response.json().catch(() => null)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(body?.error || `Request failed with ${response.status}`)
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
function parseRoute(pathname = window.location.pathname) {
|
|
if (pathname === '/') return { page: 'home', teamName: '' }
|
|
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
|
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
|
|
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
|
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
|
if (pathname.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 formatNumber(value) {
|
|
return numberFormat.format(Number(value || 0))
|
|
}
|
|
|
|
function formatDate(timestamp) {
|
|
if (!timestamp) return 'Unknown time'
|
|
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
|
}
|
|
|
|
function bestTeamName(team) {
|
|
return team?.tag_name || team?.short_name || team?.long_name || ''
|
|
}
|
|
|
|
function normalizeAnalyticsPreferences(value) {
|
|
if (!value || typeof value !== 'object') return { ...defaultAnalyticsPreferences }
|
|
|
|
return {
|
|
chosen: Boolean(value.chosen) && value.version === analyticsConsentVersion,
|
|
analytics: Boolean(value.analytics),
|
|
device: Boolean(value.device),
|
|
display: Boolean(value.display),
|
|
locale: Boolean(value.locale),
|
|
referrer: Boolean(value.referrer),
|
|
diagnostics: Boolean(value.diagnostics),
|
|
version: analyticsConsentVersion,
|
|
}
|
|
}
|
|
|
|
function browserPrivacySignalEnabled() {
|
|
return Boolean(
|
|
navigator.globalPrivacyControl ||
|
|
navigator.doNotTrack === '1' ||
|
|
window.doNotTrack === '1',
|
|
)
|
|
}
|
|
|
|
function browserConnectionDetails() {
|
|
const connection =
|
|
navigator.connection || navigator.mozConnection || navigator.webkitConnection || null
|
|
|
|
if (!connection) return {}
|
|
|
|
return {
|
|
effective_type: connection.effectiveType || '',
|
|
downlink_mbps: Number.isFinite(connection.downlink) ? connection.downlink : null,
|
|
rtt_ms: Number.isFinite(connection.rtt) ? connection.rtt : null,
|
|
save_data: Boolean(connection.saveData),
|
|
}
|
|
}
|
|
|
|
function browserDiagnosticsMetadata() {
|
|
return {
|
|
cookies_enabled: navigator.cookieEnabled,
|
|
do_not_track: navigator.doNotTrack || window.doNotTrack || '',
|
|
global_privacy_control: Boolean(navigator.globalPrivacyControl),
|
|
online: navigator.onLine,
|
|
platform: navigator.platform || '',
|
|
vendor: navigator.vendor || '',
|
|
max_touch_points: navigator.maxTouchPoints || 0,
|
|
hardware_concurrency: navigator.hardwareConcurrency || null,
|
|
device_memory_gb: navigator.deviceMemory || null,
|
|
webdriver: Boolean(navigator.webdriver),
|
|
history_length: window.history.length,
|
|
visibility_state: document.visibilityState,
|
|
connection: browserConnectionDetails(),
|
|
}
|
|
}
|
|
|
|
function readCookie(name) {
|
|
try {
|
|
const match = document.cookie
|
|
.split('; ')
|
|
.find((item) => item.startsWith(`${encodeURIComponent(name)}=`))
|
|
return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function writeCookie(name, value) {
|
|
try {
|
|
const sixMonths = 60 * 60 * 24 * 180
|
|
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Max-Age=${sixMonths}; Path=/; SameSite=Lax`
|
|
} catch {
|
|
// Some browsers block cookies; local storage and in-memory state still work.
|
|
}
|
|
}
|
|
|
|
function storedAnalyticsPreferences() {
|
|
const cookieValue = readCookie(analyticsPreferencesCookie)
|
|
if (cookieValue) {
|
|
try {
|
|
return normalizeAnalyticsPreferences(JSON.parse(cookieValue))
|
|
} catch {
|
|
// Fall back through the older storage formats below.
|
|
}
|
|
}
|
|
|
|
try {
|
|
const stored = window.localStorage.getItem(analyticsPreferencesKey)
|
|
if (stored) return normalizeAnalyticsPreferences(JSON.parse(stored))
|
|
} catch {
|
|
// Fall back through the older consent flag below.
|
|
}
|
|
|
|
try {
|
|
const legacyConsent = window.localStorage.getItem(analyticsConsentKey) || ''
|
|
if (legacyConsent === 'analytics') {
|
|
return {
|
|
...defaultAnalyticsPreferences,
|
|
chosen: true,
|
|
analytics: true,
|
|
device: true,
|
|
display: true,
|
|
locale: true,
|
|
referrer: true,
|
|
diagnostics: true,
|
|
}
|
|
}
|
|
if (legacyConsent === 'declined') {
|
|
return { ...defaultAnalyticsPreferences, chosen: true, analytics: false }
|
|
}
|
|
} catch {
|
|
// Use the default prompt state.
|
|
}
|
|
|
|
return { ...defaultAnalyticsPreferences }
|
|
}
|
|
|
|
function persistAnalyticsPreferences(preferences) {
|
|
const normalized = normalizeAnalyticsPreferences(preferences)
|
|
const serialized = JSON.stringify(normalized)
|
|
|
|
writeCookie(analyticsPreferencesCookie, serialized)
|
|
|
|
try {
|
|
window.localStorage.setItem(analyticsPreferencesKey, serialized)
|
|
window.localStorage.setItem(
|
|
analyticsConsentKey,
|
|
normalized.analytics ? 'analytics' : 'declined',
|
|
)
|
|
} catch {
|
|
// Local storage can be blocked; the cookie and in-memory choice still apply.
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function stableId(storageKey) {
|
|
try {
|
|
const existing = window.localStorage.getItem(storageKey)
|
|
if (existing) return existing
|
|
|
|
const id =
|
|
window.crypto?.randomUUID?.() ||
|
|
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
|
window.localStorage.setItem(storageKey, id)
|
|
return id
|
|
} catch {
|
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
|
}
|
|
}
|
|
|
|
function storedVisitorId(storageKey) {
|
|
try {
|
|
return window.localStorage.getItem(storageKey) || ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function forgetVisitorId(storageKey) {
|
|
try {
|
|
window.localStorage.removeItem(storageKey)
|
|
} catch {
|
|
// Storage may be unavailable; nothing else to clear locally.
|
|
}
|
|
}
|
|
|
|
const analyticsSessionId =
|
|
window.crypto?.randomUUID?.() ||
|
|
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
|
|
|
function browserName() {
|
|
const ua = navigator.userAgent
|
|
if (ua.includes('Edg/')) return 'Microsoft Edge'
|
|
if (ua.includes('OPR/')) return 'Opera'
|
|
if (ua.includes('Firefox/')) return 'Firefox'
|
|
if (ua.includes('Chrome/') && !ua.includes('Chromium/')) return 'Chrome'
|
|
if (ua.includes('Safari/') && ua.includes('Version/')) return 'Safari'
|
|
return 'Unknown'
|
|
}
|
|
|
|
function operatingSystem() {
|
|
const ua = navigator.userAgent
|
|
if (ua.includes('Windows NT')) return 'Windows'
|
|
if (ua.includes('Android')) return 'Android'
|
|
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS'
|
|
if (ua.includes('Mac OS X')) return 'macOS'
|
|
if (ua.includes('Linux')) return 'Linux'
|
|
return 'Unknown'
|
|
}
|
|
|
|
function deviceType() {
|
|
const ua = navigator.userAgent
|
|
if (/Mobi|Android|iPhone|iPod/.test(ua)) return 'Mobile'
|
|
if (/iPad|Tablet/.test(ua)) return 'Tablet'
|
|
return 'Desktop'
|
|
}
|
|
|
|
function routeLabel(route) {
|
|
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
|
|
if (route.page === 'teams') return 'Team leaderboard'
|
|
if (route.page === 'battle-logs') return 'Battle Logs'
|
|
if (route.page === 'uptime') return 'Uptime'
|
|
if (route.page === 'viewers') return 'viewers'
|
|
if (route.page === 'privacy') return 'Privacy notice'
|
|
return 'Home'
|
|
}
|
|
|
|
async function fetchRecentTssGames(teams, signal) {
|
|
const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12)
|
|
|
|
if (!teamNames.length) {
|
|
return { matches: [] }
|
|
}
|
|
|
|
const responses = await Promise.allSettled(
|
|
teamNames.map((name) => fetchJson(apiEndpoints.games(name), signal).then((data) => ({ name, data }))),
|
|
)
|
|
const bySession = new Map()
|
|
|
|
responses.forEach((result) => {
|
|
if (result.status !== 'fulfilled') return
|
|
|
|
const { name, data } = result.value
|
|
;(data.games || []).forEach((game) => {
|
|
if (!game.session_id) return
|
|
|
|
const existing = bySession.get(game.session_id)
|
|
const currentTimestamp = Number(game.timestamp || 0)
|
|
if (existing && Number(existing.timestamp || 0) >= currentTimestamp) return
|
|
|
|
bySession.set(game.session_id, {
|
|
...game,
|
|
team_name: data.tag_name || name,
|
|
long_name: data.long_name || '',
|
|
})
|
|
})
|
|
})
|
|
|
|
return {
|
|
matches: Array.from(bySession.values())
|
|
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
|
.slice(0, 50),
|
|
}
|
|
}
|
|
|
|
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 App() {
|
|
const [route, setRoute] = useState(() => parseRoute())
|
|
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
|
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
|
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
|
|
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
|
|
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
|
|
const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
|
|
const [teamQuery, setTeamQuery] = useState('')
|
|
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
|
|
const [profile, setProfile] = useState({
|
|
teamName: '',
|
|
detail: { status: 'idle', data: null, error: null },
|
|
history: { 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 matches = live.data?.matches || []
|
|
|
|
function navigate(path) {
|
|
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(() => {
|
|
const title =
|
|
route.page === 'team' && route.teamName
|
|
? `${route.teamName} | Toothless' TSS Bot`
|
|
: route.page === 'teams'
|
|
? "Team leaderboard | Toothless' TSS Bot"
|
|
: route.page === 'battle-logs'
|
|
? "Battle Logs | Toothless' TSS Bot"
|
|
: route.page === 'uptime'
|
|
? "Uptime | Toothless' TSS Bot"
|
|
: route.page === 'viewers'
|
|
? "Viewers | Toothless' TSS Bot"
|
|
: route.page === 'privacy'
|
|
? "Privacy notice | Toothless' TSS Bot"
|
|
: "Toothless' TSS Bot"
|
|
|
|
document.title = title
|
|
}, [route.page, route.teamName])
|
|
|
|
useEffect(() => {
|
|
if (!analyticsPreferences.analytics) 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,
|
|
},
|
|
}
|
|
|
|
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 : '',
|
|
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, 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: '' })
|
|
return
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timer = window.setTimeout(() => {
|
|
setSearchHint({ status: 'loading', name: '' })
|
|
fetchJson(apiEndpoints.resolve(query), controller.signal)
|
|
.then((data) => {
|
|
const name = data.tag_name || data.short_name || data.long_name || query
|
|
setSearchHint({ status: 'ready', name })
|
|
})
|
|
.catch(() => {
|
|
if (!controller.signal.aborted) {
|
|
setSearchHint({ status: 'error', name: '' })
|
|
}
|
|
})
|
|
}, 350)
|
|
|
|
return () => {
|
|
window.clearTimeout(timer)
|
|
controller.abort()
|
|
}
|
|
}, [teamQuery])
|
|
|
|
useEffect(() => {
|
|
if (!['home', 'teams', 'team', 'battle-logs'].includes(route.page)) return
|
|
if (leaderboard.status === 'ready' || leaderboard.status === 'loading') return
|
|
|
|
const controller = new AbortController()
|
|
setLeaderboard({ status: 'loading', data: null, error: null })
|
|
|
|
fetchJson(apiEndpoints.teams, controller.signal)
|
|
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
|
.catch((error) => {
|
|
if (!controller.signal.aborted) {
|
|
setLeaderboard({ status: 'error', data: null, error: error.message })
|
|
}
|
|
})
|
|
|
|
return () => controller.abort()
|
|
}, [leaderboard.status, route.page])
|
|
|
|
useEffect(() => {
|
|
if (!['home', 'teams', 'team', 'battle-logs'].includes(route.page)) return
|
|
|
|
const controller = new AbortController()
|
|
const timer = window.setInterval(() => {
|
|
fetchJson(apiEndpoints.teams, controller.signal)
|
|
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
|
.catch((error) => {
|
|
if (!controller.signal.aborted) {
|
|
setLeaderboard((current) => ({ ...current, error: error.message }))
|
|
}
|
|
})
|
|
}, 60000)
|
|
|
|
return () => {
|
|
window.clearInterval(timer)
|
|
controller.abort()
|
|
}
|
|
}, [route.page])
|
|
|
|
useEffect(() => {
|
|
if (!['home', 'battle-logs'].includes(route.page)) return
|
|
if (!teams.length) return
|
|
|
|
const controller = new AbortController()
|
|
setLive((current) =>
|
|
current.status === 'ready' ? current : { status: 'loading', data: null, error: null },
|
|
)
|
|
|
|
fetchRecentTssGames(teams, controller.signal)
|
|
.then((data) => setLive({ status: 'ready', data, error: null }))
|
|
.catch((error) => {
|
|
if (!controller.signal.aborted) {
|
|
setLive({ status: 'error', data: null, error: error.message })
|
|
}
|
|
})
|
|
|
|
return () => controller.abort()
|
|
}, [route.page, teams])
|
|
|
|
useEffect(() => {
|
|
if (!['home', 'battle-logs'].includes(route.page)) return
|
|
if (!teams.length) return
|
|
|
|
const controller = new AbortController()
|
|
const timer = window.setInterval(() => {
|
|
fetchRecentTssGames(teams, controller.signal)
|
|
.then((data) => setLive({ status: 'ready', data, error: null }))
|
|
.catch((error) => {
|
|
if (!controller.signal.aborted) {
|
|
setLive((current) => ({ ...current, error: error.message }))
|
|
}
|
|
})
|
|
}, 15000)
|
|
|
|
return () => {
|
|
window.clearInterval(timer)
|
|
controller.abort()
|
|
}
|
|
}, [route.page, teams])
|
|
|
|
useEffect(() => {
|
|
if (route.page !== 'team' || !route.teamName) return
|
|
|
|
const controller = new AbortController()
|
|
setProfile({
|
|
teamName: route.teamName,
|
|
detail: { status: 'loading', data: null, error: null },
|
|
history: { status: 'loading', data: null, error: null },
|
|
games: { status: 'loading', data: null, error: null },
|
|
})
|
|
|
|
Promise.allSettled([
|
|
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
|
fetchJson(apiEndpoints.history(route.teamName), controller.signal),
|
|
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
|
]).then(([detailResult, historyResult, gamesResult]) => {
|
|
if (controller.signal.aborted) return
|
|
|
|
setProfile({
|
|
teamName: route.teamName,
|
|
detail:
|
|
detailResult.status === 'fulfilled'
|
|
? { status: 'ready', data: detailResult.value, error: null }
|
|
: { status: 'error', data: null, error: detailResult.reason.message },
|
|
history:
|
|
historyResult.status === 'fulfilled'
|
|
? { status: 'ready', data: historyResult.value, error: null }
|
|
: { status: 'error', data: null, error: historyResult.reason.message },
|
|
games:
|
|
gamesResult.status === 'fulfilled'
|
|
? { status: 'ready', data: gamesResult.value, error: null }
|
|
: { status: 'error', data: null, error: gamesResult.reason.message },
|
|
})
|
|
})
|
|
|
|
return () => controller.abort()
|
|
}, [route.page, route.teamName])
|
|
|
|
useEffect(() => {
|
|
if (route.page !== 'uptime') return
|
|
|
|
const controller = new AbortController()
|
|
|
|
async function loadUptime() {
|
|
setUptime((current) => ({
|
|
status: current.status === 'ready' ? 'refreshing' : 'loading',
|
|
checks: current.checks,
|
|
history: current.history,
|
|
updatedAt: current.updatedAt,
|
|
}))
|
|
|
|
fetchJson(apiEndpoints.uptime, controller.signal)
|
|
.then((data) => {
|
|
const latest = data.latest
|
|
const checks = latest
|
|
? [
|
|
{
|
|
name: 'Website',
|
|
detail: 'App shell and static assets',
|
|
ok: latest.website_ok,
|
|
label: latest.details?.website?.label || (latest.website_ok ? 'Online' : 'Issue'),
|
|
latency: latest.latency_ms,
|
|
},
|
|
{
|
|
name: 'Health endpoint',
|
|
detail: apiEndpoints.health,
|
|
ok: latest.health_ok,
|
|
label: latest.details?.health?.label || (latest.health_ok ? 'Operational' : 'Issue'),
|
|
latency: latest.latency_ms,
|
|
},
|
|
{
|
|
name: 'TSS data proxy',
|
|
detail: apiEndpoints.teamsHealth,
|
|
ok: latest.tss_ok,
|
|
label: latest.details?.tss?.label || (latest.tss_ok ? 'Operational' : 'Issue'),
|
|
latency: latest.details?.tss?.latency_ms || latest.latency_ms,
|
|
},
|
|
]
|
|
: []
|
|
|
|
setUptime({
|
|
status: 'ready',
|
|
checks,
|
|
history: (data.history || []).map((sample) => ({
|
|
timestamp: new Date(sample.checked_at).getTime(),
|
|
onlineChecks: [sample.website_ok, sample.health_ok, sample.tss_ok].filter(Boolean).length,
|
|
totalChecks: 3,
|
|
ok: sample.ok,
|
|
})),
|
|
updatedAt: latest ? new Date(latest.checked_at).getTime() : null,
|
|
configured: data.configured,
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
if (controller.signal.aborted) return
|
|
|
|
setUptime({
|
|
status: 'error',
|
|
updatedAt: null,
|
|
configured: false,
|
|
history: [],
|
|
checks: [
|
|
{
|
|
name: 'Website',
|
|
detail: 'App shell and static assets',
|
|
ok: true,
|
|
label: 'Online',
|
|
latency: 0,
|
|
},
|
|
{
|
|
name: 'Health endpoint',
|
|
detail: apiEndpoints.health,
|
|
ok: false,
|
|
label: error.message,
|
|
latency: 0,
|
|
},
|
|
{
|
|
name: 'TSS data proxy',
|
|
detail: apiEndpoints.teamsHealth,
|
|
ok: false,
|
|
label: 'Uptime history unavailable',
|
|
latency: 0,
|
|
},
|
|
],
|
|
})
|
|
})
|
|
}
|
|
|
|
loadUptime()
|
|
const timer = window.setInterval(loadUptime, 60000)
|
|
|
|
return () => {
|
|
window.clearInterval(timer)
|
|
controller.abort()
|
|
}
|
|
}, [route.page])
|
|
|
|
useEffect(() => {
|
|
if (route.page !== 'viewers') return
|
|
|
|
const controller = new AbortController()
|
|
|
|
function loadViewers() {
|
|
setViewers((current) => ({
|
|
status: current.status === 'ready' ? 'refreshing' : 'loading',
|
|
data: current.data,
|
|
error: null,
|
|
updatedAt: current.updatedAt,
|
|
}))
|
|
|
|
fetchJson(apiEndpoints.viewers, controller.signal)
|
|
.then((data) => {
|
|
setViewers({
|
|
status: 'ready',
|
|
data,
|
|
error: null,
|
|
updatedAt: Date.now(),
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
if (!controller.signal.aborted) {
|
|
setViewers((current) => ({ ...current, status: 'error', error: error.message }))
|
|
}
|
|
})
|
|
}
|
|
|
|
loadViewers()
|
|
const timer = window.setInterval(loadViewers, 5000)
|
|
|
|
return () => {
|
|
window.clearInterval(timer)
|
|
controller.abort()
|
|
}
|
|
}, [route.page])
|
|
|
|
useEffect(() => {
|
|
if (route.page !== 'team' || !route.teamName) return
|
|
|
|
const controller = new AbortController()
|
|
const timer = window.setInterval(() => {
|
|
Promise.allSettled([
|
|
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
|
fetchJson(apiEndpoints.history(route.teamName), controller.signal),
|
|
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
|
]).then(([detailResult, historyResult, gamesResult]) => {
|
|
if (controller.signal.aborted) return
|
|
|
|
setProfile((current) => ({
|
|
teamName: route.teamName,
|
|
detail:
|
|
detailResult.status === 'fulfilled'
|
|
? { status: 'ready', data: detailResult.value, error: null }
|
|
: current.detail,
|
|
history:
|
|
historyResult.status === 'fulfilled'
|
|
? { status: 'ready', data: historyResult.value, error: null }
|
|
: current.history,
|
|
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 searchPlaceholder =
|
|
searchHint.status === 'ready' ? `Found ${searchHint.name}` : topTeamName || 'Search teams'
|
|
|
|
async function handleTeamSearch(event) {
|
|
event.preventDefault()
|
|
const name = teamQuery.trim()
|
|
if (!name) return
|
|
|
|
try {
|
|
const resolved = await fetchJson(apiEndpoints.resolve(name))
|
|
navigate(teamPath(resolved.tag_name || resolved.short_name || resolved.long_name || name))
|
|
} catch {
|
|
navigate(teamPath(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)
|
|
}
|
|
|
|
const activeNavPath =
|
|
route.page === 'team'
|
|
? '/teams'
|
|
: route.page === 'battle-logs'
|
|
? '/battle-logs'
|
|
: route.page === 'viewers'
|
|
? '/viewers'
|
|
: window.location.pathname
|
|
|
|
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 ${showFloatingNav
|
|
? 'translate-y-0 opacity-100'
|
|
: 'pointer-events-none -translate-y-8 opacity-0'
|
|
}`}
|
|
>
|
|
<div className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full border border-border bg-fury-white/92 px-2 py-2 shadow-[0_12px_36px_rgba(0,0,0,0.16)] backdrop-blur sm:px-3">
|
|
<button
|
|
className="hidden shrink-0 rounded-full px-3 py-2 text-sm font-bold tracking-tight transition hover:bg-surface hover:text-fury-cyan sm:block"
|
|
onClick={() => navigate('/')}
|
|
type="button"
|
|
>
|
|
Toothless' TSS Bot
|
|
</button>
|
|
|
|
<nav className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
|
{navItems.map((item) => (
|
|
<button
|
|
className={`shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition ${activeNavPath === item.path
|
|
? 'bg-text text-bg'
|
|
: 'text-text-soft hover:bg-surface hover:text-text'
|
|
}`}
|
|
key={item.path}
|
|
onClick={() => navigate(item.path)}
|
|
type="button"
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<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}
|
|
teams={teams}
|
|
teamQuery={teamQuery}
|
|
/>
|
|
) : null}
|
|
{route.page === 'teams' ? (
|
|
<TeamsPage leaderboard={leaderboard} navigate={navigate} teams={teams} />
|
|
) : null}
|
|
{route.page === 'team' ? (
|
|
<TeamProfilePage
|
|
navigate={navigate}
|
|
profile={profile}
|
|
requestedTeam={route.teamName}
|
|
teams={teams}
|
|
/>
|
|
) : null}
|
|
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
|
|
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
|
|
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
|
{route.page === 'privacy' ? <PrivacyPage /> : 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</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>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
function ConsentBanner({ preferences, onChoose }) {
|
|
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
|
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
|
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
|
const privacySignalEnabled = browserPrivacySignalEnabled()
|
|
|
|
useEffect(() => {
|
|
setDraft(normalizeAnalyticsPreferences(preferences))
|
|
if (!preferences.chosen) {
|
|
setIsOpen(true)
|
|
setIsConfiguring(false)
|
|
}
|
|
}, [preferences])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return undefined
|
|
|
|
const previousOverflow = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow
|
|
}
|
|
}, [isOpen])
|
|
|
|
function updateDraft(key, value) {
|
|
setDraft((current) => ({ ...current, [key]: value }))
|
|
}
|
|
|
|
function savePreferences(nextPreferences) {
|
|
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
|
setIsOpen(false)
|
|
}
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<button
|
|
className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:border-ring hover:bg-surface hover:text-text"
|
|
onClick={() => {
|
|
setIsConfiguring(true)
|
|
setIsOpen(true)
|
|
}}
|
|
type="button"
|
|
>
|
|
Cookie settings
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
aria-labelledby="consent-title"
|
|
aria-modal="true"
|
|
className="fixed inset-0 z-50 grid place-items-center bg-black/45 px-4 py-6 backdrop-blur-[2px]"
|
|
role="dialog"
|
|
>
|
|
<div className="w-full max-w-2xl rounded-md border border-border bg-fury-white p-5 text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)] sm:p-6">
|
|
<div className="max-w-xl">
|
|
<h2 id="consent-title" className="text-xl font-semibold">
|
|
Cookie and analytics settings
|
|
</h2>
|
|
<p className="mt-2 text-sm leading-6 text-text-soft">
|
|
We use a necessary cookie to remember these choices. You can also allow
|
|
analytics for the public viewers page and choose which details are included.
|
|
</p>
|
|
{privacySignalEnabled ? (
|
|
<p className="mt-2 text-sm font-semibold text-text">
|
|
Your browser is signalling a privacy preference, so optional analytics stay
|
|
off unless you explicitly allow them.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{isConfiguring ? (
|
|
<>
|
|
<div className="mt-5 space-y-3">
|
|
<PreferenceToggle
|
|
checked
|
|
description="Stores this consent choice so the popup does not appear on every page."
|
|
disabled
|
|
label="Necessary cookie"
|
|
/>
|
|
<PreferenceToggle
|
|
checked={draft.analytics}
|
|
description="Sends page views, live viewing state, session ID, and pseudonymous visitor ID."
|
|
label="Viewer analytics"
|
|
onChange={(value) => updateDraft('analytics', value)}
|
|
/>
|
|
<PreferenceToggle
|
|
checked={draft.device}
|
|
description="Includes browser, operating system, and broad device type."
|
|
disabled={!draft.analytics}
|
|
label="Browser and device details"
|
|
onChange={(value) => updateDraft('device', value)}
|
|
/>
|
|
<PreferenceToggle
|
|
checked={draft.display}
|
|
description="Includes screen size, viewport size, and colour depth."
|
|
disabled={!draft.analytics}
|
|
label="Screen details"
|
|
onChange={(value) => updateDraft('display', value)}
|
|
/>
|
|
<PreferenceToggle
|
|
checked={draft.locale}
|
|
description="Includes browser language and timezone."
|
|
disabled={!draft.analytics}
|
|
label="Language and timezone"
|
|
onChange={(value) => updateDraft('locale', value)}
|
|
/>
|
|
<PreferenceToggle
|
|
checked={draft.referrer}
|
|
description="Includes the page that linked you here when the browser provides it."
|
|
disabled={!draft.analytics}
|
|
label="Referrer"
|
|
onChange={(value) => updateDraft('referrer', value)}
|
|
/>
|
|
<PreferenceToggle
|
|
checked={draft.diagnostics}
|
|
description="Includes HTTP version, protocol headers, cache/debug headers, network quality, privacy signals, touch support, CPU/memory hints, and other browser diagnostics."
|
|
disabled={!draft.analytics}
|
|
label="Technical diagnostics"
|
|
onChange={(value) => updateDraft('diagnostics', value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<button
|
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
|
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
|
type="button"
|
|
>
|
|
Decline all
|
|
</button>
|
|
<button
|
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
|
onClick={() => savePreferences(draft)}
|
|
type="button"
|
|
>
|
|
Save choices
|
|
</button>
|
|
<button
|
|
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
|
onClick={() =>
|
|
savePreferences({
|
|
chosen: true,
|
|
analytics: true,
|
|
device: true,
|
|
display: true,
|
|
locale: true,
|
|
referrer: true,
|
|
diagnostics: true,
|
|
version: analyticsConsentVersion,
|
|
})
|
|
}
|
|
type="button"
|
|
>
|
|
Allow all
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
<button
|
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
|
onClick={() => setIsConfiguring(true)}
|
|
type="button"
|
|
>
|
|
Configure
|
|
</button>
|
|
<button
|
|
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
|
onClick={() =>
|
|
savePreferences({
|
|
chosen: true,
|
|
analytics: true,
|
|
device: true,
|
|
display: true,
|
|
locale: true,
|
|
referrer: true,
|
|
diagnostics: true,
|
|
version: analyticsConsentVersion,
|
|
})
|
|
}
|
|
type="button"
|
|
>
|
|
Allow all
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
|
|
return (
|
|
<label
|
|
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${
|
|
disabled ? 'opacity-65' : 'cursor-pointer'
|
|
}`}
|
|
>
|
|
<input
|
|
checked={checked}
|
|
className="mt-1 h-4 w-4 accent-fury-cyan"
|
|
disabled={disabled}
|
|
onChange={(event) => onChange?.(event.target.checked)}
|
|
type="checkbox"
|
|
/>
|
|
<span>
|
|
<span className="block text-sm font-semibold text-text">{label}</span>
|
|
<span className="mt-1 block text-xs leading-5 text-text-soft">{description}</span>
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function PrivacyPage() {
|
|
return (
|
|
<section className="mx-auto max-w-4xl py-12">
|
|
<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 and analytics choices. If you opt in to
|
|
viewer analytics, we collect page views, live viewing status, a pseudonymous
|
|
visitor ID, and a session ID. 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 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 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,
|
|
teams,
|
|
teamQuery,
|
|
}) {
|
|
const treeRef = useRef(null)
|
|
|
|
return (
|
|
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
|
<div className="relative min-h-screen overflow-hidden px-5 sm:px-8">
|
|
<PixelMountains />
|
|
|
|
<section className="relative z-10 mx-auto grid min-h-screen w-full max-w-7xl gap-8 pt-16 pb-16 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
|
<div className="max-w-3xl">
|
|
<p className="text-base font-semibold uppercase tracking-wide text-fury-cyan">
|
|
BorisBot got nothin on THIS
|
|
</p>
|
|
<h1 className="mt-3 text-6xl font-bold tracking-normal sm:text-7xl lg:text-8xl">
|
|
Toothless' 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="min-h-15 rounded-lg bg-text px-5 py-4 text-base font-semibold text-bg transition hover:bg-fury-cyan"
|
|
onClick={() => navigate('/teams')}
|
|
type="button"
|
|
>
|
|
Team leaderboard
|
|
</button>
|
|
<button
|
|
className="min-h-15 rounded-lg border-2 border-ring px-5 py-4 text-base font-semibold text-fury-cyan transition hover:bg-surface hover:text-text"
|
|
onClick={() => navigate('/battle-logs')}
|
|
type="button"
|
|
>
|
|
Battle Logs
|
|
</button>
|
|
<a
|
|
className="flex min-h-15 items-center justify-center rounded-lg border-2 border-text bg-fury-white px-5 py-4 text-base font-semibold text-text transition hover:bg-surface"
|
|
href="https://sre.pawjob.us/"
|
|
>
|
|
Visit SREBOT
|
|
</a>
|
|
</div>
|
|
|
|
<form
|
|
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
|
|
onSubmit={onTeamSearch}
|
|
>
|
|
<input
|
|
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
|
placeholder={searchPlaceholder}
|
|
value={teamQuery}
|
|
onChange={(event) => setTeamQuery(event.target.value)}
|
|
/>
|
|
<button
|
|
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
|
type="submit"
|
|
>
|
|
Search teams
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative min-h-[520px] overflow-hidden">
|
|
<FallingLeaves treeRef={treeRef} />
|
|
<div className="absolute inset-0 z-[1] flex items-end justify-center pb-10 lg:pb-0">
|
|
<Tree ref={treeRef} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute bottom-5 left-1/2 z-20 hidden -translate-x-1/2 flex-col items-center gap-2 text-xs font-semibold uppercase tracking-wide text-text-soft sm:flex">
|
|
<span>Scroll</span>
|
|
<span className="h-10 w-[2px] overflow-hidden rounded-full bg-border">
|
|
<span className="block h-4 w-full animate-[scrollPulse_1.5s_ease-in-out_infinite] rounded-full bg-fury-cyan" />
|
|
</span>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<LandingOverview teams={teams} matches={matches} navigate={navigate} />
|
|
<RecentGamesSection live={live} matches={matches} navigate={navigate} />
|
|
<LandingTrustSection navigate={navigate} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LandingOverview({ teams, matches, navigate }) {
|
|
const activeTeams = teams.slice(0, 4)
|
|
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(teams.length)} />
|
|
<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="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) => (
|
|
<article
|
|
className="rounded-lg border border-border bg-bg p-4 shadow-sm"
|
|
key={match.session_id}
|
|
>
|
|
<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>
|
|
<span className="shrink-0 rounded-md bg-surface px-2 py-1 text-xs font-semibold text-text-soft">
|
|
{match.result || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-3 text-sm">
|
|
<p className="truncate font-semibold text-fury-cyan">
|
|
{match.team_name || 'TSS team'}
|
|
</p>
|
|
<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?.ground_kills)} ground</span>
|
|
<span>{formatNumber(match.stats?.air_kills)} air</span>
|
|
<span>{formatNumber(match.stats?.deaths)} deaths</span>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
function PixelMountains() {
|
|
const canvasRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
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],
|
|
],
|
|
'#fcfbcf',
|
|
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],
|
|
],
|
|
'#fff2e6',
|
|
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],
|
|
],
|
|
'#fee5cd',
|
|
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],
|
|
],
|
|
'#fdca9b',
|
|
1.4,
|
|
)
|
|
}
|
|
|
|
draw()
|
|
}, [])
|
|
|
|
return <canvas ref={canvasRef} className="pixel-mountains" aria-hidden="true" />
|
|
}
|
|
|
|
function TeamsPage({ leaderboard, navigate, teams }) {
|
|
return (
|
|
<section className="space-y-6 pt-10 sm:pt-14">
|
|
<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 className="block truncate text-xs text-text-soft">
|
|
{team.long_name || team.short_name || 'Unresolved'}
|
|
</span>
|
|
</span>
|
|
<span className="text-sm">{formatNumber(team.player_count)} players</span>
|
|
<span className="text-sm">{formatNumber(team.total_battles)} battles</span>
|
|
<span className="text-sm">{Number(team.win_rate || 0).toFixed(1)}% WR</span>
|
|
<span className="text-sm font-semibold">
|
|
{formatNumber(team.points?.total_points || team.total_kills)}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
|
|
{!teams.length ? (
|
|
<p className="px-5 py-10 text-sm text-text-soft">
|
|
{leaderboard.status === 'loading' ? 'Loading leaderboard' : 'No teams returned'}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
|
const detail = profile.detail.data
|
|
const summary = detail?.team_summary || detail?.squadron_summary
|
|
const players = detail?.players || []
|
|
const games = profile.games.data?.games || []
|
|
const history = profile.history.data?.history || []
|
|
const ratingHourly = profile.history.data?.rating_hourly || []
|
|
const latestRating = ratingHourly.at(-1)?.rating || summary?.points?.total_points
|
|
const leaderboardTeam = teams.find((team) => bestTeamName(team) === requestedTeam)
|
|
const displayName = detail?.tag_name || bestTeamName(leaderboardTeam) || requestedTeam
|
|
const longName = detail?.long_name || leaderboardTeam?.long_name || ''
|
|
|
|
return (
|
|
<section className="space-y-6">
|
|
<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 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">
|
|
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 || longName || profile.detail.status}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
|
Rating {formatNumber(latestRating)}
|
|
</span>
|
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
|
Clan {detail?.clan_id || leaderboardTeam?.clan_id || 'n/a'}
|
|
</span>
|
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
|
{detail?.data_set || 'tss'}
|
|
</span>
|
|
</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>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
|
<RosterTable players={players} status={profile.detail.status} />
|
|
<RatingPanel history={history} ratingHourly={ratingHourly} status={profile.history.status} />
|
|
</div>
|
|
|
|
<BattleResults games={games} status={profile.games.status} />
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function RosterTable({ players, status }) {
|
|
const sortedPlayers = [...players].sort((a, b) => {
|
|
return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '')
|
|
})
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
|
<div className="border-b border-surface px-5 py-4">
|
|
<h2 className="text-lg font-semibold">Roster</h2>
|
|
<p className="mt-1 text-sm text-text-soft">{formatNumber(sortedPlayers.length)} players</p>
|
|
</div>
|
|
<div className="max-h-[520px] overflow-auto">
|
|
{sortedPlayers.map((player) => (
|
|
<div
|
|
className="grid gap-3 border-b border-surface px-5 py-3 text-sm md:grid-cols-[1fr_repeat(5,auto)] md:items-center"
|
|
key={player.uid}
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold">{player.nick || player.uid}</p>
|
|
<p className="text-xs text-text-soft">
|
|
{player.uid} · {formatNumber(player.points || player.sqb_points)} pts
|
|
</p>
|
|
</div>
|
|
<p>{formatNumber(player.total_battles)} battles</p>
|
|
<p>{formatNumber(player.total_kills)} kills</p>
|
|
<p>{Number(player.win_rate || 0).toFixed(1)}% WR</p>
|
|
<p>{Number(player.kdr || 0).toFixed(1)} KDR</p>
|
|
<p>{formatNumber(player.assists)} assists</p>
|
|
</div>
|
|
))}
|
|
{!sortedPlayers.length ? (
|
|
<p className="px-5 py-10 text-sm text-text-soft">
|
|
{status === 'loading' ? 'Loading roster' : 'No roster rows returned'}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RatingPanel({ history, ratingHourly, status }) {
|
|
const recentHistory = history.slice(-8)
|
|
const firstRating = ratingHourly[0]?.rating || 0
|
|
const latestRating = ratingHourly.at(-1)?.rating || 0
|
|
const ratingChange = latestRating - firstRating
|
|
|
|
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">History</h2>
|
|
<p className="mt-1 text-sm text-text-soft">
|
|
{ratingHourly.length ? `${formatNumber(ratingHourly.length)} rating snapshots` : status}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-5 p-5">
|
|
<div className="grid gap-5 sm:grid-cols-2">
|
|
<Stat label="Latest rating" value={formatNumber(latestRating)} />
|
|
<Stat
|
|
label="Rating change"
|
|
value={`${ratingChange >= 0 ? '+' : ''}${formatNumber(ratingChange)}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{recentHistory.map((item) => (
|
|
<div
|
|
className="grid grid-cols-[1fr_auto_auto] gap-3 rounded-md bg-surface px-3 py-2 text-sm"
|
|
key={item.period}
|
|
>
|
|
<span className="font-semibold">{item.period}</span>
|
|
<span>{formatNumber(item.battles)} battles</span>
|
|
<span>{Number(item.win_rate || 0).toFixed(1)}% WR</span>
|
|
</div>
|
|
))}
|
|
{!recentHistory.length ? (
|
|
<p className="text-sm text-text-soft">
|
|
{status === 'loading' ? 'Loading history' : 'No history rows returned'}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BattleResults({ games, 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) => (
|
|
<div
|
|
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_auto_repeat(5,auto)] md:items-center"
|
|
key={game.session_id}
|
|
>
|
|
<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>
|
|
<p
|
|
className={`rounded-md px-3 py-1 text-sm font-semibold ${String(game.result).toLowerCase() === 'win'
|
|
? 'bg-surface text-fury-cyan'
|
|
: 'bg-fury-ice text-fury-violet'
|
|
}`}
|
|
>
|
|
{game.result || 'Unknown'}
|
|
</p>
|
|
<p className="text-sm">{formatNumber(game.player_count)} players</p>
|
|
<p className="text-sm">{formatNumber(game.stats?.ground_kills)} ground</p>
|
|
<p className="text-sm">{formatNumber(game.stats?.air_kills)} air</p>
|
|
<p className="text-sm">{formatNumber(game.stats?.assists)} assists</p>
|
|
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
|
|
</div>
|
|
))}
|
|
{!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 }) {
|
|
return (
|
|
<section className="space-y-6 pt-10 sm:pt-14">
|
|
<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) => (
|
|
<div
|
|
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_1fr_auto_repeat(4,auto)] md:items-center"
|
|
key={match.session_id}
|
|
>
|
|
<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>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold text-fury-cyan">
|
|
{match.team_name || 'TSS team'}
|
|
</p>
|
|
<p className="truncate text-xs text-text-soft">{match.long_name || 'TSS battle record'}</p>
|
|
</div>
|
|
<p
|
|
className={`w-fit rounded-md px-3 py-1 text-sm font-semibold ${String(match.result).toLowerCase() === 'win'
|
|
? 'bg-surface text-fury-cyan'
|
|
: 'bg-fury-ice text-fury-violet'
|
|
}`}
|
|
>
|
|
{match.result || 'Unknown'}
|
|
</p>
|
|
<p className="text-sm">{formatNumber(match.player_count)} players</p>
|
|
<p className="text-sm">{formatNumber(match.stats?.ground_kills)} ground</p>
|
|
<p className="text-sm">{formatNumber(match.stats?.air_kills)} air</p>
|
|
<p className="text-sm">{formatNumber(match.stats?.deaths)} deaths</p>
|
|
</div>
|
|
))}
|
|
|
|
{!matches.length ? (
|
|
<p className="px-5 py-10 text-sm text-text-soft">
|
|
{live.status === 'loading' ? 'Loading battles' : 'No battles returned'}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function relativeSeconds(timestamp) {
|
|
if (!timestamp) return 'unknown'
|
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
|
|
if (seconds < 60) return `${seconds}s ago`
|
|
return `${Math.round(seconds / 60)}m ago`
|
|
}
|
|
|
|
const shortDateFormat = new Intl.DateTimeFormat('en-GB', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
})
|
|
|
|
const shortTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
|
|
const countryNames = {
|
|
AR: 'Argentina',
|
|
AU: 'Australia',
|
|
BR: 'Brazil',
|
|
CA: 'Canada',
|
|
CL: 'Chile',
|
|
CN: 'China',
|
|
DE: 'Germany',
|
|
ES: 'Spain',
|
|
FR: 'France',
|
|
GB: 'United Kingdom',
|
|
ID: 'Indonesia',
|
|
IN: 'India',
|
|
IT: 'Italy',
|
|
JP: 'Japan',
|
|
KR: 'South Korea',
|
|
MX: 'Mexico',
|
|
NL: 'Netherlands',
|
|
PL: 'Poland',
|
|
RU: 'Russia',
|
|
SE: 'Sweden',
|
|
SG: 'Singapore',
|
|
US: 'United States',
|
|
ZA: 'South Africa',
|
|
}
|
|
|
|
function 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="#fee5cd" strokeWidth="1" />
|
|
<path d={`M ${padding} ${padding} H ${width - padding}`} fill="none" stroke="#fee5cd" strokeDasharray="4 5" strokeWidth="1" />
|
|
<path d={`M ${padding} ${height / 2} H ${width - padding}`} fill="none" stroke="#fee5cd" 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="#000000"
|
|
/>
|
|
<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 || [],
|
|
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 || [],
|
|
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-fury-red text-[#ea9800] 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>
|
|
))}
|
|
</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.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="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) => ({
|
|
key: `${location.country}-${location.region}-${location.city}-${location.latitude}-${location.longitude}-${location.timezone}`,
|
|
place: location.city || location.region || location.timezone || 'Unknown city',
|
|
region: location.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 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 = [location.city, location.region, countryNames[location.country] || location.country]
|
|
.filter(Boolean)
|
|
.join(', ')
|
|
return {
|
|
...location,
|
|
lat,
|
|
lon,
|
|
label: place || location.country || 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
|
|
|
|
const 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}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 8,
|
|
}).addTo(map)
|
|
|
|
markersRef.current = {
|
|
layer: L.layerGroup().addTo(map),
|
|
map,
|
|
}
|
|
|
|
return () => {
|
|
markersRef.current = null
|
|
map.remove()
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!markersRef.current) 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, maxMarkerVisitors])
|
|
|
|
return (
|
|
<div className="p-5">
|
|
<div className="relative 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">
|
|
<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 flex h-28 items-end gap-1 rounded-md border border-border bg-bg p-3">
|
|
{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={`min-w-2 flex-1 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
|