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 { apiClient } from './client';
|
||||||
import { parsePlayer } from '$lib/schemas';
|
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';
|
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +85,18 @@ export const playersAPI = {
|
|||||||
return playerMeta;
|
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
|
* Add player to tracking system
|
||||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export interface LegacyMatchDetail {
|
|||||||
vac: boolean; // NOT vac_present
|
vac: boolean; // NOT vac_present
|
||||||
game_ban: boolean; // NOT gameban_present
|
game_ban: boolean; // NOT gameban_present
|
||||||
stats?: LegacyPlayerStats[]; // Player stats array
|
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,
|
demo_parsed: legacy.parsed,
|
||||||
vac_present: legacy.vac,
|
vac_present: legacy.vac,
|
||||||
gameban_present: legacy.game_ban,
|
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)
|
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
|
* 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 matchId - Match ID
|
||||||
* @param match - Match data with player information
|
* @param match - Match data with player information and final score
|
||||||
* @returns Structured rounds data
|
* @returns Structured rounds data with economy info (winner/win_reason unavailable)
|
||||||
*/
|
*/
|
||||||
export function transformRoundsResponse(
|
export function transformRoundsResponse(
|
||||||
rawData: RoundsAPIResponse,
|
rawData: RoundsAPIResponse,
|
||||||
@@ -15,7 +20,7 @@ export function transformRoundsResponse(
|
|||||||
): MatchRoundsResponse {
|
): MatchRoundsResponse {
|
||||||
const rounds: RoundDetail[] = [];
|
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>();
|
const playerTeamMap = new Map<string, number>();
|
||||||
if (match?.players) {
|
if (match?.players) {
|
||||||
for (const player of match.players) {
|
for (const player of match.players) {
|
||||||
@@ -47,8 +52,10 @@ export function transformRoundsResponse(
|
|||||||
|
|
||||||
rounds.push({
|
rounds.push({
|
||||||
round: roundNum + 1,
|
round: roundNum + 1,
|
||||||
winner: 0, // TODO: Determine winner from data if available
|
// Round winner data not available from backend API
|
||||||
win_reason: '', // TODO: Determine win reason if available
|
// Would require demo parser changes to store RoundEnd event winners
|
||||||
|
winner: 0,
|
||||||
|
win_reason: '',
|
||||||
players
|
players
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform raw weapons API response into structured format
|
* 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 matchId - Match ID
|
||||||
* @param match - Match data with player information
|
* @param match - Match data with player information
|
||||||
* @returns Structured weapons data
|
* @returns Structured weapons data with damage stats (kills unavailable)
|
||||||
*/
|
*/
|
||||||
export function transformWeaponsResponse(
|
export function transformWeaponsResponse(
|
||||||
rawData: WeaponsAPIResponse,
|
rawData: WeaponsAPIResponse,
|
||||||
@@ -77,7 +82,9 @@ export function transformWeaponsResponse(
|
|||||||
weapon_stats.push({
|
weapon_stats.push({
|
||||||
eq_type: eqType,
|
eq_type: eqType,
|
||||||
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${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,
|
damage: stats.damage,
|
||||||
hits: stats.hits,
|
hits: stats.hits,
|
||||||
hit_groups: hitGroupCounts,
|
hit_groups: hitGroupCounts,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { MatchListItem } from '$lib/types';
|
import type { MatchListItem } from '$lib/types';
|
||||||
import { storeMatchesState, type FilterState } from '$lib/utils/navigation';
|
import { storeMatchesState, type FilterState } from '$lib/utils/navigation';
|
||||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||||
|
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: MatchListItem;
|
match: MatchListItem;
|
||||||
@@ -87,6 +88,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Status badges - horizontal layout -->
|
<!-- Status badges - horizontal layout -->
|
||||||
<div class="flex items-center gap-1">
|
<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}
|
{#if match.player_count}
|
||||||
<span
|
<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"
|
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 */
|
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
||||||
tick_rate?: number;
|
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'
|
* Game mode: 'premier' | 'competitive' | 'wingman'
|
||||||
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
|
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
|
||||||
@@ -67,6 +73,8 @@ export interface MatchListItem {
|
|||||||
duration: number;
|
duration: number;
|
||||||
demo_parsed: boolean;
|
demo_parsed: boolean;
|
||||||
player_count?: number;
|
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 CS2 Premier rating */
|
||||||
peak_rating?: number;
|
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';
|
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
|
||||||
|
|
||||||
// Player types
|
// 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
|
// Round statistics types
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<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 { goto } from '$app/navigation';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||||
|
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||||
|
|
||||||
@@ -42,6 +43,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDownloadDemo() {
|
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) {
|
if (!match.share_code) {
|
||||||
alert('Share code not available for this match');
|
alert('Share code not available for this match');
|
||||||
return;
|
return;
|
||||||
@@ -49,6 +56,9 @@
|
|||||||
const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`;
|
const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`;
|
||||||
window.location.href = downloadUrl;
|
window.location.href = downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if demo download is available
|
||||||
|
const canDownloadDemo = match.replay_url || (match.demo_parsed && match.share_code);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Match Header with Background -->
|
<!-- Match Header with Background -->
|
||||||
@@ -95,11 +105,13 @@
|
|||||||
{mapName}
|
{mapName}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{#if match.demo_parsed && match.share_code}
|
{#if canDownloadDemo}
|
||||||
<button
|
<button
|
||||||
onclick={handleDownloadDemo}
|
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)]"
|
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" />
|
<Download class="h-4 w-4" />
|
||||||
<span class="hidden sm:inline">Download Demo</span>
|
<span class="hidden sm:inline">Download Demo</span>
|
||||||
@@ -151,6 +163,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-white/20">•</span>
|
<span class="text-white/20">•</span>
|
||||||
<span>MR12 ({match.max_rounds} rounds)</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}
|
{#if match.demo_parsed}
|
||||||
<span class="text-white/20">•</span>
|
<span class="text-white/20">•</span>
|
||||||
<Badge variant="success" size="sm">Demo Parsed</Badge>
|
<Badge variant="success" size="sm">Demo Parsed</Badge>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { profile, recentMatches, playerStats } = data;
|
const { profile, recentMatches, playerStats, metaStats } = data;
|
||||||
|
|
||||||
// Track this player visit
|
// Track this player visit
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -807,6 +807,203 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Empty State for Unparsed Matches -->
|
||||||
{#if playerStats.length === 0 && recentMatches.length > 0}
|
{#if playerStats.length === 0 && recentMatches.length > 0}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
@@ -825,6 +1022,24 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/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 -->
|
<!-- Recent Matches -->
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
import type { PlayerMetaStats } from '$lib/types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const playerId = params.id; // Keep as string to preserve uint64 precision
|
const playerId = params.id; // Keep as string to preserve uint64 precision
|
||||||
@@ -10,10 +11,15 @@ export const load: PageLoad = async ({ params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch player profile and recent matches in parallel
|
// Fetch player profile, recent matches, and pre-aggregated meta stats in parallel
|
||||||
const [profile, matchesData] = await Promise.all([
|
// Note: Backend limits meta stats to max 10 items per category
|
||||||
|
const [profile, matchesData, metaStats] = await Promise.all([
|
||||||
api.players.getPlayerMeta(playerId),
|
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
|
// Fetch full match details with player stats for performance charts
|
||||||
@@ -52,6 +58,7 @@ export const load: PageLoad = async ({ params }) => {
|
|||||||
profile,
|
profile,
|
||||||
recentMatches: matchesData.matches.slice(0, 4), // Show 4 in recent matches section
|
recentMatches: matchesData.matches.slice(0, 4), // Show 4 in recent matches section
|
||||||
playerStats, // Full stats for charts
|
playerStats, // Full stats for charts
|
||||||
|
metaStats, // Pre-aggregated stats from backend (teammates, weapons, maps)
|
||||||
meta: {
|
meta: {
|
||||||
title: `${profile.name} - Player Profile | teamflash.rip`,
|
title: `${profile.name} - Player Profile | teamflash.rip`,
|
||||||
description: `View ${profile.name}'s CS2 statistics, flash history, and how often they blind their own team.`
|
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 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
|
* Proxy sitemap.xml requests to the backend API
|
||||||
* Includes static pages and dynamic match pages
|
*
|
||||||
|
* The backend generates comprehensive sitemaps using gositemap library,
|
||||||
|
* including all matches and players. This proxies that for SEO.
|
||||||
*/
|
*/
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
try {
|
try {
|
||||||
// Static pages
|
const response = await fetch(`${API_BASE_URL}/sitemap.xml`, {
|
||||||
const staticPages = [
|
headers: {
|
||||||
{ url: '', priority: 1.0, changefreq: 'daily' }, // Home
|
Accept: 'application/xml'
|
||||||
{ url: '/matches', priority: 0.9, changefreq: 'hourly' } // Matches listing
|
}
|
||||||
];
|
});
|
||||||
|
|
||||||
// Fetch recent matches for dynamic URLs
|
if (!response.ok) {
|
||||||
let matchUrls: { url: string; lastmod: string }[] = [];
|
console.error(`Backend sitemap returned ${response.status}`);
|
||||||
try {
|
return new Response('Sitemap not available', {
|
||||||
const matchesResponse = await matchesAPI.getMatches({ limit: 100 });
|
status: response.status,
|
||||||
matchUrls = matchesResponse.matches.map((match) => ({
|
headers: {
|
||||||
url: `/match/${match.match_id}`,
|
'Content-Type': 'text/plain'
|
||||||
lastmod: match.date || new Date().toISOString()
|
}
|
||||||
}));
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch matches for sitemap:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build XML sitemap
|
const xml = await response.text();
|
||||||
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();
|
|
||||||
|
|
||||||
return new Response(sitemap, {
|
return new Response(xml, {
|
||||||
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/xml',
|
'Content-Type': 'application/xml',
|
||||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating sitemap:', error);
|
console.error('Failed to fetch sitemap from backend:', error);
|
||||||
return new Response('Error generating sitemap', { status: 500 });
|
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