ai generated solutions to our ai generated problems
This commit is contained in:
@@ -12,3 +12,6 @@ vite-dev*.log
|
||||
server-local*.log
|
||||
.local-storage/
|
||||
.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.
|
||||
+153
-58
@@ -27,6 +27,7 @@ const apiEndpoints = {
|
||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
||||
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
|
||||
searchPlayers: (name) => `/api/tss/players/search?q=${encodeURIComponent(name)}&limit=10`,
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
@@ -46,9 +47,12 @@ const analyticsConsentVersion = 3
|
||||
const themePreferenceKey = 'tssbot.theme'
|
||||
const themePreferenceCookie = 'tssbot_theme'
|
||||
const liveRefreshMs = 15000
|
||||
const siteVersion = 'v1'
|
||||
const siteVersion = '1.0.1'
|
||||
|
||||
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 = {
|
||||
chosen: false,
|
||||
@@ -61,6 +65,30 @@ const defaultAnalyticsPreferences = {
|
||||
version: analyticsConsentVersion,
|
||||
}
|
||||
|
||||
function staticDataPath(...segments) {
|
||||
return [staticDataBase, ...segments].join('/')
|
||||
}
|
||||
|
||||
function staticDataKey(value) {
|
||||
return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~')
|
||||
}
|
||||
|
||||
function dataSource(apiPath, staticPath = null) {
|
||||
return { apiPath, staticPath }
|
||||
}
|
||||
|
||||
const publicDataSources = {
|
||||
teams: dataSource(apiEndpoints.teams, staticDataPath('leaderboard-teams.json')),
|
||||
players: dataSource(apiEndpoints.players, staticDataPath('leaderboard-players.json')),
|
||||
homeTeams: dataSource(apiEndpoints.homeTeams, staticDataPath('home-teams.json')),
|
||||
recentGames: dataSource(apiEndpoints.recentGames, staticDataPath('recent-games.json')),
|
||||
detail: (name) => dataSource(apiEndpoints.detail(name), staticDataPath('teams', `${staticDataKey(name)}.json`)),
|
||||
games: (name) => dataSource(apiEndpoints.games(name), staticDataPath('teams', `${staticDataKey(name)}.games.json`)),
|
||||
player: (uid) => dataSource(apiEndpoints.player(uid), staticDataPath('players', `${staticDataKey(uid)}.json`)),
|
||||
game: (gameId) => dataSource(apiEndpoints.game(gameId), staticDataPath('games', `${staticDataKey(gameId)}.json`)),
|
||||
gameLogs: (gameId) => dataSource(apiEndpoints.gameLogs(gameId), staticDataPath('games', `${staticDataKey(gameId)}.logs.json`)),
|
||||
}
|
||||
|
||||
async function fetchJson(path, signal) {
|
||||
const response = await fetch(path, {
|
||||
signal,
|
||||
@@ -69,12 +97,29 @@ async function fetchJson(path, signal) {
|
||||
const body = await response.json().catch(() => null)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if (pathname === '/') return { page: 'home', teamName: '' }
|
||||
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||||
@@ -170,9 +215,9 @@ function ParticipantNames({ participants, spread = false }) {
|
||||
if (spread && participants.length === 2) {
|
||||
const [first, second] = participants
|
||||
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
|
||||
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}
|
||||
</span>
|
||||
@@ -180,7 +225,7 @@ function ParticipantNames({ participants, spread = false }) {
|
||||
vs
|
||||
</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}
|
||||
</span>
|
||||
@@ -206,6 +251,46 @@ function bestTeamName(team) {
|
||||
return team?.name || ''
|
||||
}
|
||||
|
||||
function searchKey(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function teamSearchResult(team) {
|
||||
const name = bestTeamName(team)
|
||||
return {
|
||||
kind: 'team',
|
||||
name,
|
||||
value: name,
|
||||
detail: 'Team',
|
||||
aliases: [team?.name].filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function playerSearchResult(player) {
|
||||
const uid = String(player?.uid || '').trim()
|
||||
const nick = String(player?.nick || '').trim()
|
||||
return {
|
||||
kind: 'player',
|
||||
name: nick || (uid ? `Player ${uid}` : ''),
|
||||
value: nick || uid,
|
||||
detail: uid ? `Player ${uid}` : 'Player',
|
||||
uid,
|
||||
aliases: [nick, uid].filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeSearchResults(results) {
|
||||
const seen = new Set()
|
||||
return results.filter((result) => {
|
||||
const key = result.kind === 'player' && result.uid
|
||||
? `player:${result.uid}`
|
||||
: `${result.kind}:${searchKey(result.name)}`
|
||||
if (!result.name || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function teamDetailLooksReal(detail) {
|
||||
if (!detail || typeof detail !== 'object') return false
|
||||
|
||||
@@ -664,7 +749,7 @@ function applySeo(route, profileDetail = null) {
|
||||
}
|
||||
|
||||
async function fetchRecentTssGames(signal) {
|
||||
return fetchJson(apiEndpoints.recentGames, signal)
|
||||
return fetchPublicJson(publicDataSources.recentGames, signal)
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
@@ -1186,35 +1271,28 @@ function AppContent() {
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => {
|
||||
setSearchHint({ status: 'loading', name: '' })
|
||||
fetchJson(apiEndpoints.searchTeams(query), controller.signal)
|
||||
.then((data) => {
|
||||
const results = (data.teams || data.results || [])
|
||||
.map((team) => ({
|
||||
name: bestTeamName(team),
|
||||
detail: '',
|
||||
aliases: [team.name].filter(Boolean),
|
||||
}))
|
||||
.filter((team) => team.name)
|
||||
.slice(0, 10)
|
||||
Promise.allSettled([
|
||||
fetchJson(apiEndpoints.searchTeams(query), controller.signal),
|
||||
fetchJson(apiEndpoints.searchPlayers(query), controller.signal),
|
||||
]).then(([teamResult, playerResult]) => {
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const teamResults = teamResult.status === 'fulfilled'
|
||||
? (teamResult.value.teams || teamResult.value.results || []).map(teamSearchResult)
|
||||
: []
|
||||
const playerResults = playerResult.status === 'fulfilled'
|
||||
? (playerResult.value.players || []).map(playerSearchResult)
|
||||
: []
|
||||
const results = dedupeSearchResults([...teamResults, ...playerResults]).slice(0, 10)
|
||||
|
||||
setTeamSearchResults(results)
|
||||
setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
fetchJson(apiEndpoints.resolve(query), controller.signal)
|
||||
.then((data) => {
|
||||
const name = data.name || ''
|
||||
setTeamSearchResults(name ? [{ name, detail: '', aliases: [name] }] : [])
|
||||
setSearchHint(name ? { status: 'ready', name } : { status: 'error', name: '' })
|
||||
})
|
||||
.catch(() => {
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setTeamSearchResults([])
|
||||
setSearchHint({ status: 'error', name: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
|
||||
return () => {
|
||||
@@ -1230,7 +1308,7 @@ function AppContent() {
|
||||
const controller = new AbortController()
|
||||
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 }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -1248,7 +1326,7 @@ function AppContent() {
|
||||
const controller = new AbortController()
|
||||
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 }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -1266,7 +1344,7 @@ function AppContent() {
|
||||
const controller = new AbortController()
|
||||
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 }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -1282,7 +1360,7 @@ function AppContent() {
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = window.setInterval(() => {
|
||||
fetchJson(apiEndpoints.teams, controller.signal)
|
||||
fetchPublicJson(publicDataSources.teams, controller.signal)
|
||||
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -1351,8 +1429,8 @@ function AppContent() {
|
||||
})
|
||||
|
||||
Promise.allSettled([
|
||||
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
||||
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
||||
fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
|
||||
fetchPublicJson(publicDataSources.games(route.teamName), controller.signal),
|
||||
])
|
||||
.then(([detailResult, gamesResult]) => {
|
||||
if (controller.signal.aborted) return
|
||||
@@ -1523,8 +1601,8 @@ function AppContent() {
|
||||
const controller = new AbortController()
|
||||
const timer = window.setInterval(() => {
|
||||
Promise.allSettled([
|
||||
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
||||
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
||||
fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
|
||||
fetchPublicJson(publicDataSources.games(route.teamName), controller.signal),
|
||||
]).then(([detailResult, gamesResult]) => {
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
@@ -1550,30 +1628,26 @@ function AppContent() {
|
||||
|
||||
const topTeamName = bestTeamName(teams[0])
|
||||
const localTeamSuggestions = useMemo(() => {
|
||||
const query = teamQuery.trim().toLowerCase()
|
||||
const seen = new Set()
|
||||
const query = searchKey(teamQuery)
|
||||
|
||||
return teams
|
||||
.map((team) => {
|
||||
const name = bestTeamName(team)
|
||||
const aliases = [team.name].filter(Boolean)
|
||||
return { name, detail: '', aliases }
|
||||
})
|
||||
return dedupeSearchResults(teams.map(teamSearchResult))
|
||||
.filter(({ name, aliases }) => {
|
||||
if (!name || seen.has(name)) return false
|
||||
seen.add(name)
|
||||
if (!query) return true
|
||||
return aliases.some((alias) => String(alias).toLowerCase().includes(query))
|
||||
return aliases.some((alias) => searchKey(alias).includes(query)) || searchKey(name).includes(query)
|
||||
})
|
||||
.slice(0, 10)
|
||||
}, [teamQuery, teams])
|
||||
const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions
|
||||
const selectedSearchResult = teamSuggestions.find((result) => {
|
||||
const query = searchKey(teamQuery)
|
||||
return searchKey(result.value) === query || result.aliases.some((alias) => searchKey(alias) === query)
|
||||
})
|
||||
const searchPlaceholder =
|
||||
searchHint.status === 'ready'
|
||||
? `Found ${searchHint.name}`
|
||||
: searchHint.status === 'error'
|
||||
? 'Team not found'
|
||||
: topTeamName || 'Search teams'
|
||||
? 'No team or player found'
|
||||
: topTeamName ? `${topTeamName}, or search players` : 'Search teams or players'
|
||||
|
||||
async function handleTeamSearch(event) {
|
||||
event.preventDefault()
|
||||
@@ -1581,19 +1655,36 @@ function AppContent() {
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
if (selectedSearchResult?.kind === 'player' && selectedSearchResult.uid) {
|
||||
navigate(playerPath(selectedSearchResult.uid))
|
||||
setTeamQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = await fetchJson(apiEndpoints.resolve(name))
|
||||
const resolvedName = resolved.name
|
||||
if (!resolvedName) throw new Error('Team not found')
|
||||
|
||||
const detail = await fetchJson(apiEndpoints.detail(resolvedName))
|
||||
const detail = await fetchPublicJson(publicDataSources.detail(resolvedName))
|
||||
if (!teamDetailLooksReal(detail)) throw new Error('Team not found')
|
||||
|
||||
navigate(teamPath(canonicalTeamName(detail, resolvedName)))
|
||||
setTeamQuery('')
|
||||
} catch (teamError) {
|
||||
try {
|
||||
const players = await fetchJson(apiEndpoints.searchPlayers(name))
|
||||
const player = (players.players || []).find((candidate) => (
|
||||
searchKey(candidate.nick) === searchKey(name) || searchKey(candidate.uid) === searchKey(name)
|
||||
)) || players.players?.[0]
|
||||
|
||||
if (!player?.uid) throw teamError
|
||||
navigate(playerPath(player.uid))
|
||||
setTeamQuery('')
|
||||
} catch {
|
||||
setSearchHint({ status: 'error', name: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseAnalyticsConsent(preferences) {
|
||||
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
||||
@@ -1665,7 +1756,7 @@ function AppContent() {
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = window.setInterval(() => {
|
||||
fetchJson(apiEndpoints.players, controller.signal)
|
||||
fetchPublicJson(publicDataSources.players, controller.signal)
|
||||
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -2130,7 +2221,7 @@ function PlayerPage({ uid, navigate }) {
|
||||
}
|
||||
let cancelled = false
|
||||
setState({ status: 'loading', data: null, error: '' })
|
||||
fetchJson(apiEndpoints.player(uid))
|
||||
fetchPublicJson(publicDataSources.player(uid))
|
||||
.then((data) => {
|
||||
if (!cancelled) setState({ status: 'ready', data, error: '' })
|
||||
})
|
||||
@@ -2289,15 +2380,19 @@ function Landing({
|
||||
onChange={(event) => setTeamQuery(event.target.value)}
|
||||
/>
|
||||
<datalist id="team-search-suggestions">
|
||||
{teamSuggestions.map((team) => (
|
||||
<option key={team.name} label={team.detail} value={team.name} />
|
||||
{teamSuggestions.map((result) => (
|
||||
<option
|
||||
key={`${result.kind}-${result.uid || result.name}`}
|
||||
label={result.detail}
|
||||
value={result.value}
|
||||
/>
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
||||
type="submit"
|
||||
>
|
||||
Search teams
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -3057,7 +3152,7 @@ function GamePage({ gameId, navigate }) {
|
||||
setGameState({ status: 'loading', data: null, error: null })
|
||||
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
||||
|
||||
fetchJson(apiEndpoints.game(gameId), controller.signal)
|
||||
fetchPublicJson(publicDataSources.game(gameId), controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
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) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLogs({
|
||||
@@ -3431,7 +3526,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
{matches.map((match) => (
|
||||
<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}
|
||||
onClick={() => navigate(gamePath(match.session_id))}
|
||||
type="button"
|
||||
|
||||
@@ -20,6 +20,12 @@ function isAllowedApiUrl(req) {
|
||||
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') {
|
||||
const keys = [...params.keys()]
|
||||
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
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user