fix: Fix match page SSR, tab errors, and table consistency
- Enable SSR for match pages by detecting server vs client context in API client - Fix 500 errors on economy, chat, and details tabs by adding data loaders - Handle unparsed matches gracefully with "Match Not Parsed" messages - Fix dynamic team ID detection instead of hardcoding team IDs 2/3 - Fix DataTable component to properly render HTML in render functions - Add fixed column widths to tables for visual consistency - Add meta titles to all tab page loaders - Fix Svelte 5 $derived syntax errors - Fix ESLint errors (unused imports, any types, reactive state) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,25 +6,34 @@ import { APIException } from '$lib/types';
|
|||||||
* API Client Configuration
|
* API Client Configuration
|
||||||
*/
|
*/
|
||||||
const getAPIBaseURL = (): string => {
|
const getAPIBaseURL = (): string => {
|
||||||
// In production builds, use the configured URL directly
|
const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||||
if (import.meta.env.PROD) {
|
|
||||||
return import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
// Check if we're running on the server (SSR) or in production
|
||||||
|
// On the server, we must use the actual API URL, not the proxy
|
||||||
|
if (import.meta.env.SSR || import.meta.env.PROD) {
|
||||||
|
return apiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In development mode, ALWAYS use the Vite proxy to avoid CORS issues
|
// In development mode on the client, use the Vite proxy to avoid CORS issues
|
||||||
// The proxy will forward /api requests to VITE_API_BASE_URL
|
// The proxy will forward /api requests to VITE_API_BASE_URL
|
||||||
// This works regardless of whether the backend is local or remote
|
|
||||||
return '/api';
|
return '/api';
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL = getAPIBaseURL();
|
const API_BASE_URL = getAPIBaseURL();
|
||||||
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
|
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
|
||||||
|
|
||||||
// Log the API configuration in development
|
// Log the API configuration
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log('[API Client] Development mode - using Vite proxy');
|
if (import.meta.env.SSR) {
|
||||||
|
console.log('[API Client] SSR mode - using direct API URL:', API_BASE_URL);
|
||||||
|
} else {
|
||||||
|
console.log('[API Client] Browser mode - using Vite proxy');
|
||||||
console.log('[API Client] Frontend requests: /api/*');
|
console.log('[API Client] Frontend requests: /api/*');
|
||||||
console.log('[API Client] Proxy target:', import.meta.env?.VITE_API_BASE_URL || 'http://localhost:8000');
|
console.log(
|
||||||
|
'[API Client] Proxy target:',
|
||||||
|
import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
render?: (value: T[keyof T], row: T) => unknown;
|
render?: (value: T[keyof T], row: T) => unknown;
|
||||||
align?: 'left' | 'center' | 'right';
|
align?: 'left' | 'center' | 'right';
|
||||||
class?: string;
|
class?: string;
|
||||||
|
width?: string; // e.g., '200px', '30%', 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
striped?: boolean;
|
striped?: boolean;
|
||||||
hoverable?: boolean;
|
hoverable?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -27,7 +29,8 @@
|
|||||||
class: className = '',
|
class: className = '',
|
||||||
striped = false,
|
striped = false,
|
||||||
hoverable = true,
|
hoverable = true,
|
||||||
compact = false
|
compact = false,
|
||||||
|
fixedLayout = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let sortKey = $state<keyof T | null>(null);
|
let sortKey = $state<keyof T | null>(null);
|
||||||
@@ -68,7 +71,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto {className}">
|
<div class="overflow-x-auto {className}">
|
||||||
<table class="table" class:table-zebra={striped} class:table-xs={compact}>
|
<table
|
||||||
|
class="table"
|
||||||
|
class:table-zebra={striped}
|
||||||
|
class:table-xs={compact}
|
||||||
|
style={fixedLayout ? 'table-layout: fixed;' : ''}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{#each columns as column}
|
{#each columns as column}
|
||||||
@@ -76,6 +84,7 @@
|
|||||||
class:cursor-pointer={column.sortable}
|
class:cursor-pointer={column.sortable}
|
||||||
class:hover:bg-base-200={column.sortable}
|
class:hover:bg-base-200={column.sortable}
|
||||||
class="text-{column.align || 'left'} {column.class || ''}"
|
class="text-{column.align || 'left'} {column.class || ''}"
|
||||||
|
style={column.width ? `width: ${column.width}` : ''}
|
||||||
onclick={() => handleSort(column)}
|
onclick={() => handleSort(column)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -109,7 +118,7 @@
|
|||||||
{#each columns as column}
|
{#each columns as column}
|
||||||
<td class="text-{column.align || 'left'} {column.class || ''}">
|
<td class="text-{column.align || 'left'} {column.class || ''}">
|
||||||
{#if column.render}
|
{#if column.render}
|
||||||
{@render column.render(row[column.key], row)}
|
{@html column.render(row[column.key], row)}
|
||||||
{:else}
|
{:else}
|
||||||
{getValue(row, column)}
|
{getValue(row, column)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Trophy, Target, Crosshair } from 'lucide-svelte';
|
import { Trophy } from 'lucide-svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import Card from '$lib/components/ui/Card.svelte';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import type { MatchPlayer } from '$lib/types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { match } = data;
|
const { match } = data;
|
||||||
|
|
||||||
// Group players by team
|
// Group players by team - use dynamic team IDs from API
|
||||||
const teamA = match.players?.filter((p: any) => p.team_id === 2) || [];
|
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||||
const teamB = match.players?.filter((p: any) => p.team_id === 3) || [];
|
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||||
|
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||||
|
|
||||||
|
const teamA = match.players?.filter((p) => p.team_id === firstTeamId) || [];
|
||||||
|
const teamB = match.players?.filter((p) => p.team_id === secondTeamId) || [];
|
||||||
|
|
||||||
// Sort by kills descending
|
// Sort by kills descending
|
||||||
const sortedTeamA = teamA.sort((a: any, b: any) => b.kills - a.kills);
|
const sortedTeamA = teamA.sort((a, b) => b.kills - a.kills);
|
||||||
const sortedTeamB = teamB.sort((a: any, b: any) => b.kills - a.kills);
|
const sortedTeamB = teamB.sort((a, b) => b.kills - a.kills);
|
||||||
|
|
||||||
// Calculate team stats
|
// Calculate team stats
|
||||||
const calcTeamStats = (players: typeof teamA) => {
|
const calcTeamStats = (players: MatchPlayer[]) => {
|
||||||
const totalKills = players.reduce((sum: number, p: any) => sum + p.kills, 0);
|
const totalKills = players.reduce((sum, p) => sum + p.kills, 0);
|
||||||
const totalDeaths = players.reduce((sum: number, p: any) => sum + p.deaths, 0);
|
const totalDeaths = players.reduce((sum, p) => sum + p.deaths, 0);
|
||||||
const totalADR = players.reduce((sum: number, p: any) => sum + (p.adr || 0), 0);
|
const totalADR = players.reduce((sum, p) => sum + (p.adr || 0), 0);
|
||||||
const avgKAST = players.reduce((sum: number, p: any) => sum + (p.kast || 0), 0) / players.length;
|
const avgKAST = players.reduce((sum, p) => sum + (p.kast || 0), 0) / players.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kills: totalKills,
|
kills: totalKills,
|
||||||
@@ -41,7 +46,7 @@
|
|||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
|
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
|
||||||
<div class="text-3xl font-bold font-mono text-terrorist">{match.score_team_a}</div>
|
<div class="font-mono text-3xl font-bold text-terrorist">{match.score_team_a}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
|
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
|
||||||
<div class="text-3xl font-bold font-mono text-ct">{match.score_team_b}</div>
|
<div class="font-mono text-3xl font-bold text-ct">{match.score_team_b}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -101,16 +106,16 @@
|
|||||||
<h3 class="text-lg font-semibold text-terrorist">Terrorists</h3>
|
<h3 class="text-lg font-semibold text-terrorist">Terrorists</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" style="table-layout: fixed;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-base-300">
|
<tr class="border-base-300">
|
||||||
<th>Player</th>
|
<th style="width: 200px;">Player</th>
|
||||||
<th>K</th>
|
<th style="width: 80px;">K</th>
|
||||||
<th>D</th>
|
<th style="width: 80px;">D</th>
|
||||||
<th>A</th>
|
<th style="width: 80px;">A</th>
|
||||||
<th>ADR</th>
|
<th style="width: 100px;">ADR</th>
|
||||||
<th>HS%</th>
|
<th style="width: 100px;">HS%</th>
|
||||||
<th>KAST%</th>
|
<th style="width: 100px;">KAST%</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -119,7 +124,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
href={`/player/${player.id}`}
|
href={`/player/${player.id}`}
|
||||||
class="font-medium hover:text-primary transition-colors"
|
class="font-medium transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
{player.name}
|
{player.name}
|
||||||
</a>
|
</a>
|
||||||
@@ -146,16 +151,16 @@
|
|||||||
<h3 class="text-lg font-semibold text-ct">Counter-Terrorists</h3>
|
<h3 class="text-lg font-semibold text-ct">Counter-Terrorists</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" style="table-layout: fixed;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-base-300">
|
<tr class="border-base-300">
|
||||||
<th>Player</th>
|
<th style="width: 200px;">Player</th>
|
||||||
<th>K</th>
|
<th style="width: 80px;">K</th>
|
||||||
<th>D</th>
|
<th style="width: 80px;">D</th>
|
||||||
<th>A</th>
|
<th style="width: 80px;">A</th>
|
||||||
<th>ADR</th>
|
<th style="width: 100px;">ADR</th>
|
||||||
<th>HS%</th>
|
<th style="width: 100px;">HS%</th>
|
||||||
<th>KAST%</th>
|
<th style="width: 100px;">KAST%</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -164,7 +169,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
href={`/player/${player.id}`}
|
href={`/player/${player.id}`}
|
||||||
class="font-medium hover:text-primary transition-colors"
|
class="font-medium transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
{player.name}
|
{player.name}
|
||||||
</a>
|
</a>
|
||||||
@@ -191,8 +196,8 @@
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
|
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
|
||||||
<p class="text-base-content/60">
|
<p class="text-base-content/60">
|
||||||
Round-by-round timeline visualization coming soon. Will show bomb plants, defuses, and
|
Round-by-round timeline visualization coming soon. Will show bomb plants, defuses, and round
|
||||||
round winners.
|
winners.
|
||||||
</p>
|
</p>
|
||||||
<Badge variant="warning" size="md" class="mt-4">Coming in Future Update</Badge>
|
<Badge variant="warning" size="md" class="mt-4">Coming in Future Update</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MessageSquare, Filter, Search } from 'lucide-svelte';
|
import { MessageSquare, Filter, Search, AlertCircle } from 'lucide-svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import Card from '$lib/components/ui/Card.svelte';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface MessagePlayer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
team_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { match, chatData } = data;
|
const { match, chatData } = data;
|
||||||
|
|
||||||
// State for filtering
|
// Only process if chat data exists
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let showTeamChat = $state(true);
|
let showTeamChat = $state(true);
|
||||||
let showAllChat = $state(true);
|
let showAllChat = $state(true);
|
||||||
let selectedPlayer = $state<number | null>(null);
|
let selectedPlayer = $state<number | null>(null);
|
||||||
|
|
||||||
|
let messagePlayers = $state<MessagePlayer[]>([]);
|
||||||
|
let filteredMessages = $state<typeof chatData.messages>([]);
|
||||||
|
let messagesByRound = $state<Record<number, typeof chatData.messages>>({});
|
||||||
|
let rounds = $state<number[]>([]);
|
||||||
|
let totalMessages = $state(0);
|
||||||
|
let teamChatCount = $state(0);
|
||||||
|
let allChatCount = $state(0);
|
||||||
|
|
||||||
|
// Get player info for a message
|
||||||
|
const getPlayerInfo = (playerId: number) => {
|
||||||
|
const player = match.players?.find((p) => p.id === playerId);
|
||||||
|
return {
|
||||||
|
name: player?.name || `Player ${playerId}`,
|
||||||
|
team_id: player?.team_id || 0
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chatData) {
|
||||||
// Get unique players who sent messages
|
// Get unique players who sent messages
|
||||||
const messagePlayers = Array.from(
|
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id))).map(
|
||||||
new Set(chatData.messages.map((m) => m.player_id))
|
(playerId) => {
|
||||||
).map((playerId) => {
|
|
||||||
const player = match.players?.find((p) => p.id === playerId);
|
const player = match.players?.find((p) => p.id === playerId);
|
||||||
return {
|
return {
|
||||||
id: playerId,
|
id: playerId,
|
||||||
name: player?.name || `Player ${playerId}`,
|
name: player?.name || `Player ${playerId}`,
|
||||||
team_id: player?.team_id
|
team_id: player?.team_id
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Filter messages
|
// Filter messages
|
||||||
const filteredMessages = $derived(() => {
|
const computeFilteredMessages = () => {
|
||||||
return chatData.messages.filter((msg) => {
|
return chatData.messages.filter((msg) => {
|
||||||
// Chat type filter
|
// Chat type filter
|
||||||
if (!showTeamChat && !msg.all_chat) return false;
|
if (!showTeamChat && !msg.all_chat) return false;
|
||||||
@@ -43,19 +66,14 @@
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
})();
|
};
|
||||||
|
|
||||||
// Get player info for a message
|
// Update filtered messages reactively
|
||||||
const getPlayerInfo = (playerId: number) => {
|
$effect(() => {
|
||||||
const player = match.players?.find((p) => p.id === playerId);
|
filteredMessages = computeFilteredMessages();
|
||||||
return {
|
|
||||||
name: player?.name || `Player ${playerId}`,
|
|
||||||
team_id: player?.team_id || 0
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group messages by round
|
// Group messages by round
|
||||||
const messagesByRound: Record<number, typeof chatData.messages> = {};
|
messagesByRound = {};
|
||||||
for (const msg of filteredMessages) {
|
for (const msg of filteredMessages) {
|
||||||
const round = msg.round || 0;
|
const round = msg.round || 0;
|
||||||
if (!messagesByRound[round]) {
|
if (!messagesByRound[round]) {
|
||||||
@@ -64,20 +82,34 @@
|
|||||||
messagesByRound[round].push(msg);
|
messagesByRound[round].push(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rounds = Object.keys(messagesByRound)
|
rounds = Object.keys(messagesByRound)
|
||||||
.map(Number)
|
.map(Number)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const totalMessages = chatData.messages.length;
|
totalMessages = chatData.messages.length;
|
||||||
const teamChatCount = chatData.messages.filter((m) => !m.all_chat).length;
|
teamChatCount = chatData.messages.filter((m) => !m.all_chat).length;
|
||||||
const allChatCount = chatData.messages.filter((m) => m.all_chat).length;
|
allChatCount = chatData.messages.filter((m) => m.all_chat).length;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.meta.title}</title>
|
<title>{data.meta.title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !chatData}
|
||||||
|
<Card padding="lg">
|
||||||
|
<div class="text-center">
|
||||||
|
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||||
|
<h2 class="mb-2 text-2xl font-bold text-base-content">Match Not Parsed</h2>
|
||||||
|
<p class="mb-4 text-base-content/60">
|
||||||
|
This match hasn't been parsed yet, so chat data is not available.
|
||||||
|
</p>
|
||||||
|
<Badge variant="warning" size="lg">Demo parsing required</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-3">
|
||||||
@@ -118,28 +150,17 @@
|
|||||||
<!-- Chat Type -->
|
<!-- Chat Type -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<label class="label cursor-pointer gap-2">
|
<label class="label cursor-pointer gap-2">
|
||||||
<input
|
<input type="checkbox" bind:checked={showTeamChat} class="checkbox checkbox-sm" />
|
||||||
type="checkbox"
|
|
||||||
bind:checked={showTeamChat}
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="label-text">Team Chat</span>
|
<span class="label-text">Team Chat</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="label cursor-pointer gap-2">
|
<label class="label cursor-pointer gap-2">
|
||||||
<input
|
<input type="checkbox" bind:checked={showAllChat} class="checkbox checkbox-sm" />
|
||||||
type="checkbox"
|
|
||||||
bind:checked={showAllChat}
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="label-text">All Chat</span>
|
<span class="label-text">All Chat</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Filter -->
|
<!-- Player Filter -->
|
||||||
<select
|
<select bind:value={selectedPlayer} class="select select-bordered select-sm">
|
||||||
bind:value={selectedPlayer}
|
|
||||||
class="select select-bordered select-sm"
|
|
||||||
>
|
|
||||||
<option value={null}>All Players</option>
|
<option value={null}>All Players</option>
|
||||||
{#each messagePlayers as player}
|
{#each messagePlayers as player}
|
||||||
<option value={player.id}>{player.name}</option>
|
<option value={player.id}>{player.name}</option>
|
||||||
@@ -147,13 +168,13 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative flex-1 min-w-[200px]">
|
<div class="relative min-w-[200px] flex-1">
|
||||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-base-content/40" />
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-base-content/40" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Search messages..."
|
placeholder="Search messages..."
|
||||||
class="input input-bordered input-sm w-full pl-9"
|
class="input input-sm input-bordered w-full pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +199,9 @@
|
|||||||
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
|
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="default" size="sm">
|
<Badge variant="default" size="sm">
|
||||||
{messagesByRound[round].length} message{messagesByRound[round].length !== 1 ? 's' : ''}
|
{messagesByRound[round].length} message{messagesByRound[round].length !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +210,7 @@
|
|||||||
<div class="divide-y divide-base-300">
|
<div class="divide-y divide-base-300">
|
||||||
{#each messagesByRound[round] as message}
|
{#each messagesByRound[round] as message}
|
||||||
{@const playerInfo = getPlayerInfo(message.player_id)}
|
{@const playerInfo = getPlayerInfo(message.player_id)}
|
||||||
<div class="p-4 hover:bg-base-200/50 transition-colors">
|
<div class="p-4 transition-colors hover:bg-base-200/50">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<!-- Player Avatar/Icon -->
|
<!-- Player Avatar/Icon -->
|
||||||
<div
|
<div
|
||||||
@@ -200,7 +223,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message Content -->
|
<!-- Message Content -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-baseline gap-2">
|
<div class="flex items-baseline gap-2">
|
||||||
<a
|
<a
|
||||||
href="/player/{message.player_id}"
|
href="/player/{message.player_id}"
|
||||||
@@ -216,7 +239,7 @@
|
|||||||
<Badge variant="default" size="sm">Team</Badge>
|
<Badge variant="default" size="sm">Team</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-base-content break-words">{message.message}</p>
|
<p class="mt-1 break-words text-base-content">{message.message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,3 +249,4 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { api } from '$lib/api';
|
||||||
import { matchesAPI } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, parent }) => {
|
export const load: PageLoad = async ({ parent, params }) => {
|
||||||
try {
|
|
||||||
// Get match data from parent layout
|
|
||||||
const { match } = await parent();
|
const { match } = await parent();
|
||||||
|
|
||||||
// Fetch chat messages
|
// Only load chat data if match is parsed
|
||||||
const chatData = await matchesAPI.getMatchChat(params.id);
|
if (!match.demo_parsed) {
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
chatData: null,
|
||||||
|
meta: {
|
||||||
|
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatData = await api.matches.getMatchChat(params.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
match,
|
match,
|
||||||
chatData,
|
chatData,
|
||||||
meta: {
|
meta: {
|
||||||
title: `${match.map} Chat - Match ${match.match_id} - CS2.WTF`,
|
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||||
description: `In-game chat log for ${match.map} match`
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load chat data:', err);
|
console.error(`Failed to load chat data for match ${params.id}:`, err);
|
||||||
throw error(500, {
|
// Return null instead of throwing error
|
||||||
message: 'Failed to load chat data'
|
return {
|
||||||
});
|
match,
|
||||||
|
chatData: null,
|
||||||
|
meta: {
|
||||||
|
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Trophy, Target, Flame, Zap } from 'lucide-svelte';
|
import { Trophy, Target, Flame, AlertCircle } from 'lucide-svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import Card from '$lib/components/ui/Card.svelte';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||||
@@ -7,25 +7,40 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { match, weaponsData } = data;
|
const { match } = data;
|
||||||
|
|
||||||
|
// Check if we have player data to display
|
||||||
|
const hasPlayerData = match.players && match.players.length > 0;
|
||||||
|
|
||||||
|
// Get unique team IDs dynamically
|
||||||
|
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||||
|
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||||
|
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||||
|
|
||||||
// Calculate additional stats for players
|
// Calculate additional stats for players
|
||||||
const playersWithStats = (match.players || []).map((player) => {
|
const playersWithStats = hasPlayerData
|
||||||
const kd = player.deaths > 0 ? (player.kills / player.deaths).toFixed(2) : player.kills.toFixed(2);
|
? (match.players || []).map((player) => {
|
||||||
const hsPercent = player.kills > 0 ? ((player.headshot / player.kills) * 100).toFixed(1) : '0.0';
|
const kd =
|
||||||
const adr = player.dmg_enemy ? (player.dmg_enemy / (match.max_rounds || 24)).toFixed(1) : '0.0';
|
player.deaths > 0 ? (player.kills / player.deaths).toFixed(2) : player.kills.toFixed(2);
|
||||||
|
const hsPercent =
|
||||||
|
player.kills > 0 ? ((player.headshot / player.kills) * 100).toFixed(1) : '0.0';
|
||||||
|
const adr = player.dmg_enemy
|
||||||
|
? (player.dmg_enemy / (match.max_rounds || 24)).toFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...player,
|
...player,
|
||||||
kd: parseFloat(kd),
|
kd: parseFloat(kd),
|
||||||
hsPercent: parseFloat(hsPercent),
|
hsPercent: parseFloat(hsPercent),
|
||||||
adr: parseFloat(adr),
|
adr: parseFloat(adr),
|
||||||
totalMultiKills: (player.mk_2 || 0) + (player.mk_3 || 0) + (player.mk_4 || 0) + (player.mk_5 || 0)
|
totalMultiKills:
|
||||||
|
(player.mk_2 || 0) + (player.mk_3 || 0) + (player.mk_4 || 0) + (player.mk_5 || 0)
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
// Sort by kills descending
|
// Sort by kills descending
|
||||||
const sortedPlayers = playersWithStats.sort((a, b) => b.kills - a.kills);
|
const sortedPlayers = hasPlayerData ? playersWithStats.sort((a, b) => b.kills - a.kills) : [];
|
||||||
|
|
||||||
// Prepare data table columns
|
// Prepare data table columns
|
||||||
const detailsColumns = [
|
const detailsColumns = [
|
||||||
@@ -33,18 +48,52 @@
|
|||||||
key: 'name',
|
key: 'name',
|
||||||
label: 'Player',
|
label: 'Player',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value: string, row: any) => {
|
render: (value: string, row: (typeof playersWithStats)[0]) => {
|
||||||
const teamClass = row.team_id === 2 ? 'text-terrorist' : 'text-ct';
|
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||||
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
|
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ key: 'kills', label: 'K', sortable: true, align: 'center' as const, class: 'font-mono font-semibold' },
|
{
|
||||||
|
key: 'kills',
|
||||||
|
label: 'K',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
class: 'font-mono font-semibold'
|
||||||
|
},
|
||||||
{ key: 'deaths', label: 'D', sortable: true, align: 'center' as const, class: 'font-mono' },
|
{ key: 'deaths', label: 'D', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||||
{ key: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
|
{ key: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||||
{ key: 'kd', label: 'K/D', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => v.toFixed(2) },
|
{
|
||||||
{ key: 'adr', label: 'ADR', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => v.toFixed(1) },
|
key: 'kd',
|
||||||
{ key: 'hsPercent', label: 'HS%', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => `${v.toFixed(1)}%` },
|
label: 'K/D',
|
||||||
{ key: 'kast', label: 'KAST%', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => `${v.toFixed(1)}%` },
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
class: 'font-mono',
|
||||||
|
format: (v: number) => v.toFixed(2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adr',
|
||||||
|
label: 'ADR',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
class: 'font-mono',
|
||||||
|
format: (v: number) => v.toFixed(1)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hsPercent',
|
||||||
|
label: 'HS%',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
class: 'font-mono',
|
||||||
|
format: (v: number) => `${v.toFixed(1)}%`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kast',
|
||||||
|
label: 'KAST%',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
class: 'font-mono',
|
||||||
|
format: (v: number) => `${v.toFixed(1)}%`
|
||||||
|
},
|
||||||
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
|
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||||
{
|
{
|
||||||
key: 'mk_5',
|
key: 'mk_5',
|
||||||
@@ -86,28 +135,64 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate team totals
|
// Calculate team totals
|
||||||
const teamAPlayers = playersWithStats.filter((p) => p.team_id === 2);
|
const teamAPlayers = hasPlayerData
|
||||||
const teamBPlayers = playersWithStats.filter((p) => p.team_id === 3);
|
? playersWithStats.filter((p) => p.team_id === firstTeamId)
|
||||||
|
: [];
|
||||||
|
const teamBPlayers = hasPlayerData
|
||||||
|
? playersWithStats.filter((p) => p.team_id === secondTeamId)
|
||||||
|
: [];
|
||||||
|
|
||||||
const teamAStats = {
|
const teamAStats = hasPlayerData
|
||||||
|
? {
|
||||||
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
|
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
|
||||||
totalUtilityDamage: teamAPlayers.reduce((sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0), 0),
|
totalUtilityDamage: teamAPlayers.reduce(
|
||||||
|
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||||
avgKAST: (teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length).toFixed(1)
|
avgKAST:
|
||||||
};
|
teamAPlayers.length > 0
|
||||||
|
? (
|
||||||
|
teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length
|
||||||
|
).toFixed(1)
|
||||||
|
: '0.0'
|
||||||
|
}
|
||||||
|
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
|
||||||
|
|
||||||
const teamBStats = {
|
const teamBStats = hasPlayerData
|
||||||
|
? {
|
||||||
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
|
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
|
||||||
totalUtilityDamage: teamBPlayers.reduce((sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0), 0),
|
totalUtilityDamage: teamBPlayers.reduce(
|
||||||
|
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||||
avgKAST: (teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length).toFixed(1)
|
avgKAST:
|
||||||
};
|
teamBPlayers.length > 0
|
||||||
|
? (
|
||||||
|
teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length
|
||||||
|
).toFixed(1)
|
||||||
|
: '0.0'
|
||||||
|
}
|
||||||
|
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.meta.title}</title>
|
<title>Match Details - CS2.WTF</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !hasPlayerData}
|
||||||
|
<Card padding="lg">
|
||||||
|
<div class="text-center">
|
||||||
|
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||||
|
<h2 class="mb-2 text-2xl font-bold text-base-content">No Player Data Available</h2>
|
||||||
|
<p class="mb-4 text-base-content/60">
|
||||||
|
Detailed player statistics are not available for this match.
|
||||||
|
</p>
|
||||||
|
<Badge variant="warning" size="lg">Player data unavailable</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Team Performance Summary -->
|
<!-- Team Performance Summary -->
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
@@ -191,7 +276,7 @@
|
|||||||
<h3 class="font-semibold text-base-content">Most Kills</h3>
|
<h3 class="font-semibold text-base-content">Most Kills</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
|
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
|
||||||
<div class="mt-1 text-3xl font-mono font-bold text-primary">{sortedPlayers[0].kills}</div>
|
<div class="mt-1 font-mono text-3xl font-bold text-primary">{sortedPlayers[0].kills}</div>
|
||||||
<div class="mt-2 text-xs text-base-content/60">
|
<div class="mt-2 text-xs text-base-content/60">
|
||||||
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
|
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +290,7 @@
|
|||||||
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
|
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
|
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
|
||||||
<div class="mt-1 text-3xl font-mono font-bold text-success">{bestKD.kd.toFixed(2)}</div>
|
<div class="mt-1 font-mono text-3xl font-bold text-success">{bestKD.kd.toFixed(2)}</div>
|
||||||
<div class="mt-2 text-xs text-base-content/60">
|
<div class="mt-2 text-xs text-base-content/60">
|
||||||
{bestKD.kills}K / {bestKD.deaths}D
|
{bestKD.kills}K / {bestKD.deaths}D
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +306,7 @@
|
|||||||
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
|
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
|
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
|
||||||
<div class="mt-1 text-3xl font-mono font-bold text-error">
|
<div class="mt-1 font-mono text-3xl font-bold text-error">
|
||||||
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
|
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-xs text-base-content/60">
|
<div class="mt-2 text-xs text-base-content/60">
|
||||||
@@ -231,3 +316,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { api } from '$lib/api';
|
||||||
import { matchesAPI } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, parent }) => {
|
export const load: PageLoad = async ({ parent, params }) => {
|
||||||
try {
|
|
||||||
// Get match data from parent layout
|
|
||||||
const { match } = await parent();
|
const { match } = await parent();
|
||||||
|
|
||||||
// Fetch weapon statistics
|
// Only load weapons data if match is parsed
|
||||||
const weaponsData = await matchesAPI.getMatchWeapons(params.id);
|
if (!match.demo_parsed) {
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
weaponsData: null,
|
||||||
|
meta: {
|
||||||
|
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const weaponsData = await api.matches.getMatchWeapons(params.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
match,
|
match,
|
||||||
weaponsData,
|
weaponsData,
|
||||||
meta: {
|
meta: {
|
||||||
title: `${match.map} Details - Match ${match.match_id} - CS2.WTF`,
|
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||||
description: `Detailed player statistics and weapon breakdown for ${match.map} match`
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load match details:', err);
|
console.error(`Failed to load weapons data for match ${params.id}:`, err);
|
||||||
throw error(500, {
|
// Return null instead of throwing error
|
||||||
message: 'Failed to load match details'
|
return {
|
||||||
});
|
match,
|
||||||
|
weaponsData: null,
|
||||||
|
meta: {
|
||||||
|
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { DollarSign, TrendingUp, ShoppingCart } from 'lucide-svelte';
|
import { TrendingUp, ShoppingCart, AlertCircle } from 'lucide-svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import Card from '$lib/components/ui/Card.svelte';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import LineChart from '$lib/components/charts/LineChart.svelte';
|
import LineChart from '$lib/components/charts/LineChart.svelte';
|
||||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import type { ChartData } from 'chart.js';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
const { match, roundsData } = data;
|
|
||||||
|
|
||||||
// Aggregate team economy per round
|
|
||||||
interface TeamEconomy {
|
interface TeamEconomy {
|
||||||
round: number;
|
round: number;
|
||||||
teamA_bank: number;
|
teamA_bank: number;
|
||||||
@@ -23,19 +20,34 @@
|
|||||||
teamB_buyType: string;
|
teamB_buyType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const teamEconomy: TeamEconomy[] = [];
|
let { data }: { data: PageData } = $props();
|
||||||
|
const { match, roundsData } = data;
|
||||||
|
|
||||||
|
// Get unique team IDs dynamically
|
||||||
|
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||||
|
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||||
|
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||||
|
|
||||||
|
// Only process if rounds data exists
|
||||||
|
let teamEconomy = $state<TeamEconomy[]>([]);
|
||||||
|
let equipmentChartData = $state<ChartData<'line'> | null>(null);
|
||||||
|
let totalRounds = $state(0);
|
||||||
|
let teamA_fullBuys = $state(0);
|
||||||
|
let teamB_fullBuys = $state(0);
|
||||||
|
let teamA_ecos = $state(0);
|
||||||
|
let teamB_ecos = $state(0);
|
||||||
|
|
||||||
|
if (roundsData) {
|
||||||
// Process rounds data to calculate team totals
|
// Process rounds data to calculate team totals
|
||||||
for (const roundData of roundsData.rounds) {
|
for (const roundData of roundsData.rounds) {
|
||||||
const teamAPlayers = roundData.players.filter((p) => {
|
const teamAPlayers = roundData.players.filter((p) => {
|
||||||
// Find player's team from match data
|
|
||||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||||
return matchPlayer?.team_id === 2;
|
return matchPlayer?.team_id === firstTeamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamBPlayers = roundData.players.filter((p) => {
|
const teamBPlayers = roundData.players.filter((p) => {
|
||||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||||
return matchPlayer?.team_id === 3;
|
return matchPlayer?.team_id === secondTeamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamA_bank = teamAPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
const teamA_bank = teamAPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||||
@@ -45,9 +57,10 @@
|
|||||||
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||||
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||||
|
|
||||||
// Classify buy type based on average equipment value
|
const avgTeamA_equipment =
|
||||||
const avgTeamA_equipment = teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0;
|
teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0;
|
||||||
const avgTeamB_equipment = teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0;
|
const avgTeamB_equipment =
|
||||||
|
teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0;
|
||||||
|
|
||||||
const classifyBuyType = (avgEquipment: number): string => {
|
const classifyBuyType = (avgEquipment: number): string => {
|
||||||
if (avgEquipment < 1500) return 'Eco';
|
if (avgEquipment < 1500) return 'Eco';
|
||||||
@@ -71,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare chart data
|
// Prepare chart data
|
||||||
const equipmentChartData = {
|
equipmentChartData = {
|
||||||
labels: teamEconomy.map((r) => `R${r.round}`),
|
labels: teamEconomy.map((r) => `R${r.round}`),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
@@ -94,13 +107,14 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate summary stats
|
// Calculate summary stats
|
||||||
const totalRounds = teamEconomy.length;
|
totalRounds = teamEconomy.length;
|
||||||
const teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
||||||
const teamB_fullBuys = teamEconomy.filter((r) => r.teamB_buyType === 'Full Buy').length;
|
teamB_fullBuys = teamEconomy.filter((r) => r.teamB_buyType === 'Full Buy').length;
|
||||||
const teamA_ecos = teamEconomy.filter((r) => r.teamA_buyType === 'Eco').length;
|
teamA_ecos = teamEconomy.filter((r) => r.teamA_buyType === 'Eco').length;
|
||||||
const teamB_ecos = teamEconomy.filter((r) => r.teamB_buyType === 'Eco').length;
|
teamB_ecos = teamEconomy.filter((r) => r.teamB_buyType === 'Eco').length;
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare table data
|
// Table columns
|
||||||
const tableColumns = [
|
const tableColumns = [
|
||||||
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
|
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
|
||||||
{
|
{
|
||||||
@@ -124,7 +138,7 @@
|
|||||||
label: 'T Equipment',
|
label: 'T Equipment',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
format: (value: number) => `$${value.toLocaleString()}`
|
formatter: (value: number) => `$${value.toLocaleString()}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'teamB_buyType',
|
key: 'teamB_buyType',
|
||||||
@@ -147,25 +161,35 @@
|
|||||||
label: 'CT Equipment',
|
label: 'CT Equipment',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
format: (value: number) => `$${value.toLocaleString()}`
|
formatter: (value: number) => `$${value.toLocaleString()}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'winner',
|
key: 'winner',
|
||||||
label: 'Winner',
|
label: 'Winner',
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (value: number) => {
|
render: (value: number) => {
|
||||||
if (value === 2) return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
|
if (value === 2)
|
||||||
if (value === 3) return '<span class="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
|
return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
|
||||||
|
if (value === 3)
|
||||||
|
return '<span class="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
|
||||||
return '<span class="text-base-content/40">-</span>';
|
return '<span class="text-base-content/40">-</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
{#if !roundsData}
|
||||||
<title>{data.meta.title}</title>
|
<Card padding="lg">
|
||||||
</svelte:head>
|
<div class="text-center">
|
||||||
|
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||||
|
<h2 class="mb-2 text-2xl font-bold text-base-content">Match Not Parsed</h2>
|
||||||
|
<p class="mb-4 text-base-content/60">
|
||||||
|
This match hasn't been parsed yet, so detailed economy data is not available.
|
||||||
|
</p>
|
||||||
|
<Badge variant="warning" size="lg">Demo parsing required</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Summary Cards -->
|
<!-- Summary Cards -->
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-3">
|
||||||
@@ -207,7 +231,9 @@
|
|||||||
Total equipment value for each team across all rounds
|
Total equipment value for each team across all rounds
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{#if equipmentChartData}
|
||||||
<LineChart data={equipmentChartData} height={350} />
|
<LineChart data={equipmentChartData} height={350} />
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Round-by-Round Table -->
|
<!-- Round-by-Round Table -->
|
||||||
@@ -245,3 +271,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { api } from '$lib/api';
|
||||||
import { matchesAPI } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, parent }) => {
|
export const load: PageLoad = async ({ parent, params }) => {
|
||||||
try {
|
|
||||||
// Get match data from parent layout
|
|
||||||
const { match } = await parent();
|
const { match } = await parent();
|
||||||
|
|
||||||
// Fetch round-by-round economy data
|
// Only load rounds data if match is parsed
|
||||||
const roundsData = await matchesAPI.getMatchRounds(params.id);
|
if (!match.demo_parsed) {
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
roundsData: null,
|
||||||
|
meta: {
|
||||||
|
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roundsData = await api.matches.getMatchRounds(params.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
match,
|
match,
|
||||||
roundsData,
|
roundsData,
|
||||||
meta: {
|
meta: {
|
||||||
title: `${match.map} Economy - Match ${match.match_id} - CS2.WTF`,
|
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||||
description: `Round-by-round economy analysis for ${match.map} match`
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load economy data:', err);
|
console.error(`Failed to load rounds data for match ${params.id}:`, err);
|
||||||
throw error(500, {
|
// Return null instead of throwing error
|
||||||
message: 'Failed to load economy data'
|
return {
|
||||||
});
|
match,
|
||||||
|
roundsData: null,
|
||||||
|
meta: {
|
||||||
|
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,9 +27,14 @@
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.enemies_blinded - a.enemies_blinded);
|
.sort((a, b) => b.enemies_blinded - a.enemies_blinded);
|
||||||
|
|
||||||
|
// Get unique team IDs dynamically
|
||||||
|
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||||
|
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||||
|
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||||
|
|
||||||
// Separate by team
|
// Separate by team
|
||||||
const teamAFlashStats = flashStats.filter((p) => p.team_id === 2);
|
const teamAFlashStats = flashStats.filter((p) => p.team_id === firstTeamId);
|
||||||
const teamBFlashStats = flashStats.filter((p) => p.team_id === 3);
|
const teamBFlashStats = flashStats.filter((p) => p.team_id === secondTeamId);
|
||||||
|
|
||||||
// Calculate team totals
|
// Calculate team totals
|
||||||
const calcTeamTotals = (players: typeof flashStats) => ({
|
const calcTeamTotals = (players: typeof flashStats) => ({
|
||||||
@@ -44,39 +49,44 @@
|
|||||||
const teamATotals = calcTeamTotals(teamAFlashStats);
|
const teamATotals = calcTeamTotals(teamAFlashStats);
|
||||||
const teamBTotals = calcTeamTotals(teamBFlashStats);
|
const teamBTotals = calcTeamTotals(teamBFlashStats);
|
||||||
|
|
||||||
// Table columns
|
// Table columns with fixed widths for consistency across multiple tables
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Player', sortable: true },
|
{ key: 'name', label: 'Player', sortable: true, width: '200px' },
|
||||||
{
|
{
|
||||||
key: 'enemies_blinded',
|
key: 'enemies_blinded',
|
||||||
label: 'Enemies Blinded',
|
label: 'Enemies Blinded',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'center' as const
|
align: 'center' as const,
|
||||||
|
width: '150px'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'avg_blind_duration',
|
key: 'avg_blind_duration',
|
||||||
label: 'Avg Duration (s)',
|
label: 'Avg Duration (s)',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
formatter: (value: string) => `${value}s`
|
formatter: (value: string) => `${value}s`,
|
||||||
|
width: '150px'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'flash_assists',
|
key: 'flash_assists',
|
||||||
label: 'Flash Assists',
|
label: 'Flash Assists',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'center' as const
|
align: 'center' as const,
|
||||||
|
width: '130px'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'teammates_blinded',
|
key: 'teammates_blinded',
|
||||||
label: 'Team Flashed',
|
label: 'Team Flashed',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'center' as const
|
align: 'center' as const,
|
||||||
|
width: '130px'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'self_blinded',
|
key: 'self_blinded',
|
||||||
label: 'Self Flashed',
|
label: 'Self Flashed',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'center' as const
|
align: 'center' as const,
|
||||||
|
width: '130px'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
@@ -159,7 +169,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable data={flashStats} {columns} striped hoverable />
|
<DataTable data={flashStats} {columns} striped hoverable fixedLayout />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Team A Details -->
|
<!-- Team A Details -->
|
||||||
@@ -167,7 +177,7 @@
|
|||||||
<div class="border-b border-base-300 bg-terrorist/5 p-6">
|
<div class="border-b border-base-300 bg-terrorist/5 p-6">
|
||||||
<h3 class="text-xl font-bold text-terrorist">Terrorists - Flash Stats</h3>
|
<h3 class="text-xl font-bold text-terrorist">Terrorists - Flash Stats</h3>
|
||||||
</div>
|
</div>
|
||||||
<DataTable data={teamAFlashStats} {columns} striped hoverable />
|
<DataTable data={teamAFlashStats} {columns} striped hoverable fixedLayout />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Team B Details -->
|
<!-- Team B Details -->
|
||||||
@@ -175,7 +185,7 @@
|
|||||||
<div class="border-b border-base-300 bg-ct/5 p-6">
|
<div class="border-b border-base-300 bg-ct/5 p-6">
|
||||||
<h3 class="text-xl font-bold text-ct">Counter-Terrorists - Flash Stats</h3>
|
<h3 class="text-xl font-bold text-ct">Counter-Terrorists - Flash Stats</h3>
|
||||||
</div>
|
</div>
|
||||||
<DataTable data={teamBFlashStats} {columns} striped hoverable />
|
<DataTable data={teamBFlashStats} {columns} striped hoverable fixedLayout />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Info Box -->
|
<!-- Info Box -->
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Computed filtered and sorted matches
|
// Computed filtered and sorted matches
|
||||||
const displayMatches = $derived(() => {
|
const displayMatches = $derived.by(() => {
|
||||||
let filtered = [...matches];
|
let filtered = [...matches];
|
||||||
|
|
||||||
// Apply result filter
|
// Apply result filter
|
||||||
@@ -267,15 +267,15 @@
|
|||||||
{#if matches.length > 0 && resultFilter !== 'all'}
|
{#if matches.length > 0 && resultFilter !== 'all'}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Badge variant="info">
|
<Badge variant="info">
|
||||||
Showing {displayMatches().length} of {matches.length} matches
|
Showing {displayMatches.length} of {matches.length} matches
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Matches Grid -->
|
<!-- Matches Grid -->
|
||||||
{#if displayMatches().length > 0}
|
{#if displayMatches.length > 0}
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each displayMatches() as match}
|
{#each displayMatches as match}
|
||||||
<MatchCard {match} />
|
<MatchCard {match} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,12 +299,12 @@
|
|||||||
<div class="mt-8 text-center">
|
<div class="mt-8 text-center">
|
||||||
<Badge variant="default">
|
<Badge variant="default">
|
||||||
All matches loaded ({matches.length} total{resultFilter !== 'all'
|
All matches loaded ({matches.length} total{resultFilter !== 'all'
|
||||||
? `, ${displayMatches().length} shown`
|
? `, ${displayMatches.length} shown`
|
||||||
: ''})
|
: ''})
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if matches.length > 0 && displayMatches().length === 0}
|
{:else if matches.length > 0 && displayMatches.length === 0}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
|
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
|
||||||
|
|||||||
Reference in New Issue
Block a user