ai generated solutions to our ai generated problems
This commit is contained in:
@@ -12,3 +12,6 @@ vite-dev*.log
|
|||||||
server-local*.log
|
server-local*.log
|
||||||
.local-storage/
|
.local-storage/
|
||||||
.claude/
|
.claude/
|
||||||
|
frontend/public/data/*
|
||||||
|
!frontend/public/data/
|
||||||
|
!frontend/public/data/README.md
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Static Public Data
|
||||||
|
|
||||||
|
The frontend tries these JSON snapshots before falling back to the live API:
|
||||||
|
|
||||||
|
- `/data/leaderboard-teams.json`
|
||||||
|
- `/data/leaderboard-players.json`
|
||||||
|
- `/data/home-teams.json`
|
||||||
|
- `/data/recent-games.json`
|
||||||
|
- `/data/teams/{key}.json`
|
||||||
|
- `/data/teams/{key}.games.json`
|
||||||
|
- `/data/players/{key}.json`
|
||||||
|
- `/data/games/{key}.json`
|
||||||
|
- `/data/games/{key}.logs.json`
|
||||||
|
|
||||||
|
`{key}` is `encodeURIComponent(value).replace(/%/g, '~')`.
|
||||||
|
|
||||||
|
Snapshot files should keep the same response shape as the matching `/api/tss/*`
|
||||||
|
endpoint. Missing files are fine: the app remembers the miss for the current
|
||||||
|
browser session and uses the existing API route instead.
|
||||||
|
|
||||||
|
Do not generate every possible entity forever. Keep `/data` bounded:
|
||||||
|
|
||||||
|
- Always generate the small global snapshots: leaderboards, home teams, recent
|
||||||
|
games.
|
||||||
|
- Generate team/player/game detail snapshots only for hot items, such as current
|
||||||
|
leaderboard entries, recent games, recently viewed pages, or pinned items.
|
||||||
|
- Prune detail snapshots by age and access. For example: keep recent games for
|
||||||
|
7-30 days, keep leaderboard teams/players while they remain ranked, and delete
|
||||||
|
cold files that have not been refreshed recently.
|
||||||
|
- Write snapshots atomically (`file.tmp` then rename) so readers never see a
|
||||||
|
partial JSON file.
|
||||||
|
- Serve compressed responses from the web server/CDN when possible. Keep source
|
||||||
|
JSON minified unless humans need to inspect a fixture.
|
||||||
+162
-67
@@ -27,6 +27,7 @@ const apiEndpoints = {
|
|||||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||||
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
||||||
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
|
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
|
||||||
|
searchPlayers: (name) => `/api/tss/players/search?q=${encodeURIComponent(name)}&limit=10`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -46,9 +47,12 @@ const analyticsConsentVersion = 3
|
|||||||
const themePreferenceKey = 'tssbot.theme'
|
const themePreferenceKey = 'tssbot.theme'
|
||||||
const themePreferenceCookie = 'tssbot_theme'
|
const themePreferenceCookie = 'tssbot_theme'
|
||||||
const liveRefreshMs = 15000
|
const liveRefreshMs = 15000
|
||||||
const siteVersion = 'v1'
|
const siteVersion = '1.0.1'
|
||||||
|
|
||||||
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||||
|
const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '')
|
||||||
|
const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'true').toLowerCase() !== 'false'
|
||||||
|
const missingStaticDataPaths = new Set()
|
||||||
|
|
||||||
const defaultAnalyticsPreferences = {
|
const defaultAnalyticsPreferences = {
|
||||||
chosen: false,
|
chosen: false,
|
||||||
@@ -61,6 +65,30 @@ const defaultAnalyticsPreferences = {
|
|||||||
version: analyticsConsentVersion,
|
version: analyticsConsentVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function staticDataPath(...segments) {
|
||||||
|
return [staticDataBase, ...segments].join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticDataKey(value) {
|
||||||
|
return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataSource(apiPath, staticPath = null) {
|
||||||
|
return { apiPath, staticPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicDataSources = {
|
||||||
|
teams: dataSource(apiEndpoints.teams, staticDataPath('leaderboard-teams.json')),
|
||||||
|
players: dataSource(apiEndpoints.players, staticDataPath('leaderboard-players.json')),
|
||||||
|
homeTeams: dataSource(apiEndpoints.homeTeams, staticDataPath('home-teams.json')),
|
||||||
|
recentGames: dataSource(apiEndpoints.recentGames, staticDataPath('recent-games.json')),
|
||||||
|
detail: (name) => dataSource(apiEndpoints.detail(name), staticDataPath('teams', `${staticDataKey(name)}.json`)),
|
||||||
|
games: (name) => dataSource(apiEndpoints.games(name), staticDataPath('teams', `${staticDataKey(name)}.games.json`)),
|
||||||
|
player: (uid) => dataSource(apiEndpoints.player(uid), staticDataPath('players', `${staticDataKey(uid)}.json`)),
|
||||||
|
game: (gameId) => dataSource(apiEndpoints.game(gameId), staticDataPath('games', `${staticDataKey(gameId)}.json`)),
|
||||||
|
gameLogs: (gameId) => dataSource(apiEndpoints.gameLogs(gameId), staticDataPath('games', `${staticDataKey(gameId)}.logs.json`)),
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson(path, signal) {
|
async function fetchJson(path, signal) {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
signal,
|
signal,
|
||||||
@@ -69,12 +97,29 @@ async function fetchJson(path, signal) {
|
|||||||
const body = await response.json().catch(() => null)
|
const body = await response.json().catch(() => null)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(body?.error || `Request failed with ${response.status}`)
|
const error = new Error(body?.error || `Request failed with ${response.status}`)
|
||||||
|
error.status = response.status
|
||||||
|
error.path = path
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPublicJson(source, signal) {
|
||||||
|
if (!source?.staticPath || !staticDataEnabled || missingStaticDataPaths.has(source.staticPath)) {
|
||||||
|
return fetchJson(source.apiPath, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetchJson(source.staticPath, signal)
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted || error?.name === 'AbortError') throw error
|
||||||
|
if (error?.status === 404) missingStaticDataPaths.add(source.staticPath)
|
||||||
|
return fetchJson(source.apiPath, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseRoute(pathname = window.location.pathname) {
|
function parseRoute(pathname = window.location.pathname) {
|
||||||
if (pathname === '/') return { page: 'home', teamName: '' }
|
if (pathname === '/') return { page: 'home', teamName: '' }
|
||||||
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||||||
@@ -170,9 +215,9 @@ function ParticipantNames({ participants, spread = false }) {
|
|||||||
if (spread && participants.length === 2) {
|
if (spread && participants.length === 2) {
|
||||||
const [first, second] = participants
|
const [first, second] = participants
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-x-3">
|
<div className="mx-auto grid w-full max-w-xl min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-4">
|
||||||
<span
|
<span
|
||||||
className={`min-w-0 flex-1 truncate text-left text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
|
className={`min-w-0 truncate text-right text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||||
>
|
>
|
||||||
{first.name}
|
{first.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -180,7 +225,7 @@ function ParticipantNames({ participants, spread = false }) {
|
|||||||
vs
|
vs
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`min-w-0 flex-1 truncate text-right text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
|
className={`min-w-0 truncate text-left text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||||
>
|
>
|
||||||
{second.name}
|
{second.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -206,6 +251,46 @@ function bestTeamName(team) {
|
|||||||
return team?.name || ''
|
return team?.name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchKey(value) {
|
||||||
|
return String(value || '').trim().toLocaleLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamSearchResult(team) {
|
||||||
|
const name = bestTeamName(team)
|
||||||
|
return {
|
||||||
|
kind: 'team',
|
||||||
|
name,
|
||||||
|
value: name,
|
||||||
|
detail: 'Team',
|
||||||
|
aliases: [team?.name].filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerSearchResult(player) {
|
||||||
|
const uid = String(player?.uid || '').trim()
|
||||||
|
const nick = String(player?.nick || '').trim()
|
||||||
|
return {
|
||||||
|
kind: 'player',
|
||||||
|
name: nick || (uid ? `Player ${uid}` : ''),
|
||||||
|
value: nick || uid,
|
||||||
|
detail: uid ? `Player ${uid}` : 'Player',
|
||||||
|
uid,
|
||||||
|
aliases: [nick, uid].filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSearchResults(results) {
|
||||||
|
const seen = new Set()
|
||||||
|
return results.filter((result) => {
|
||||||
|
const key = result.kind === 'player' && result.uid
|
||||||
|
? `player:${result.uid}`
|
||||||
|
: `${result.kind}:${searchKey(result.name)}`
|
||||||
|
if (!result.name || seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function teamDetailLooksReal(detail) {
|
function teamDetailLooksReal(detail) {
|
||||||
if (!detail || typeof detail !== 'object') return false
|
if (!detail || typeof detail !== 'object') return false
|
||||||
|
|
||||||
@@ -664,7 +749,7 @@ function applySeo(route, profileDetail = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRecentTssGames(signal) {
|
async function fetchRecentTssGames(signal) {
|
||||||
return fetchJson(apiEndpoints.recentGames, signal)
|
return fetchPublicJson(publicDataSources.recentGames, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }) {
|
function Stat({ label, value }) {
|
||||||
@@ -1186,35 +1271,28 @@ function AppContent() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setSearchHint({ status: 'loading', name: '' })
|
setSearchHint({ status: 'loading', name: '' })
|
||||||
fetchJson(apiEndpoints.searchTeams(query), controller.signal)
|
Promise.allSettled([
|
||||||
.then((data) => {
|
fetchJson(apiEndpoints.searchTeams(query), controller.signal),
|
||||||
const results = (data.teams || data.results || [])
|
fetchJson(apiEndpoints.searchPlayers(query), controller.signal),
|
||||||
.map((team) => ({
|
]).then(([teamResult, playerResult]) => {
|
||||||
name: bestTeamName(team),
|
if (controller.signal.aborted) return
|
||||||
detail: '',
|
|
||||||
aliases: [team.name].filter(Boolean),
|
const teamResults = teamResult.status === 'fulfilled'
|
||||||
}))
|
? (teamResult.value.teams || teamResult.value.results || []).map(teamSearchResult)
|
||||||
.filter((team) => team.name)
|
: []
|
||||||
.slice(0, 10)
|
const playerResults = playerResult.status === 'fulfilled'
|
||||||
setTeamSearchResults(results)
|
? (playerResult.value.players || []).map(playerSearchResult)
|
||||||
setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' })
|
: []
|
||||||
})
|
const results = dedupeSearchResults([...teamResults, ...playerResults]).slice(0, 10)
|
||||||
.catch(() => {
|
|
||||||
if (!controller.signal.aborted) {
|
setTeamSearchResults(results)
|
||||||
fetchJson(apiEndpoints.resolve(query), controller.signal)
|
setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' })
|
||||||
.then((data) => {
|
}).catch(() => {
|
||||||
const name = data.name || ''
|
if (!controller.signal.aborted) {
|
||||||
setTeamSearchResults(name ? [{ name, detail: '', aliases: [name] }] : [])
|
setTeamSearchResults([])
|
||||||
setSearchHint(name ? { status: 'ready', name } : { status: 'error', name: '' })
|
setSearchHint({ status: 'error', name: '' })
|
||||||
})
|
}
|
||||||
.catch(() => {
|
})
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setTeamSearchResults([])
|
|
||||||
setSearchHint({ status: 'error', name: '' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, 350)
|
}, 350)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1230,7 +1308,7 @@ function AppContent() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
setLeaderboard({ status: 'loading', data: null, error: null })
|
setLeaderboard({ status: 'loading', data: null, error: null })
|
||||||
|
|
||||||
fetchJson(apiEndpoints.teams, controller.signal)
|
fetchPublicJson(publicDataSources.teams, controller.signal)
|
||||||
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -1248,7 +1326,7 @@ function AppContent() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
setPlayerLeaderboard({ status: 'loading', data: null, error: null })
|
setPlayerLeaderboard({ status: 'loading', data: null, error: null })
|
||||||
|
|
||||||
fetchJson(apiEndpoints.players, controller.signal)
|
fetchPublicJson(publicDataSources.players, controller.signal)
|
||||||
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -1266,7 +1344,7 @@ function AppContent() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
setHomeTeams({ status: 'loading', data: null, error: null })
|
setHomeTeams({ status: 'loading', data: null, error: null })
|
||||||
|
|
||||||
fetchJson(apiEndpoints.homeTeams, controller.signal)
|
fetchPublicJson(publicDataSources.homeTeams, controller.signal)
|
||||||
.then((data) => setHomeTeams({ status: 'ready', data, error: null }))
|
.then((data) => setHomeTeams({ status: 'ready', data, error: null }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -1282,7 +1360,7 @@ function AppContent() {
|
|||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
fetchJson(apiEndpoints.teams, controller.signal)
|
fetchPublicJson(publicDataSources.teams, controller.signal)
|
||||||
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -1351,8 +1429,8 @@ function AppContent() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
|
||||||
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
fetchPublicJson(publicDataSources.games(route.teamName), controller.signal),
|
||||||
])
|
])
|
||||||
.then(([detailResult, gamesResult]) => {
|
.then(([detailResult, gamesResult]) => {
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
@@ -1523,8 +1601,8 @@ function AppContent() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
|
||||||
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
fetchPublicJson(publicDataSources.games(route.teamName), controller.signal),
|
||||||
]).then(([detailResult, gamesResult]) => {
|
]).then(([detailResult, gamesResult]) => {
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
|
|
||||||
@@ -1550,30 +1628,26 @@ function AppContent() {
|
|||||||
|
|
||||||
const topTeamName = bestTeamName(teams[0])
|
const topTeamName = bestTeamName(teams[0])
|
||||||
const localTeamSuggestions = useMemo(() => {
|
const localTeamSuggestions = useMemo(() => {
|
||||||
const query = teamQuery.trim().toLowerCase()
|
const query = searchKey(teamQuery)
|
||||||
const seen = new Set()
|
|
||||||
|
|
||||||
return teams
|
return dedupeSearchResults(teams.map(teamSearchResult))
|
||||||
.map((team) => {
|
|
||||||
const name = bestTeamName(team)
|
|
||||||
const aliases = [team.name].filter(Boolean)
|
|
||||||
return { name, detail: '', aliases }
|
|
||||||
})
|
|
||||||
.filter(({ name, aliases }) => {
|
.filter(({ name, aliases }) => {
|
||||||
if (!name || seen.has(name)) return false
|
|
||||||
seen.add(name)
|
|
||||||
if (!query) return true
|
if (!query) return true
|
||||||
return aliases.some((alias) => String(alias).toLowerCase().includes(query))
|
return aliases.some((alias) => searchKey(alias).includes(query)) || searchKey(name).includes(query)
|
||||||
})
|
})
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
}, [teamQuery, teams])
|
}, [teamQuery, teams])
|
||||||
const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions
|
const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions
|
||||||
|
const selectedSearchResult = teamSuggestions.find((result) => {
|
||||||
|
const query = searchKey(teamQuery)
|
||||||
|
return searchKey(result.value) === query || result.aliases.some((alias) => searchKey(alias) === query)
|
||||||
|
})
|
||||||
const searchPlaceholder =
|
const searchPlaceholder =
|
||||||
searchHint.status === 'ready'
|
searchHint.status === 'ready'
|
||||||
? `Found ${searchHint.name}`
|
? `Found ${searchHint.name}`
|
||||||
: searchHint.status === 'error'
|
: searchHint.status === 'error'
|
||||||
? 'Team not found'
|
? 'No team or player found'
|
||||||
: topTeamName || 'Search teams'
|
: topTeamName ? `${topTeamName}, or search players` : 'Search teams or players'
|
||||||
|
|
||||||
async function handleTeamSearch(event) {
|
async function handleTeamSearch(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1581,17 +1655,34 @@ function AppContent() {
|
|||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (selectedSearchResult?.kind === 'player' && selectedSearchResult.uid) {
|
||||||
|
navigate(playerPath(selectedSearchResult.uid))
|
||||||
|
setTeamQuery('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const resolved = await fetchJson(apiEndpoints.resolve(name))
|
const resolved = await fetchJson(apiEndpoints.resolve(name))
|
||||||
const resolvedName = resolved.name
|
const resolvedName = resolved.name
|
||||||
if (!resolvedName) throw new Error('Team not found')
|
if (!resolvedName) throw new Error('Team not found')
|
||||||
|
|
||||||
const detail = await fetchJson(apiEndpoints.detail(resolvedName))
|
const detail = await fetchPublicJson(publicDataSources.detail(resolvedName))
|
||||||
if (!teamDetailLooksReal(detail)) throw new Error('Team not found')
|
if (!teamDetailLooksReal(detail)) throw new Error('Team not found')
|
||||||
|
|
||||||
navigate(teamPath(canonicalTeamName(detail, resolvedName)))
|
navigate(teamPath(canonicalTeamName(detail, resolvedName)))
|
||||||
setTeamQuery('')
|
setTeamQuery('')
|
||||||
} catch {
|
} catch (teamError) {
|
||||||
setSearchHint({ status: 'error', name: '' })
|
try {
|
||||||
|
const players = await fetchJson(apiEndpoints.searchPlayers(name))
|
||||||
|
const player = (players.players || []).find((candidate) => (
|
||||||
|
searchKey(candidate.nick) === searchKey(name) || searchKey(candidate.uid) === searchKey(name)
|
||||||
|
)) || players.players?.[0]
|
||||||
|
|
||||||
|
if (!player?.uid) throw teamError
|
||||||
|
navigate(playerPath(player.uid))
|
||||||
|
setTeamQuery('')
|
||||||
|
} catch {
|
||||||
|
setSearchHint({ status: 'error', name: '' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1665,7 +1756,7 @@ function AppContent() {
|
|||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
fetchJson(apiEndpoints.players, controller.signal)
|
fetchPublicJson(publicDataSources.players, controller.signal)
|
||||||
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -2130,7 +2221,7 @@ function PlayerPage({ uid, navigate }) {
|
|||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setState({ status: 'loading', data: null, error: '' })
|
setState({ status: 'loading', data: null, error: '' })
|
||||||
fetchJson(apiEndpoints.player(uid))
|
fetchPublicJson(publicDataSources.player(uid))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setState({ status: 'ready', data, error: '' })
|
if (!cancelled) setState({ status: 'ready', data, error: '' })
|
||||||
})
|
})
|
||||||
@@ -2289,15 +2380,19 @@ function Landing({
|
|||||||
onChange={(event) => setTeamQuery(event.target.value)}
|
onChange={(event) => setTeamQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<datalist id="team-search-suggestions">
|
<datalist id="team-search-suggestions">
|
||||||
{teamSuggestions.map((team) => (
|
{teamSuggestions.map((result) => (
|
||||||
<option key={team.name} label={team.detail} value={team.name} />
|
<option
|
||||||
|
key={`${result.kind}-${result.uid || result.name}`}
|
||||||
|
label={result.detail}
|
||||||
|
value={result.value}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Search teams
|
Search
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -3057,7 +3152,7 @@ function GamePage({ gameId, navigate }) {
|
|||||||
setGameState({ status: 'loading', data: null, error: null })
|
setGameState({ status: 'loading', data: null, error: null })
|
||||||
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
||||||
|
|
||||||
fetchJson(apiEndpoints.game(gameId), controller.signal)
|
fetchPublicJson(publicDataSources.game(gameId), controller.signal)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setGameState({ status: 'ready', data, error: null })
|
setGameState({ status: 'ready', data, error: null })
|
||||||
@@ -3069,7 +3164,7 @@ function GamePage({ gameId, navigate }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
fetchJson(apiEndpoints.gameLogs(gameId), controller.signal)
|
fetchPublicJson(publicDataSources.gameLogs(gameId), controller.signal)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setLogs({
|
setLogs({
|
||||||
@@ -3431,7 +3526,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
|||||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
{matches.map((match) => (
|
{matches.map((match) => (
|
||||||
<button
|
<button
|
||||||
className="grid w-full gap-x-8 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.7fr)_auto] md:items-center"
|
className="grid w-full gap-x-6 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)_auto] md:items-center"
|
||||||
key={match.session_id}
|
key={match.session_id}
|
||||||
onClick={() => navigate(gamePath(match.session_id))}
|
onClick={() => navigate(gamePath(match.session_id))}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ function isAllowedApiUrl(req) {
|
|||||||
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
|
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/tss/leaderboard/players') {
|
||||||
|
const keys = [...params.keys()]
|
||||||
|
const limit = Number(params.get('limit') || 100)
|
||||||
|
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/tss/games/recent') {
|
if (url.pathname === '/api/tss/games/recent') {
|
||||||
const keys = [...params.keys()]
|
const keys = [...params.keys()]
|
||||||
const limit = Number(params.get('limit') || 50)
|
const limit = Number(params.get('limit') || 50)
|
||||||
@@ -56,6 +62,24 @@ function isAllowedApiUrl(req) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/tss/players/search') {
|
||||||
|
const keys = [...params.keys()]
|
||||||
|
const query = params.get('q') || params.get('name') || ''
|
||||||
|
const limit = Number(params.get('limit') || 25)
|
||||||
|
return (
|
||||||
|
keys.every((key) => ['q', 'name', 'limit'].includes(key)) &&
|
||||||
|
query.length >= 2 &&
|
||||||
|
query.length <= MAX_TEAM_NAME_LENGTH &&
|
||||||
|
Number.isInteger(limit) &&
|
||||||
|
limit >= 1 &&
|
||||||
|
limit <= 25
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/api\/tss\/player\/[0-9]{1,32}$/.test(url.pathname)) {
|
||||||
|
return [...params.keys()].length === 0
|
||||||
|
}
|
||||||
|
|
||||||
if ([...params.keys()].length) return false
|
if ([...params.keys()].length) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user