feat: Integrate player meta stats, match metadata, and sitemap proxies
- Add player meta stats API endpoint with teammates, weapons, and map data - Display avg_rank badge on match cards and match detail page - Add tick rate and demo download improvements to match layout - Create sitemap proxy routes to backend for SEO - Document backend data limitations in transformers (rounds/weapons) - Fix 400 error: backend limits meta stats to max 10 items 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client';
|
||||
import { parsePlayer } from '$lib/schemas';
|
||||
import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
|
||||
import type { Player, PlayerMeta, PlayerMetaStats, TrackPlayerResponse } from '$lib/types';
|
||||
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
|
||||
|
||||
/**
|
||||
@@ -85,6 +85,18 @@ export const playersAPI = {
|
||||
return playerMeta;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get player aggregated meta stats from backend
|
||||
* Uses pre-computed stats (cached 30 days) including teammates, weapons, maps
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param limit - Number of items per category (max 10, default: 4)
|
||||
* @returns Player meta stats with teammates, weapons, map performance
|
||||
*/
|
||||
async getPlayerMetaStats(steamId: string, limit = 4): Promise<PlayerMetaStats> {
|
||||
const url = `/player/${steamId}/meta/${limit}`;
|
||||
return apiClient.get<PlayerMetaStats>(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add player to tracking system
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
|
||||
@@ -55,6 +55,9 @@ export interface LegacyMatchDetail {
|
||||
vac: boolean; // NOT vac_present
|
||||
game_ban: boolean; // NOT gameban_present
|
||||
stats?: LegacyPlayerStats[]; // Player stats array
|
||||
tick_rate?: number; // Server tick rate (64 or 128)
|
||||
avg_rank?: number; // Average Premier rating (backend computed)
|
||||
replay_url?: string; // Demo download URL (if < 30 days old)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,6 +222,9 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
||||
demo_parsed: legacy.parsed,
|
||||
vac_present: legacy.vac,
|
||||
gameban_present: legacy.game_ban,
|
||||
tick_rate: legacy.tick_rate,
|
||||
avg_rank: legacy.avg_rank,
|
||||
replay_url: legacy.replay_url,
|
||||
players: legacy.stats?.map(transformPlayerStats)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/t
|
||||
|
||||
/**
|
||||
* Transform raw rounds API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
*
|
||||
* NOTE: The backend API only provides economy data (bank/equipment/spent) per round.
|
||||
* Round winners and win reasons are not currently stored in the database.
|
||||
* To add this data would require backend changes to the demo parser.
|
||||
*
|
||||
* @param rawData - Raw API response containing economy data per round
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured rounds data
|
||||
* @param match - Match data with player information and final score
|
||||
* @returns Structured rounds data with economy info (winner/win_reason unavailable)
|
||||
*/
|
||||
export function transformRoundsResponse(
|
||||
rawData: RoundsAPIResponse,
|
||||
@@ -15,7 +20,7 @@ export function transformRoundsResponse(
|
||||
): MatchRoundsResponse {
|
||||
const rounds: RoundDetail[] = [];
|
||||
|
||||
// Create player ID to team mapping
|
||||
// Create player ID to team mapping for potential future use
|
||||
const playerTeamMap = new Map<string, number>();
|
||||
if (match?.players) {
|
||||
for (const player of match.players) {
|
||||
@@ -47,8 +52,10 @@ export function transformRoundsResponse(
|
||||
|
||||
rounds.push({
|
||||
round: roundNum + 1,
|
||||
winner: 0, // TODO: Determine winner from data if available
|
||||
win_reason: '', // TODO: Determine win reason if available
|
||||
// Round winner data not available from backend API
|
||||
// Would require demo parser changes to store RoundEnd event winners
|
||||
winner: 0,
|
||||
win_reason: '',
|
||||
players
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from
|
||||
|
||||
/**
|
||||
* Transform raw weapons API response into structured format
|
||||
* @param rawData - Raw API response
|
||||
*
|
||||
* NOTE: The backend API provides hit/damage data per weapon but not kill counts.
|
||||
* Kill tracking would require the demo parser to correlate damage events with
|
||||
* player death events. Currently only aggregated damage and hit group data is available.
|
||||
*
|
||||
* @param rawData - Raw API response containing damage/hit data per weapon
|
||||
* @param matchId - Match ID
|
||||
* @param match - Match data with player information
|
||||
* @returns Structured weapons data
|
||||
* @returns Structured weapons data with damage stats (kills unavailable)
|
||||
*/
|
||||
export function transformWeaponsResponse(
|
||||
rawData: WeaponsAPIResponse,
|
||||
@@ -77,7 +82,9 @@ export function transformWeaponsResponse(
|
||||
weapon_stats.push({
|
||||
eq_type: eqType,
|
||||
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`,
|
||||
kills: 0, // TODO: Calculate kills if needed
|
||||
// Kill data not available - API only provides hit/damage events
|
||||
// Would require backend changes to correlate damage with death events
|
||||
kills: 0,
|
||||
damage: stats.damage,
|
||||
hits: stats.hits,
|
||||
hit_groups: hitGroupCounts,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
import { storeMatchesState, type FilterState } from '$lib/utils/navigation';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
match: MatchListItem;
|
||||
@@ -87,6 +88,11 @@
|
||||
</div>
|
||||
<!-- Status badges - horizontal layout -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if match.avg_rank && match.avg_rank > 0}
|
||||
<div class="backdrop-blur-sm" title="Average player rating">
|
||||
<PremierRatingBadge rating={match.avg_rank} size="sm" showTier={false} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if match.player_count}
|
||||
<span
|
||||
class="rounded-full border border-neon-blue/30 bg-neon-blue/20 px-1.5 py-0.5 text-[10px] text-neon-blue backdrop-blur-sm"
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface Match {
|
||||
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
||||
tick_rate?: number;
|
||||
|
||||
/** Average Premier rating of all players in the match - optional, backend computed */
|
||||
avg_rank?: number;
|
||||
|
||||
/** Demo replay download URL (only available for matches < 30 days old) */
|
||||
replay_url?: string;
|
||||
|
||||
/**
|
||||
* Game mode: 'premier' | 'competitive' | 'wingman'
|
||||
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
|
||||
@@ -67,6 +73,8 @@ export interface MatchListItem {
|
||||
duration: number;
|
||||
demo_parsed: boolean;
|
||||
player_count?: number;
|
||||
/** Average Premier rating of all players (backend computed, optional) */
|
||||
avg_rank?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,3 +119,79 @@ export interface PlayerProfile extends Player {
|
||||
/** Peak CS2 Premier rating */
|
||||
peak_rating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teammate statistics from backend meta endpoint
|
||||
*/
|
||||
export interface TeammateStats {
|
||||
/** Player profile */
|
||||
player: {
|
||||
steamid64: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
vac?: boolean;
|
||||
game_ban?: boolean;
|
||||
};
|
||||
/** Win rate when playing together (0-1) */
|
||||
win_rate?: number;
|
||||
/** Tie rate when playing together (0-1) */
|
||||
tie_rate?: number;
|
||||
/** Total matches played together */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weapon damage statistics from backend meta endpoint
|
||||
*/
|
||||
export interface WeaponDamageStats {
|
||||
/** Equipment type ID */
|
||||
eq: number;
|
||||
/** Total damage dealt with this weapon */
|
||||
dmg: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map statistics from backend meta endpoint
|
||||
*/
|
||||
export interface MapStats {
|
||||
/** Map name (e.g., "de_inferno") */
|
||||
map: string;
|
||||
/** Win rate on this map (0-1) */
|
||||
win_rate: number;
|
||||
/** Tie rate on this map (0-1) */
|
||||
tie_rate: number;
|
||||
/** Total matches on this map */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full player meta statistics from /player/:id/meta endpoint
|
||||
* Contains pre-aggregated stats from the backend (cached for 30 days)
|
||||
*/
|
||||
export interface PlayerMetaStats {
|
||||
/** Basic player info */
|
||||
player: {
|
||||
steamid64: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
vac?: boolean;
|
||||
vac_date?: number;
|
||||
game_ban?: boolean;
|
||||
game_ban_date?: number;
|
||||
tracked?: boolean;
|
||||
};
|
||||
/** Best teammates sorted by win rate */
|
||||
best_mates?: TeammateStats[];
|
||||
/** Most played teammates sorted by total games */
|
||||
most_mates?: TeammateStats[];
|
||||
/** Equipment ID to name mapping */
|
||||
eq_map?: Record<number, string>;
|
||||
/** Weapon damage stats sorted by damage */
|
||||
weapon_dmg?: WeaponDamageStats[];
|
||||
/** Win rate per map (map name -> rate 0-1) */
|
||||
win_maps?: Record<string, number>;
|
||||
/** Tie rate per map (map name -> rate 0-1) */
|
||||
tie_maps?: Record<string, number>;
|
||||
/** Total matches per map (map name -> count) */
|
||||
total_maps?: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,16 @@ export type { ChatAPIResponse } from './api/ChatAPIResponse';
|
||||
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
|
||||
|
||||
// Player types
|
||||
export type { Player, PlayerMatch, PlayerMeta, PlayerProfile } from './Player';
|
||||
export type {
|
||||
Player,
|
||||
PlayerMatch,
|
||||
PlayerMeta,
|
||||
PlayerProfile,
|
||||
PlayerMetaStats,
|
||||
TeammateStats,
|
||||
WeaponDamageStats,
|
||||
MapStats
|
||||
} from './Player';
|
||||
|
||||
// Round statistics types
|
||||
export type {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Download, Calendar, Clock, ArrowLeft } from 'lucide-svelte';
|
||||
import { Download, Calendar, Clock, ArrowLeft, Server, Users } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
|
||||
@@ -42,6 +43,12 @@
|
||||
}
|
||||
|
||||
function handleDownloadDemo() {
|
||||
// Prefer direct replay_url if available (faster download)
|
||||
if (match.replay_url) {
|
||||
window.open(match.replay_url, '_blank');
|
||||
return;
|
||||
}
|
||||
// Fall back to Steam share code
|
||||
if (!match.share_code) {
|
||||
alert('Share code not available for this match');
|
||||
return;
|
||||
@@ -49,6 +56,9 @@
|
||||
const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`;
|
||||
window.location.href = downloadUrl;
|
||||
}
|
||||
|
||||
// Check if demo download is available
|
||||
const canDownloadDemo = match.replay_url || (match.demo_parsed && match.share_code);
|
||||
</script>
|
||||
|
||||
<!-- Match Header with Background -->
|
||||
@@ -95,11 +105,13 @@
|
||||
{mapName}
|
||||
</h1>
|
||||
</div>
|
||||
{#if match.demo_parsed && match.share_code}
|
||||
{#if canDownloadDemo}
|
||||
<button
|
||||
onclick={handleDownloadDemo}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-neon-blue/30 bg-neon-blue/10 px-4 py-2 text-sm font-medium text-neon-blue backdrop-blur-md transition-all duration-200 hover:border-neon-blue/50 hover:bg-neon-blue/20 hover:shadow-[0_0_20px_rgba(0,212,255,0.2)]"
|
||||
title="Download this match demo to your Steam client"
|
||||
title={match.replay_url
|
||||
? 'Download demo file directly'
|
||||
: 'Download this match demo to your Steam client'}
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Download Demo</span>
|
||||
@@ -151,6 +163,21 @@
|
||||
</div>
|
||||
<span class="text-white/20">•</span>
|
||||
<span>MR12 ({match.max_rounds} rounds)</span>
|
||||
{#if match.tick_rate}
|
||||
<span class="text-white/20">•</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Server class="h-3.5 w-3.5 text-neon-purple" />
|
||||
<span class="font-mono">{match.tick_rate} tick</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if match.avg_rank && match.avg_rank > 0}
|
||||
<span class="text-white/20">•</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Users class="h-3.5 w-3.5 text-neon-gold" />
|
||||
<span>Avg Rating:</span>
|
||||
<PremierRatingBadge rating={match.avg_rank} size="sm" showTier={false} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if match.demo_parsed}
|
||||
<span class="text-white/20">•</span>
|
||||
<Badge variant="success" size="sm">Demo Parsed</Badge>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { profile, recentMatches, playerStats } = data;
|
||||
const { profile, recentMatches, playerStats, metaStats } = data;
|
||||
|
||||
// Track this player visit
|
||||
onMount(() => {
|
||||
@@ -807,6 +807,203 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Teammates Section (from pre-aggregated meta stats) -->
|
||||
{#if metaStats && (metaStats.best_mates?.length || metaStats.most_mates?.length)}
|
||||
<div>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20"
|
||||
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
|
||||
>
|
||||
<Users class="h-5 w-5 text-neon-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Teammates</h2>
|
||||
<p class="text-sm text-white/50">Who they play with</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Best Teammates (by win rate) -->
|
||||
{#if metaStats.best_mates && metaStats.best_mates.length > 0}
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Trophy class="h-5 w-5 text-neon-gold" />
|
||||
Best Teammates
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each metaStats.best_mates.slice(0, 5) as teammate}
|
||||
{@const winRatePercent = (teammate.win_rate ?? 0) * 100}
|
||||
<a
|
||||
href="/player/{teammate.player.steamid64}"
|
||||
class="group flex items-center gap-3 rounded-lg bg-white/5 p-3 transition-all hover:bg-white/10"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-void"
|
||||
>
|
||||
{#if teammate.player.avatar}
|
||||
<img
|
||||
src={teammate.player.avatar}
|
||||
alt={teammate.player.name || 'Player'}
|
||||
class="h-full w-full rounded-full"
|
||||
/>
|
||||
{:else}
|
||||
<User class="h-5 w-5 text-white/50" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-white group-hover:text-neon-blue">
|
||||
{teammate.player.name || `Player ${teammate.player.steamid64}`}
|
||||
</span>
|
||||
{#if teammate.player.vac || teammate.player.game_ban}
|
||||
<AlertTriangle class="h-3 w-3 text-neon-red" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-white/50">
|
||||
{teammate.total || 0} matches together
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div
|
||||
class="font-mono text-lg font-bold {winRatePercent >= 55
|
||||
? 'text-neon-green'
|
||||
: winRatePercent >= 45
|
||||
? 'text-neon-blue'
|
||||
: 'text-neon-red'}"
|
||||
>
|
||||
{winRatePercent.toFixed(0)}%
|
||||
</div>
|
||||
<div class="text-xs text-white/50">win rate</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Most Played Teammates -->
|
||||
{#if metaStats.most_mates && metaStats.most_mates.length > 0}
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Users class="h-5 w-5 text-neon-purple" />
|
||||
Frequent Teammates
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each metaStats.most_mates.slice(0, 5) as teammate}
|
||||
{@const winRatePercent = (teammate.win_rate ?? 0) * 100}
|
||||
<a
|
||||
href="/player/{teammate.player.steamid64}"
|
||||
class="group flex items-center gap-3 rounded-lg bg-white/5 p-3 transition-all hover:bg-white/10"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-void"
|
||||
>
|
||||
{#if teammate.player.avatar}
|
||||
<img
|
||||
src={teammate.player.avatar}
|
||||
alt={teammate.player.name || 'Player'}
|
||||
class="h-full w-full rounded-full"
|
||||
/>
|
||||
{:else}
|
||||
<User class="h-5 w-5 text-white/50" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-white group-hover:text-neon-purple">
|
||||
{teammate.player.name || `Player ${teammate.player.steamid64}`}
|
||||
</span>
|
||||
{#if teammate.player.vac || teammate.player.game_ban}
|
||||
<AlertTriangle class="h-3 w-3 text-neon-red" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-white/50">
|
||||
<span
|
||||
class="font-mono {winRatePercent >= 55
|
||||
? 'text-neon-green'
|
||||
: winRatePercent >= 45
|
||||
? 'text-neon-blue'
|
||||
: 'text-neon-red'}"
|
||||
>
|
||||
{winRatePercent.toFixed(0)}% WR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-mono text-lg font-bold text-neon-purple">
|
||||
{teammate.total || 0}
|
||||
</div>
|
||||
<div class="text-xs text-white/50">matches</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Weapon Preferences Section (from pre-aggregated meta stats) -->
|
||||
{#if metaStats && metaStats.weapon_dmg && metaStats.weapon_dmg.length > 0}
|
||||
{@const topWeapons = metaStats.weapon_dmg.slice(0, 8)}
|
||||
{@const maxDamage = Math.max(...topWeapons.map((w) => w.dmg))}
|
||||
<div>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-gold/20"
|
||||
style="box-shadow: 0 0 15px rgba(255, 215, 0, 0.2);"
|
||||
>
|
||||
<Crosshair class="h-5 w-5 text-neon-gold" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Weapon Preferences</h2>
|
||||
<p class="text-sm text-white/50">Damage dealt by weapon type</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="space-y-4">
|
||||
{#each topWeapons as weapon, index}
|
||||
{@const weaponName = metaStats.eq_map?.[weapon.eq] || `Weapon ${weapon.eq}`}
|
||||
{@const barWidth = (weapon.dmg / maxDamage) * 100}
|
||||
{@const isTopWeapon = index < 3}
|
||||
<div class="group">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="font-mono text-xs font-bold {isTopWeapon
|
||||
? 'text-neon-gold'
|
||||
: 'text-white/50'}"
|
||||
>
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span class="font-medium capitalize text-white">
|
||||
{weaponName.toLowerCase().replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm text-white/70">
|
||||
{weapon.dmg.toLocaleString()} dmg
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 {isTopWeapon
|
||||
? 'bg-gradient-to-r from-neon-gold/80 to-neon-gold'
|
||||
: 'bg-gradient-to-r from-neon-blue/60 to-neon-blue/80'}"
|
||||
style="width: {barWidth}%; box-shadow: 0 0 10px {isTopWeapon
|
||||
? 'rgba(255, 215, 0, 0.3)'
|
||||
: 'rgba(0, 212, 255, 0.2)'};"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty State for Unparsed Matches -->
|
||||
{#if playerStats.length === 0 && recentMatches.length > 0}
|
||||
<Card padding="lg">
|
||||
@@ -825,6 +1022,24 @@
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Info when meta stats unavailable (teammates, weapons) -->
|
||||
{#if !metaStats}
|
||||
<Card padding="lg">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-neon-blue/10">
|
||||
<Users class="h-6 w-6 text-neon-blue/50" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">Teammate & Weapon Stats Loading</h3>
|
||||
<p class="text-sm text-white/50">
|
||||
Teammate history and weapon preferences are being calculated. This data is cached and
|
||||
may take a moment to generate on first visit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Recent Matches -->
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PlayerMetaStats } from '$lib/types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const playerId = params.id; // Keep as string to preserve uint64 precision
|
||||
@@ -10,10 +11,15 @@ export const load: PageLoad = async ({ params }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch player profile and recent matches in parallel
|
||||
const [profile, matchesData] = await Promise.all([
|
||||
// Fetch player profile, recent matches, and pre-aggregated meta stats in parallel
|
||||
// Note: Backend limits meta stats to max 10 items per category
|
||||
const [profile, matchesData, metaStats] = await Promise.all([
|
||||
api.players.getPlayerMeta(playerId),
|
||||
api.matches.getMatches({ player_id: playerId, limit: 20 })
|
||||
api.matches.getMatches({ player_id: playerId, limit: 20 }),
|
||||
api.players.getPlayerMetaStats(playerId, 10).catch((err): PlayerMetaStats | null => {
|
||||
console.error(`Failed to fetch player meta stats for ${playerId}:`, err);
|
||||
return null;
|
||||
})
|
||||
]);
|
||||
|
||||
// Fetch full match details with player stats for performance charts
|
||||
@@ -52,6 +58,7 @@ export const load: PageLoad = async ({ params }) => {
|
||||
profile,
|
||||
recentMatches: matchesData.matches.slice(0, 4), // Show 4 in recent matches section
|
||||
playerStats, // Full stats for charts
|
||||
metaStats, // Pre-aggregated stats from backend (teammates, weapons, maps)
|
||||
meta: {
|
||||
title: `${profile.name} - Player Profile | teamflash.rip`,
|
||||
description: `View ${profile.name}'s CS2 statistics, flash history, and how often they blind their own team.`
|
||||
|
||||
@@ -1,65 +1,49 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { matchesAPI } from '$lib/api/matches';
|
||||
|
||||
const SITE_URL = 'https://teamflash.rip'; // Production URL
|
||||
// Get backend API URL from environment variable (same as API proxy route)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
/**
|
||||
* Generate XML sitemap for SEO
|
||||
* Includes static pages and dynamic match pages
|
||||
* Proxy sitemap.xml requests to the backend API
|
||||
*
|
||||
* The backend generates comprehensive sitemaps using gositemap library,
|
||||
* including all matches and players. This proxies that for SEO.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
// Static pages
|
||||
const staticPages = [
|
||||
{ url: '', priority: 1.0, changefreq: 'daily' }, // Home
|
||||
{ url: '/matches', priority: 0.9, changefreq: 'hourly' } // Matches listing
|
||||
];
|
||||
const response = await fetch(`${API_BASE_URL}/sitemap.xml`, {
|
||||
headers: {
|
||||
Accept: 'application/xml'
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch recent matches for dynamic URLs
|
||||
let matchUrls: { url: string; lastmod: string }[] = [];
|
||||
try {
|
||||
const matchesResponse = await matchesAPI.getMatches({ limit: 100 });
|
||||
matchUrls = matchesResponse.matches.map((match) => ({
|
||||
url: `/match/${match.match_id}`,
|
||||
lastmod: match.date || new Date().toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch matches for sitemap:', error);
|
||||
if (!response.ok) {
|
||||
console.error(`Backend sitemap returned ${response.status}`);
|
||||
return new Response('Sitemap not available', {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build XML sitemap
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${staticPages
|
||||
.map(
|
||||
(page) => ` <url>
|
||||
<loc>${SITE_URL}${page.url}</loc>
|
||||
<lastmod>${new Date().toISOString()}</lastmod>
|
||||
<changefreq>${page.changefreq}</changefreq>
|
||||
<priority>${page.priority}</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
${matchUrls
|
||||
.map(
|
||||
(match) => ` <url>
|
||||
<loc>${SITE_URL}${match.url}</loc>
|
||||
<lastmod>${match.lastmod}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`.trim();
|
||||
const xml = await response.text();
|
||||
|
||||
return new Response(sitemap, {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
return new Response('Error generating sitemap', { status: 500 });
|
||||
console.error('Failed to fetch sitemap from backend:', error);
|
||||
return new Response('Sitemap temporarily unavailable', {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Retry-After': '300' // Retry after 5 minutes
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
52
src/routes/sitemap/[id]/+server.ts
Normal file
52
src/routes/sitemap/[id]/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Get backend API URL from environment variable (same as API proxy route)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
/**
|
||||
* Proxy individual sitemap requests to the backend API
|
||||
*
|
||||
* The backend uses a sitemap index pattern where /sitemap.xml points to
|
||||
* individual sitemaps like /sitemap/0, /sitemap/1, etc.
|
||||
* This proxies those individual sitemaps for SEO.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/sitemap/${id}`, {
|
||||
headers: {
|
||||
Accept: 'application/xml'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Backend sitemap/${id} returned ${response.status}`);
|
||||
return new Response('Sitemap not found', {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch sitemap/${id} from backend:`, error);
|
||||
return new Response('Sitemap temporarily unavailable', {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Retry-After': '300' // Retry after 5 minutes
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user