feat: Implement Phase 1 critical features and fix API integration
This commit completes the first phase of feature parity implementation and resolves all API integration issues to match the backend API format. ## API Integration Fixes - Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated) - Update TypeScript types to make fields optional where backend doesn't guarantee them - Update Zod schemas to validate optional fields correctly - Fix mock data to match real API response format (plain arrays, not wrapped objects) - Update UI components to handle undefined values with proper fallbacks - Add comprehensive API documentation for Match and Player endpoints ## Phase 1 Features Implemented (3/6) ### 1. Player Tracking System ✅ - Created TrackPlayerModal.svelte with auth code input - Integrated track/untrack player API endpoints - Added UI for providing optional share code - Displays tracked status on player profiles - Full validation and error handling ### 2. Share Code Parsing ✅ - Created ShareCodeInput.svelte component - Added to matches page for easy match submission - Real-time validation of share code format - Parse status feedback with loading states - Auto-redirect to match page on success ### 3. VAC/Game Ban Status ✅ - Added VAC and game ban count/date fields to Player type - Display status badges on player profile pages - Show ban count and date when available - Visual indicators using DaisyUI badge components ## Component Improvements - Modal.svelte: Added Svelte 5 Snippet types, actions slot support - ThemeToggle.svelte: Removed deprecated svelte:component usage - Tooltip.svelte: Fixed type safety with Snippet type - All new components follow Svelte 5 runes pattern ($state, $derived, $bindable) ## Type Safety & Linting - Fixed all ESLint errors (any types → proper types) - Fixed form label accessibility issues - Replaced error: any with error: unknown + proper type guards - Added Snippet type imports where needed - Updated all catch blocks to use instanceof Error checks ## Static Assets - Migrated all files from public/ to static/ directory per SvelteKit best practices - Moved 200+ map icons, screenshots, and other assets - Updated all import paths to use /images/ (served from static/) ## Documentation - Created IMPLEMENTATION_STATUS.md tracking all 15 missing features - Updated API.md with optional field annotations - Created MATCHES_API.md with comprehensive endpoint documentation - Added inline comments marking optional vs required fields ## Testing - Updated mock fixtures to remove default values - Fixed mock handlers to return plain arrays like real API - Ensured all components handle undefined gracefully ## Remaining Phase 1 Tasks - [ ] Add VAC status column to match scoreboard - [ ] Create weapons statistics tab for matches - [ ] Implement recently visited players on home page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { Search, TrendingUp, Users, Zap } from 'lucide-svelte';
|
||||
import { Search, TrendingUp, Users, Zap, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
// Get data from page loader
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Transform API matches to display format
|
||||
const featuredMatches = data.featuredMatches.map((match) => ({
|
||||
id: match.match_id.toString(),
|
||||
map: match.map || 'unknown',
|
||||
mapDisplay: match.map ? match.map.replace('de_', '').toUpperCase() : 'UNKNOWN',
|
||||
scoreT: match.score_team_a,
|
||||
scoreCT: match.score_team_b,
|
||||
date: new Date(match.date).toLocaleString(),
|
||||
live: false // TODO: Implement live match detection
|
||||
}));
|
||||
// Use matches directly - already transformed by API client
|
||||
const featuredMatches = data.featuredMatches;
|
||||
|
||||
const stats = [
|
||||
{ icon: Users, label: 'Players Tracked', value: '1.2M+' },
|
||||
{ icon: TrendingUp, label: 'Matches Analyzed', value: '500K+' },
|
||||
{ icon: Zap, label: 'Demos Parsed', value: '2M+' }
|
||||
];
|
||||
|
||||
// Carousel state
|
||||
let currentSlide = $state(0);
|
||||
let isPaused = $state(false);
|
||||
let autoRotateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let manualNavigationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let windowWidth = $state(1024); // Default to desktop
|
||||
|
||||
// Track window width for responsive slides
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
windowWidth = window.innerWidth;
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth = window.innerWidth;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
// Return empty cleanup function for server-side rendering path
|
||||
return () => {};
|
||||
});
|
||||
|
||||
// Determine matches per slide based on screen width
|
||||
const matchesPerSlide = $derived(windowWidth < 768 ? 1 : windowWidth < 1024 ? 2 : 3);
|
||||
|
||||
const totalSlides = $derived(Math.ceil(featuredMatches.length / matchesPerSlide));
|
||||
|
||||
// Get visible matches for current slide
|
||||
const visibleMatches = $derived.by(() => {
|
||||
const start = currentSlide * matchesPerSlide;
|
||||
return featuredMatches.slice(start, start + matchesPerSlide);
|
||||
});
|
||||
|
||||
function nextSlide() {
|
||||
currentSlide = (currentSlide + 1) % totalSlides;
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
currentSlide = (currentSlide - 1 + totalSlides) % totalSlides;
|
||||
}
|
||||
|
||||
function goToSlide(index: number) {
|
||||
currentSlide = index;
|
||||
pauseAutoRotateTemporarily();
|
||||
}
|
||||
|
||||
function pauseAutoRotateTemporarily() {
|
||||
isPaused = true;
|
||||
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
|
||||
manualNavigationTimeout = setTimeout(() => {
|
||||
isPaused = false;
|
||||
}, 10000); // Resume after 10 seconds
|
||||
}
|
||||
|
||||
function handleManualNavigation(direction: 'prev' | 'next') {
|
||||
if (direction === 'prev') {
|
||||
prevSlide();
|
||||
} else {
|
||||
nextSlide();
|
||||
}
|
||||
pauseAutoRotateTemporarily();
|
||||
}
|
||||
|
||||
// Auto-rotation effect
|
||||
$effect(() => {
|
||||
if (autoRotateInterval) clearInterval(autoRotateInterval);
|
||||
|
||||
autoRotateInterval = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
nextSlide();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (autoRotateInterval) clearInterval(autoRotateInterval);
|
||||
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -85,46 +161,72 @@
|
||||
<Button variant="ghost" href="/matches">View All</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each featuredMatches as match}
|
||||
<Card variant="interactive" padding="none">
|
||||
<a href={`/match/${match.id}`} class="block">
|
||||
<div
|
||||
class="relative h-48 overflow-hidden rounded-t-md bg-gradient-to-br from-base-300 to-base-100"
|
||||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-6xl font-bold text-base-content/10">{match.mapDisplay}</span>
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<Badge variant="default">{match.map}</Badge>
|
||||
</div>
|
||||
{#if match.live}
|
||||
<div class="absolute right-4 top-4">
|
||||
<Badge variant="error" size="sm">
|
||||
<span class="animate-pulse">● LIVE</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if featuredMatches.length > 0}
|
||||
<!-- Carousel Container -->
|
||||
<div
|
||||
class="relative"
|
||||
onmouseenter={() => (isPaused = true)}
|
||||
onmouseleave={() => (isPaused = false)}
|
||||
role="region"
|
||||
aria-label="Featured matches carousel"
|
||||
>
|
||||
<!-- Matches Grid with Fade Transition -->
|
||||
<div class="transition-opacity duration-500" class:opacity-100={true}>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each visibleMatches as match (match.match_id)}
|
||||
<MatchCard {match} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="mb-3 flex items-center justify-center gap-4">
|
||||
<span class="font-mono text-2xl font-bold text-terrorist">{match.scoreT}</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
<span class="font-mono text-2xl font-bold text-ct">{match.scoreCT}</span>
|
||||
</div>
|
||||
<!-- Navigation Arrows - Only show if there are multiple slides -->
|
||||
{#if totalSlides > 1}
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
onclick={() => handleManualNavigation('prev')}
|
||||
class="group absolute left-0 top-1/2 z-10 -translate-x-4 -translate-y-1/2 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:-translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:-translate-x-6 md:hover:-translate-x-7"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft
|
||||
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-base-content/60">
|
||||
<span>{match.date}</span>
|
||||
{#if !match.live}
|
||||
<Badge variant="default" size="sm">Completed</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
onclick={() => handleManualNavigation('next')}
|
||||
class="group absolute right-0 top-1/2 z-10 -translate-y-1/2 translate-x-4 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:translate-x-6 md:hover:translate-x-7"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight
|
||||
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dot Indicators - Only show if there are multiple slides -->
|
||||
{#if totalSlides > 1}
|
||||
<div class="mt-8 flex justify-center gap-2">
|
||||
{#each Array(totalSlides) as _, i}
|
||||
<button
|
||||
onclick={() => goToSlide(i)}
|
||||
class="h-2 w-2 rounded-full transition-all duration-300 hover:scale-125 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
class:bg-primary={i === currentSlide}
|
||||
class:w-8={i === currentSlide}
|
||||
class:bg-base-300={i !== currentSlide}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- No Matches Found -->
|
||||
<div class="rounded-lg border border-base-300 bg-base-100 p-12 text-center">
|
||||
<p class="text-lg text-base-content/60">No featured matches available at the moment.</p>
|
||||
<p class="mt-2 text-sm text-base-content/40">Check back soon for the latest matches!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
await parent();
|
||||
|
||||
try {
|
||||
// Load featured matches (limit to 3 for homepage)
|
||||
const matchesData = await api.matches.getMatches({ limit: 3 });
|
||||
// Load featured matches for homepage carousel
|
||||
const matchesData = await api.matches.getMatches({ limit: 9 });
|
||||
|
||||
return {
|
||||
featuredMatches: matchesData.matches.slice(0, 3), // Ensure max 3 matches
|
||||
featuredMatches: matchesData.matches.slice(0, 9), // Get 9 matches for carousel (3 slides)
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
description:
|
||||
|
||||
163
src/routes/api/[...path]/+server.ts
Normal file
163
src/routes/api/[...path]/+server.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* SvelteKit API Route Handler
|
||||
*
|
||||
* This catch-all route proxies requests to the backend API.
|
||||
* Benefits over Vite proxy:
|
||||
* - Works in development, preview, and production
|
||||
* - Single code path for all environments
|
||||
* - Can add caching, rate limiting, auth in the future
|
||||
* - No CORS issues
|
||||
*
|
||||
* Backend selection:
|
||||
* - Set VITE_API_BASE_URL=http://localhost:8000 for local development
|
||||
* - Set VITE_API_BASE_URL=https://api.csgow.tf for production API
|
||||
*/
|
||||
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Get backend API URL from environment variable
|
||||
// Note: We use $env/dynamic/private instead of import.meta.env for server-side access
|
||||
const API_BASE_URL = env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
/**
|
||||
* GET request handler
|
||||
* Forwards GET requests to the backend API
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, request }) => {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
|
||||
// Construct full backend URL
|
||||
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
|
||||
|
||||
try {
|
||||
// Forward request to backend
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
// Forward relevant headers
|
||||
Accept: request.headers.get('Accept') || 'application/json',
|
||||
'User-Agent': 'CS2.WTF Frontend'
|
||||
}
|
||||
});
|
||||
|
||||
// Check if request was successful
|
||||
if (!response.ok) {
|
||||
throw error(response.status, `Backend API returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Get response data
|
||||
const data = await response.json();
|
||||
|
||||
// Return JSON response
|
||||
return json(data);
|
||||
} catch (err) {
|
||||
// Log error for debugging
|
||||
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
|
||||
|
||||
// Handle fetch errors
|
||||
if (err instanceof Error && err.message.includes('fetch')) {
|
||||
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
|
||||
}
|
||||
|
||||
// Re-throw SvelteKit errors
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST request handler
|
||||
* Forwards POST requests to the backend API
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, request }) => {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
|
||||
// Construct full backend URL
|
||||
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
|
||||
|
||||
try {
|
||||
// Get request body
|
||||
const body = await request.text();
|
||||
|
||||
// Forward request to backend
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': request.headers.get('Content-Type') || 'application/json',
|
||||
Accept: request.headers.get('Accept') || 'application/json',
|
||||
'User-Agent': 'CS2.WTF Frontend'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
// Check if request was successful
|
||||
if (!response.ok) {
|
||||
throw error(response.status, `Backend API returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Get response data
|
||||
const data = await response.json();
|
||||
|
||||
// Return JSON response
|
||||
return json(data);
|
||||
} catch (err) {
|
||||
// Log error for debugging
|
||||
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
|
||||
|
||||
// Handle fetch errors
|
||||
if (err instanceof Error && err.message.includes('fetch')) {
|
||||
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
|
||||
}
|
||||
|
||||
// Re-throw SvelteKit errors
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE request handler
|
||||
* Forwards DELETE requests to the backend API
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, url, request }) => {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
|
||||
// Construct full backend URL
|
||||
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
|
||||
|
||||
try {
|
||||
// Forward request to backend
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: request.headers.get('Accept') || 'application/json',
|
||||
'User-Agent': 'CS2.WTF Frontend'
|
||||
}
|
||||
});
|
||||
|
||||
// Check if request was successful
|
||||
if (!response.ok) {
|
||||
throw error(response.status, `Backend API returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Get response data
|
||||
const data = await response.json();
|
||||
|
||||
// Return JSON response
|
||||
return json(data);
|
||||
} catch (err) {
|
||||
// Log error for debugging
|
||||
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
|
||||
|
||||
// Handle fetch errors
|
||||
if (err instanceof Error && err.message.includes('fetch')) {
|
||||
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
|
||||
}
|
||||
|
||||
// Re-throw SvelteKit errors
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Download, Calendar, Clock } from 'lucide-svelte';
|
||||
import { Download, Calendar, Clock, ArrowLeft } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
||||
|
||||
const { match } = data;
|
||||
|
||||
function handleBack() {
|
||||
// Navigate back to matches page
|
||||
goto('/matches');
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Overview', href: `/match/${match.match_id}` },
|
||||
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
|
||||
@@ -26,17 +33,42 @@
|
||||
? `${Math.floor(match.duration / 60)}:${(match.duration % 60).toString().padStart(2, '0')}`
|
||||
: 'N/A';
|
||||
|
||||
const mapName = match.map.replace('de_', '').toUpperCase();
|
||||
const mapName = formatMapName(match.map);
|
||||
const mapBg = getMapBackground(match.map);
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = '/images/map_screenshots/default.webp';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Match Header -->
|
||||
<div class="border-b border-base-300 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Match Header with Background -->
|
||||
<div class="relative overflow-hidden border-b border-base-300">
|
||||
<!-- Background Image -->
|
||||
<div class="absolute inset-0">
|
||||
<img src={mapBg} alt={mapName} class="h-full w-full object-cover" onerror={handleImageError} />
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/70 to-black/50"></div>
|
||||
</div>
|
||||
|
||||
<div class="container relative mx-auto px-4 py-8">
|
||||
<!-- Back Button -->
|
||||
<div class="mb-4">
|
||||
<button
|
||||
onclick={handleBack}
|
||||
class="btn btn-ghost btn-sm gap-2 text-white/80 hover:text-white"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
<span>Back to Matches</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Map Name -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<Badge variant="default" size="lg">{match.map}</Badge>
|
||||
<h1 class="mt-2 text-4xl font-bold text-base-content">{mapName}</h1>
|
||||
{#if match.map}
|
||||
<Badge variant="default" size="lg">{match.map}</Badge>
|
||||
{/if}
|
||||
<h1 class="mt-2 text-4xl font-bold text-white drop-shadow-lg">{mapName}</h1>
|
||||
</div>
|
||||
{#if match.demo_parsed}
|
||||
<button class="btn btn-outline btn-primary gap-2">
|
||||
@@ -49,18 +81,20 @@
|
||||
<!-- Score -->
|
||||
<div class="mb-6 flex items-center justify-center gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-base-content/60">TERRORISTS</div>
|
||||
<div class="font-mono text-5xl font-bold text-terrorist">{match.score_team_a}</div>
|
||||
<div class="text-sm font-medium text-white/70">TERRORISTS</div>
|
||||
<div class="font-mono text-5xl font-bold text-terrorist drop-shadow-lg">
|
||||
{match.score_team_a}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content/40">:</div>
|
||||
<div class="text-3xl font-bold text-white/40">:</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-medium text-base-content/60">COUNTER-TERRORISTS</div>
|
||||
<div class="font-mono text-5xl font-bold text-ct">{match.score_team_b}</div>
|
||||
<div class="text-sm font-medium text-white/70">COUNTER-TERRORISTS</div>
|
||||
<div class="font-mono text-5xl font-bold text-ct drop-shadow-lg">{match.score_team_b}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Meta -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-base-content/70">
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-white/80">
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-4 w-4" />
|
||||
<span>{formattedDate}</span>
|
||||
@@ -76,7 +110,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-6">
|
||||
<div class="mt-6 rounded-lg bg-black/30 p-4 backdrop-blur-sm">
|
||||
<Tabs {tabs} variant="bordered" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Trophy } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
import RoundTimeline from '$lib/components/RoundTimeline.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { MatchPlayer } from '$lib/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match } = data;
|
||||
const { match, rounds } = data;
|
||||
|
||||
// Group players by team - use dynamic team IDs from API
|
||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||
@@ -25,7 +27,8 @@
|
||||
const totalKills = players.reduce((sum, p) => sum + p.kills, 0);
|
||||
const totalDeaths = players.reduce((sum, p) => sum + p.deaths, 0);
|
||||
const totalADR = players.reduce((sum, p) => sum + (p.adr || 0), 0);
|
||||
const avgKAST = players.reduce((sum, p) => sum + (p.kast || 0), 0) / players.length;
|
||||
const avgKAST =
|
||||
players.length > 0 ? players.reduce((sum, p) => sum + (p.kast || 0), 0) / players.length : 0;
|
||||
|
||||
return {
|
||||
kills: totalKills,
|
||||
@@ -116,6 +119,7 @@
|
||||
<th style="width: 100px;">ADR</th>
|
||||
<th style="width: 100px;">HS%</th>
|
||||
<th style="width: 100px;">KAST%</th>
|
||||
<th style="width: 180px;">Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -135,9 +139,18 @@
|
||||
<td class="font-mono font-semibold">{player.kills}</td>
|
||||
<td class="font-mono">{player.deaths}</td>
|
||||
<td class="font-mono">{player.assists}</td>
|
||||
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td>
|
||||
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td>
|
||||
<td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
|
||||
<td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
|
||||
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
|
||||
<td>
|
||||
<PremierRatingBadge
|
||||
rating={player.rank_new}
|
||||
oldRating={player.rank_old}
|
||||
size="sm"
|
||||
showChange={true}
|
||||
showIcon={false}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -161,6 +174,7 @@
|
||||
<th style="width: 100px;">ADR</th>
|
||||
<th style="width: 100px;">HS%</th>
|
||||
<th style="width: 100px;">KAST%</th>
|
||||
<th style="width: 180px;">Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -180,9 +194,18 @@
|
||||
<td class="font-mono font-semibold">{player.kills}</td>
|
||||
<td class="font-mono">{player.deaths}</td>
|
||||
<td class="font-mono">{player.assists}</td>
|
||||
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td>
|
||||
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td>
|
||||
<td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
|
||||
<td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
|
||||
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
|
||||
<td>
|
||||
<PremierRatingBadge
|
||||
rating={player.rank_new}
|
||||
oldRating={player.rank_old}
|
||||
size="sm"
|
||||
showChange={true}
|
||||
showIcon={false}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -191,15 +214,23 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Coming Soon Badges for Round Timeline -->
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
|
||||
<p class="text-base-content/60">
|
||||
Round-by-round timeline visualization coming soon. Will show bomb plants, defuses, and round
|
||||
winners.
|
||||
</p>
|
||||
<Badge variant="warning" size="md" class="mt-4">Coming in Future Update</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- Round Timeline -->
|
||||
{#if rounds && rounds.rounds && rounds.rounds.length > 0}
|
||||
<RoundTimeline rounds={rounds.rounds} />
|
||||
{:else}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
|
||||
<p class="text-base-content/60">
|
||||
Round-by-round timeline data is not available for this match. This requires the demo to be
|
||||
fully parsed.
|
||||
</p>
|
||||
{#if !match.demo_parsed}
|
||||
<Badge variant="warning" size="md" class="mt-4">Demo Not Yet Parsed</Badge>
|
||||
{:else}
|
||||
<Badge variant="info" size="md" class="mt-4">Round Data Not Available</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
21
src/routes/match/[id]/+page.ts
Normal file
21
src/routes/match/[id]/+page.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const matchId = params.id;
|
||||
|
||||
try {
|
||||
// Fetch rounds data for the timeline visualization
|
||||
const rounds = await api.matches.getMatchRounds(matchId);
|
||||
|
||||
return {
|
||||
rounds
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to load rounds for match ${matchId}:`, err);
|
||||
// Return empty rounds if the endpoint fails (demo might not be parsed yet)
|
||||
return {
|
||||
rounds: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -20,8 +20,8 @@
|
||||
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 filteredMessages = $state<NonNullable<PageData['chatData']>['messages']>([]);
|
||||
let messagesByRound = $state<Record<number, NonNullable<PageData['chatData']>['messages']>>({});
|
||||
let rounds = $state<number[]>([]);
|
||||
let totalMessages = $state(0);
|
||||
let teamChatCount = $state(0);
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
// Get player info for a message
|
||||
const getPlayerInfo = (playerId: number) => {
|
||||
const player = match.players?.find((p) => p.id === playerId);
|
||||
const player = match.players?.find((p) => p.id === String(playerId));
|
||||
return {
|
||||
name: player?.name || `Player ${playerId}`,
|
||||
team_id: player?.team_id || 0
|
||||
@@ -38,16 +38,16 @@
|
||||
|
||||
if (chatData) {
|
||||
// Get unique players who sent messages
|
||||
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id))).map(
|
||||
(playerId) => {
|
||||
const player = match.players?.find((p) => p.id === playerId);
|
||||
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id)))
|
||||
.filter((playerId): playerId is number => playerId !== undefined)
|
||||
.map((playerId) => {
|
||||
const player = match.players?.find((p) => p.id === String(playerId));
|
||||
return {
|
||||
id: playerId,
|
||||
name: player?.name || `Player ${playerId}`,
|
||||
team_id: player?.team_id
|
||||
team_id: player?.team_id || 0
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Filter messages
|
||||
const computeFilteredMessages = () => {
|
||||
@@ -199,7 +199,11 @@
|
||||
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
|
||||
</h3>
|
||||
<Badge variant="default" size="sm">
|
||||
{messagesByRound[round].length} message{messagesByRound[round].length !== 1
|
||||
{messagesByRound[round] ? messagesByRound[round].length : 0} message{(messagesByRound[
|
||||
round
|
||||
]
|
||||
? messagesByRound[round].length
|
||||
: 0) !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</Badge>
|
||||
@@ -209,7 +213,7 @@
|
||||
<!-- Messages -->
|
||||
<div class="divide-y divide-base-300">
|
||||
{#each messagesByRound[round] as message}
|
||||
{@const playerInfo = getPlayerInfo(message.player_id)}
|
||||
{@const playerInfo = getPlayerInfo(message.player_id || 0)}
|
||||
<div class="p-4 transition-colors hover:bg-base-200/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Player Avatar/Icon -->
|
||||
@@ -226,7 +230,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<a
|
||||
href="/player/{message.player_id}"
|
||||
href={`/player/${message.player_id || 0}`}
|
||||
class="font-semibold hover:underline"
|
||||
class:text-terrorist={playerInfo.team_id === 2}
|
||||
class:text-ct={playerInfo.team_id === 3}
|
||||
|
||||
@@ -1,38 +1,291 @@
|
||||
<script lang="ts">
|
||||
import { Crosshair, Target } from 'lucide-svelte';
|
||||
import { Target, Crosshair, AlertCircle } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
import PieChart from '$lib/components/charts/PieChart.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
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 player stats with damage metrics
|
||||
const playersWithDamageStats = hasPlayerData
|
||||
? (match.players || []).map((player) => {
|
||||
const damage = player.dmg_enemy || 0;
|
||||
const avgDamagePerRound = match.max_rounds > 0 ? damage / match.max_rounds : 0;
|
||||
|
||||
// Note: Hit group breakdown would require weapon stats data
|
||||
// For now, using total damage metrics
|
||||
return {
|
||||
...player,
|
||||
damage,
|
||||
avgDamagePerRound
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
// Sort by damage descending
|
||||
const sortedByDamage = hasPlayerData
|
||||
? [...playersWithDamageStats].sort((a, b) => b.damage - a.damage)
|
||||
: [];
|
||||
|
||||
// Team damage stats
|
||||
const teamAPlayers = hasPlayerData
|
||||
? playersWithDamageStats.filter((p) => p.team_id === firstTeamId)
|
||||
: [];
|
||||
const teamBPlayers = hasPlayerData
|
||||
? playersWithDamageStats.filter((p) => p.team_id === secondTeamId)
|
||||
: [];
|
||||
|
||||
const teamAStats = hasPlayerData
|
||||
? {
|
||||
totalDamage: teamAPlayers.reduce((sum, p) => sum + p.damage, 0),
|
||||
avgDamagePerPlayer:
|
||||
teamAPlayers.length > 0
|
||||
? teamAPlayers.reduce((sum, p) => sum + p.damage, 0) / teamAPlayers.length
|
||||
: 0
|
||||
}
|
||||
: { totalDamage: 0, avgDamagePerPlayer: 0 };
|
||||
|
||||
const teamBStats = hasPlayerData
|
||||
? {
|
||||
totalDamage: teamBPlayers.reduce((sum, p) => sum + p.damage, 0),
|
||||
avgDamagePerPlayer:
|
||||
teamBPlayers.length > 0
|
||||
? teamBPlayers.reduce((sum, p) => sum + p.damage, 0) / teamBPlayers.length
|
||||
: 0
|
||||
}
|
||||
: { totalDamage: 0, avgDamagePerPlayer: 0 };
|
||||
|
||||
// Top damage dealers (top 3)
|
||||
const topDamageDealers = sortedByDamage.slice(0, 3);
|
||||
|
||||
// Damage table columns
|
||||
const damageColumns = [
|
||||
{
|
||||
key: 'name' as const,
|
||||
label: 'Player',
|
||||
sortable: true,
|
||||
render: (value: unknown, row: (typeof playersWithDamageStats)[0]) => {
|
||||
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'damage' as const,
|
||||
label: 'Damage Dealt',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
class: 'font-mono font-semibold',
|
||||
format: (value: unknown) => (typeof value === 'number' ? value.toLocaleString() : '0')
|
||||
},
|
||||
{
|
||||
key: 'avgDamagePerRound' as const,
|
||||
label: 'Avg Damage/Round',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
class: 'font-mono',
|
||||
format: (value: unknown) => (typeof value === 'number' ? value.toFixed(1) : '0.0')
|
||||
},
|
||||
{
|
||||
key: 'headshot' as const,
|
||||
label: 'Headshots',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
},
|
||||
{
|
||||
key: 'kills' as const,
|
||||
label: 'Kills',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
},
|
||||
{
|
||||
key: 'dmg_team' as const,
|
||||
label: 'Team Damage',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
class: 'font-mono',
|
||||
render: (value: unknown) => {
|
||||
const dmg = typeof value === 'number' ? value : 0;
|
||||
if (!dmg || dmg === 0) return '<span class="text-base-content/40">-</span>';
|
||||
return `<span class="text-error">${dmg.toLocaleString()}</span>`;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Hit group distribution data (placeholder - would need weapon stats data)
|
||||
// For now, showing utility damage breakdown instead
|
||||
const utilityDamageData = hasPlayerData
|
||||
? {
|
||||
labels: ['HE Grenades', 'Fire (Molotov/Inc)'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Utility Damage',
|
||||
data: [
|
||||
playersWithDamageStats.reduce((sum, p) => sum + (p.ud_he || 0), 0),
|
||||
playersWithDamageStats.reduce((sum, p) => sum + (p.ud_flames || 0), 0)
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgba(34, 197, 94, 0.8)', // Green for HE
|
||||
'rgba(239, 68, 68, 0.8)' // Red for Fire
|
||||
],
|
||||
borderColor: ['rgba(34, 197, 94, 1)', 'rgba(239, 68, 68, 1)'],
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
: {
|
||||
labels: [],
|
||||
datasets: []
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<svelte:head>
|
||||
<title>Damage Analysis - CS2.WTF</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !hasPlayerData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<Crosshair class="mx-auto mb-4 h-16 w-16 text-error" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">Damage Analysis</h2>
|
||||
<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">
|
||||
Damage dealt/received, hit group breakdown, damage heatmaps, and weapon range analysis.
|
||||
Detailed damage statistics are not available for this match.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Coming in Future Update</Badge>
|
||||
<Badge variant="warning" size="lg">Player data unavailable</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Team Damage Summary Cards -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Terrorists Damage Stats -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists Damage</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Total Damage</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{teamAStats.totalDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg per Player</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{Math.round(teamAStats.avgDamagePerPlayer).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<!-- Counter-Terrorists Damage Stats -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists Damage</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Total Damage</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{teamBStats.totalDamage.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-base-content/60">Avg per Player</div>
|
||||
<div class="text-3xl font-bold text-base-content">
|
||||
{Math.round(teamBStats.avgDamagePerPlayer).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Top Damage Dealers -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
{#each topDamageDealers as player, index}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Target
|
||||
class="h-5 w-5 {index === 0
|
||||
? 'text-warning'
|
||||
: index === 1
|
||||
? 'text-base-content/70'
|
||||
: 'text-base-content/50'}"
|
||||
/>
|
||||
<h3 class="font-semibold text-base-content">
|
||||
#{index + 1} Damage Dealer
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-2xl font-bold {player.team_id === firstTeamId
|
||||
? 'text-terrorist'
|
||||
: 'text-ct'}"
|
||||
>
|
||||
{player.name}
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-primary">
|
||||
{player.damage.toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
{player.avgDamagePerRound.toFixed(1)} ADR
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Utility Damage Distribution -->
|
||||
<Card padding="lg">
|
||||
<Crosshair class="mb-2 h-8 w-8 text-error" />
|
||||
<h3 class="mb-1 text-lg font-semibold">Damage Summary</h3>
|
||||
<p class="text-sm text-base-content/60">Total damage dealt and received</p>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Utility Damage Distribution</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Breakdown of damage dealt by grenades and fire across all players
|
||||
</p>
|
||||
</div>
|
||||
{#if utilityDamageData.datasets.length > 0 && utilityDamageData.datasets[0]?.data.some((v) => v > 0)}
|
||||
<PieChart data={utilityDamageData} height={300} />
|
||||
{:else}
|
||||
<div class="py-12 text-center text-base-content/40">
|
||||
<Crosshair class="mx-auto mb-2 h-12 w-12" />
|
||||
<p>No utility damage recorded for this match</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Target class="mb-2 h-8 w-8 text-primary" />
|
||||
<h3 class="mb-1 text-lg font-semibold">Hit Groups</h3>
|
||||
<p class="text-sm text-base-content/60">Headshots, chest, legs, arms breakdown</p>
|
||||
<!-- Player Damage Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Player Damage Statistics</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">Detailed damage breakdown for all players</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={sortedByDamage} columns={damageColumns} striped hoverable />
|
||||
</Card>
|
||||
|
||||
<!-- Additional Info Note -->
|
||||
<Card padding="lg">
|
||||
<Crosshair class="mb-2 h-8 w-8 text-info" />
|
||||
<h3 class="mb-1 text-lg font-semibold">Range Analysis</h3>
|
||||
<p class="text-sm text-base-content/60">Damage effectiveness by distance</p>
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertCircle class="h-5 w-5 flex-shrink-0 text-info" />
|
||||
<div class="text-sm">
|
||||
<h3 class="mb-1 font-semibold text-base-content">About Damage Statistics</h3>
|
||||
<p class="text-base-content/70">
|
||||
Damage statistics show total damage dealt to enemies throughout the match. Average
|
||||
damage per round (ADR) is calculated by dividing total damage by the number of rounds
|
||||
played. Hit group breakdown (head, chest, legs, etc.) is available in weapon-specific
|
||||
statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -45,63 +45,90 @@
|
||||
// Prepare data table columns
|
||||
const detailsColumns = [
|
||||
{
|
||||
key: 'name',
|
||||
key: 'name' as keyof (typeof playersWithStats)[0],
|
||||
label: 'Player',
|
||||
sortable: true,
|
||||
render: (value: string, row: (typeof playersWithStats)[0]) => {
|
||||
render: (value: string | number | boolean | undefined, row: (typeof playersWithStats)[0]) => {
|
||||
const strValue = value !== undefined ? String(value) : '';
|
||||
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}">${strValue}</a>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'kills',
|
||||
key: 'kills' as keyof (typeof playersWithStats)[0],
|
||||
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: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||
{
|
||||
key: 'kd',
|
||||
key: 'deaths' as keyof (typeof playersWithStats)[0],
|
||||
label: 'D',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
},
|
||||
{
|
||||
key: 'assists' as keyof (typeof playersWithStats)[0],
|
||||
label: 'A',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
},
|
||||
{
|
||||
key: 'kd' as keyof (typeof playersWithStats)[0],
|
||||
label: 'K/D',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: number) => v.toFixed(2)
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(2) : '0.00'
|
||||
},
|
||||
{
|
||||
key: 'adr',
|
||||
key: 'adr' as keyof (typeof playersWithStats)[0],
|
||||
label: 'ADR',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: number) => v.toFixed(1)
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(1) : '0.0'
|
||||
},
|
||||
{
|
||||
key: 'hsPercent',
|
||||
key: 'hsPercent' as keyof (typeof playersWithStats)[0],
|
||||
label: 'HS%',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: number) => `${v.toFixed(1)}%`
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(1) : '0.0'
|
||||
},
|
||||
{
|
||||
key: 'kast',
|
||||
key: 'kast' as keyof (typeof playersWithStats)[0],
|
||||
label: 'KAST%',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: number) => `${v.toFixed(1)}%`
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(1) : '-'
|
||||
},
|
||||
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||
{
|
||||
key: 'mk_5',
|
||||
key: 'mvp' as keyof (typeof playersWithStats)[0],
|
||||
label: 'MVP',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono'
|
||||
},
|
||||
{
|
||||
key: 'mk_5' as keyof (typeof playersWithStats)[0],
|
||||
label: 'Aces',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
render: (value: number) => {
|
||||
if (value > 0) return `<span class="badge badge-warning badge-sm">${value}</span>`;
|
||||
render: (
|
||||
value: string | number | boolean | undefined,
|
||||
_row: (typeof playersWithStats)[0]
|
||||
) => {
|
||||
const numValue = value !== undefined ? (value as number) : 0;
|
||||
if (numValue > 0) return `<span class="badge badge-warning badge-sm">${numValue}</span>`;
|
||||
return '<span class="text-base-content/40">-</span>';
|
||||
}
|
||||
}
|
||||
@@ -142,39 +169,41 @@
|
||||
? playersWithStats.filter((p) => p.team_id === secondTeamId)
|
||||
: [];
|
||||
|
||||
const teamAStats = hasPlayerData
|
||||
? {
|
||||
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
|
||||
),
|
||||
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||
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 teamAStats =
|
||||
hasPlayerData && teamAPlayers.length > 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
|
||||
),
|
||||
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||
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 = hasPlayerData
|
||||
? {
|
||||
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
|
||||
),
|
||||
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||
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' };
|
||||
const teamBStats =
|
||||
hasPlayerData && teamBPlayers.length > 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
|
||||
),
|
||||
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -267,8 +296,9 @@
|
||||
</Card>
|
||||
|
||||
<!-- Top Performers -->
|
||||
// Top Performers
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
{#if sortedPlayers.length > 0}
|
||||
{#if sortedPlayers.length > 0 && sortedPlayers[0]}
|
||||
<!-- Most Kills -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
@@ -276,7 +306,9 @@
|
||||
<h3 class="font-semibold text-base-content">Most Kills</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
|
||||
<div class="mt-1 font-mono text-3xl 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">
|
||||
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
|
||||
</div>
|
||||
@@ -284,35 +316,39 @@
|
||||
|
||||
<!-- Best K/D -->
|
||||
{@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Target class="h-5 w-5 text-success" />
|
||||
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestKD.name}</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">
|
||||
{bestKD.kills}K / {bestKD.deaths}D
|
||||
</div>
|
||||
</Card>
|
||||
{#if bestKD}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Target class="h-5 w-5 text-success" />
|
||||
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestKD.name}</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">
|
||||
{bestKD.kills}K / {bestKD.deaths}D
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Most Utility Damage -->
|
||||
{@const bestUtility = [...sortedPlayers].sort(
|
||||
(a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0))
|
||||
)[0]}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Flame class="h-5 w-5 text-error" />
|
||||
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-error">
|
||||
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
|
||||
</div>
|
||||
</Card>
|
||||
{#if bestUtility}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Flame class="h-5 w-5 text-error" />
|
||||
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
|
||||
<div class="mt-1 font-mono text-3xl font-bold text-error">
|
||||
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import LineChart from '$lib/components/charts/LineChart.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { ChartData } from 'chart.js';
|
||||
|
||||
interface TeamEconomy {
|
||||
round: number;
|
||||
@@ -30,7 +29,17 @@
|
||||
|
||||
// Only process if rounds data exists
|
||||
let teamEconomy = $state<TeamEconomy[]>([]);
|
||||
let equipmentChartData = $state<ChartData<'line'> | null>(null);
|
||||
let equipmentChartData = $state<{
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
}>;
|
||||
} | null>(null);
|
||||
let totalRounds = $state(0);
|
||||
let teamA_fullBuys = $state(0);
|
||||
let teamB_fullBuys = $state(0);
|
||||
@@ -41,12 +50,12 @@
|
||||
// Process rounds data to calculate team totals
|
||||
for (const roundData of roundsData.rounds) {
|
||||
const teamAPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
|
||||
return matchPlayer?.team_id === firstTeamId;
|
||||
});
|
||||
|
||||
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 === String(p.player_id));
|
||||
return matchPlayer?.team_id === secondTeamId;
|
||||
});
|
||||
|
||||
@@ -116,61 +125,71 @@
|
||||
|
||||
// Table columns
|
||||
const tableColumns = [
|
||||
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
|
||||
{
|
||||
key: 'teamA_buyType',
|
||||
key: 'round' as keyof TeamEconomy,
|
||||
label: 'Round',
|
||||
sortable: true,
|
||||
align: 'center' as const
|
||||
},
|
||||
{
|
||||
key: 'teamA_buyType' as keyof TeamEconomy,
|
||||
label: 'T Buy',
|
||||
sortable: true,
|
||||
render: (value: string) => {
|
||||
render: (value: string | number | boolean, _row: TeamEconomy) => {
|
||||
const strValue = value as string;
|
||||
const variant =
|
||||
value === 'Full Buy'
|
||||
strValue === 'Full Buy'
|
||||
? 'success'
|
||||
: value === 'Eco'
|
||||
: strValue === 'Eco'
|
||||
? 'error'
|
||||
: value === 'Force'
|
||||
: strValue === 'Force'
|
||||
? 'warning'
|
||||
: 'default';
|
||||
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
|
||||
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'teamA_equipment',
|
||||
key: 'teamA_equipment' as keyof TeamEconomy,
|
||||
label: 'T Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
formatter: (value: number) => `$${value.toLocaleString()}`
|
||||
format: (value: string | number | boolean, _row: TeamEconomy) =>
|
||||
`$${(value as number).toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'teamB_buyType',
|
||||
key: 'teamB_buyType' as keyof TeamEconomy,
|
||||
label: 'CT Buy',
|
||||
sortable: true,
|
||||
render: (value: string) => {
|
||||
render: (value: string | number | boolean, _row: TeamEconomy) => {
|
||||
const strValue = value as string;
|
||||
const variant =
|
||||
value === 'Full Buy'
|
||||
strValue === 'Full Buy'
|
||||
? 'success'
|
||||
: value === 'Eco'
|
||||
: strValue === 'Eco'
|
||||
? 'error'
|
||||
: value === 'Force'
|
||||
: strValue === 'Force'
|
||||
? 'warning'
|
||||
: 'default';
|
||||
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
|
||||
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'teamB_equipment',
|
||||
key: 'teamB_equipment' as keyof TeamEconomy,
|
||||
label: 'CT Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
formatter: (value: number) => `$${value.toLocaleString()}`
|
||||
format: (value: string | number | boolean, _row: TeamEconomy) =>
|
||||
`$${(value as number).toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'winner',
|
||||
key: 'winner' as keyof TeamEconomy,
|
||||
label: 'Winner',
|
||||
align: 'center' as const,
|
||||
render: (value: number) => {
|
||||
if (value === 2)
|
||||
render: (value: string | number | boolean, _row: TeamEconomy) => {
|
||||
const numValue = value as number;
|
||||
if (numValue === 2)
|
||||
return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
|
||||
if (value === 3)
|
||||
if (numValue === 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>';
|
||||
}
|
||||
|
||||
@@ -50,39 +50,52 @@
|
||||
const teamBTotals = calcTeamTotals(teamBFlashStats);
|
||||
|
||||
// Table columns with fixed widths for consistency across multiple tables
|
||||
interface FlashStat {
|
||||
name: string;
|
||||
team_id: number;
|
||||
enemies_blinded: number;
|
||||
teammates_blinded: number;
|
||||
self_blinded: number;
|
||||
enemy_blind_duration: number;
|
||||
team_blind_duration: number;
|
||||
self_blind_duration: number;
|
||||
flash_assists: number;
|
||||
avg_blind_duration: string;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Player', sortable: true, width: '200px' },
|
||||
{ key: 'name' as const, label: 'Player', sortable: true, width: '200px' },
|
||||
{
|
||||
key: 'enemies_blinded',
|
||||
key: 'enemies_blinded' as const,
|
||||
label: 'Enemies Blinded',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'avg_blind_duration',
|
||||
key: 'avg_blind_duration' as const,
|
||||
label: 'Avg Duration (s)',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
formatter: (value: string) => `${value}s`,
|
||||
format: (value: string | number | boolean, _row: FlashStat) => `${value as string}s`,
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'flash_assists',
|
||||
key: 'flash_assists' as const,
|
||||
label: 'Flash Assists',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
},
|
||||
{
|
||||
key: 'teammates_blinded',
|
||||
key: 'teammates_blinded' as const,
|
||||
label: 'Team Flashed',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
},
|
||||
{
|
||||
key: 'self_blinded',
|
||||
key: 'self_blinded' as const,
|
||||
label: 'Self Flashed',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Search, Filter, Calendar, Loader2 } from 'lucide-svelte';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
Loader2,
|
||||
Download,
|
||||
FileDown,
|
||||
FileJson,
|
||||
LayoutGrid,
|
||||
Table as TableIcon
|
||||
} from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
@@ -7,8 +17,17 @@
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
||||
import ShareCodeInput from '$lib/components/match/ShareCodeInput.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
import { exportMatchesToCSV, exportMatchesToJSON } from '$lib/utils/export';
|
||||
import {
|
||||
getMatchesState,
|
||||
scrollToMatch,
|
||||
clearMatchesState,
|
||||
storeMatchesState
|
||||
} from '$lib/utils/navigation';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -19,6 +38,29 @@
|
||||
|
||||
let searchQuery = $state(currentSearch);
|
||||
let showFilters = $state(false);
|
||||
let exportDropdownOpen = $state(false);
|
||||
let exportMessage = $state<string | null>(null);
|
||||
|
||||
// View mode state with localStorage persistence
|
||||
let viewMode = $state<'grid' | 'table'>('grid');
|
||||
|
||||
// Initialize view mode from localStorage on client side
|
||||
$effect(() => {
|
||||
if (!import.meta.env.SSR && typeof window !== 'undefined') {
|
||||
const savedViewMode = localStorage.getItem('matches-view-mode');
|
||||
if (savedViewMode === 'grid' || savedViewMode === 'table') {
|
||||
viewMode = savedViewMode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save view mode to localStorage when it changes
|
||||
const setViewMode = (mode: 'grid' | 'table') => {
|
||||
viewMode = mode;
|
||||
if (!import.meta.env.SSR && typeof window !== 'undefined') {
|
||||
localStorage.setItem('matches-view-mode', mode);
|
||||
}
|
||||
};
|
||||
|
||||
// Pagination state
|
||||
let matches = $state<MatchListItem[]>(data.matches);
|
||||
@@ -31,6 +73,13 @@
|
||||
let sortOrder = $state<'desc' | 'asc'>('desc');
|
||||
let resultFilter = $state<'all' | 'win' | 'loss' | 'tie'>('all');
|
||||
|
||||
// Date range filter state
|
||||
let fromDate = $state<string>('');
|
||||
let toDate = $state<string>('');
|
||||
|
||||
// Future filters (disabled until API supports them)
|
||||
let rankTier = $state<string>('all');
|
||||
|
||||
// Reset pagination when data changes (new filters applied)
|
||||
$effect(() => {
|
||||
matches = data.matches;
|
||||
@@ -38,10 +87,103 @@
|
||||
nextPageTime = data.nextPageTime;
|
||||
});
|
||||
|
||||
// Infinite scroll setup
|
||||
let loadMoreTriggerRef = $state<HTMLDivElement | null>(null);
|
||||
let observer = $state<IntersectionObserver | null>(null);
|
||||
let loadMoreTimeout = $state<number | null>(null);
|
||||
|
||||
// Set up intersection observer for infinite scroll
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && loadMoreTriggerRef && hasMore && !isLoadingMore) {
|
||||
// Clean up existing observer
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
// Create new observer
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && hasMore && !isLoadingMore) {
|
||||
// Debounce the load more call to prevent too frequent requests
|
||||
if (loadMoreTimeout) {
|
||||
clearTimeout(loadMoreTimeout);
|
||||
}
|
||||
loadMoreTimeout = window.setTimeout(() => {
|
||||
loadMore();
|
||||
}, 300); // 300ms debounce
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '100px', // Trigger 100px before element is visible
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(loadMoreTriggerRef);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
if (loadMoreTimeout) {
|
||||
clearTimeout(loadMoreTimeout);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => {}; // Return empty cleanup function for server-side rendering
|
||||
});
|
||||
|
||||
// Track window width for responsive slides
|
||||
// Scroll restoration when returning from a match detail page
|
||||
$effect(() => {
|
||||
const navState = getMatchesState();
|
||||
if (navState) {
|
||||
// Check if we need to load more matches to find the target match
|
||||
const targetMatch = matches.find((m) => m.match_id === navState.matchId);
|
||||
|
||||
if (targetMatch) {
|
||||
// Match found, scroll to it
|
||||
scrollToMatch(navState.matchId, navState.scrollY);
|
||||
clearMatchesState();
|
||||
} else if (hasMore && matches.length < navState.loadedCount) {
|
||||
// Match not found but we had more matches loaded before, try loading more
|
||||
loadMore().then(() => {
|
||||
// After loading, check again
|
||||
const found = matches.find((m) => m.match_id === navState.matchId);
|
||||
if (found) {
|
||||
scrollToMatch(navState.matchId, navState.scrollY);
|
||||
} else {
|
||||
// Still not found, just use scroll position
|
||||
window.scrollTo({ top: navState.scrollY, behavior: 'instant' });
|
||||
}
|
||||
clearMatchesState();
|
||||
});
|
||||
} else {
|
||||
// Match not found and can't load more, fallback to scroll position
|
||||
window.scrollTo({ top: navState.scrollY, behavior: 'instant' });
|
||||
clearMatchesState();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Computed filtered and sorted matches
|
||||
const displayMatches = $derived.by(() => {
|
||||
let filtered = [...matches];
|
||||
|
||||
// Apply date range filter
|
||||
if (fromDate || toDate) {
|
||||
filtered = filtered.filter((match) => {
|
||||
const matchDate = new Date(match.date);
|
||||
if (fromDate && matchDate < new Date(fromDate + 'T00:00:00')) return false;
|
||||
if (toDate && matchDate > new Date(toDate + 'T23:59:59')) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply result filter
|
||||
if (resultFilter !== 'all') {
|
||||
filtered = filtered.filter((match) => {
|
||||
@@ -81,26 +223,89 @@
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (currentMap) params.set('map', currentMap);
|
||||
if (currentPlayerId) params.set('player_id', currentPlayerId);
|
||||
if (fromDate) params.set('from_date', fromDate);
|
||||
if (toDate) params.set('to_date', toDate);
|
||||
|
||||
goto(`/matches?${params.toString()}`);
|
||||
};
|
||||
|
||||
// Date preset functions
|
||||
const setDatePreset = (preset: 'today' | 'week' | 'month' | 'all') => {
|
||||
const now = new Date();
|
||||
if (preset === 'all') {
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
} else if (preset === 'today') {
|
||||
const dateStr = now.toISOString().substring(0, 10);
|
||||
fromDate = toDate = dateStr;
|
||||
} else if (preset === 'week') {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
fromDate = weekAgo.toISOString().substring(0, 10);
|
||||
toDate = now.toISOString().substring(0, 10);
|
||||
} else if (preset === 'month') {
|
||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
fromDate = monthAgo.toISOString().substring(0, 10);
|
||||
toDate = now.toISOString().substring(0, 10);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all filters function
|
||||
const clearAllFilters = () => {
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
rankTier = 'all';
|
||||
resultFilter = 'all';
|
||||
sortBy = 'date';
|
||||
sortOrder = 'desc';
|
||||
};
|
||||
|
||||
// Count active client-side filters
|
||||
const activeFilterCount = $derived(() => {
|
||||
let count = 0;
|
||||
if (fromDate) count++;
|
||||
if (toDate) count++;
|
||||
if (resultFilter !== 'all') count++;
|
||||
if (sortBy !== 'date') count++;
|
||||
if (sortOrder !== 'desc') count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore || isLoadingMore || !nextPageTime) return;
|
||||
// Prevent multiple simultaneous requests
|
||||
if (!hasMore || isLoadingMore || matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending auto-load timeout
|
||||
if (loadMoreTimeout) {
|
||||
clearTimeout(loadMoreTimeout);
|
||||
loadMoreTimeout = null;
|
||||
}
|
||||
|
||||
// Get the date of the last match for pagination
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
if (!lastMatch) {
|
||||
isLoadingMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMatchDate = lastMatch.date;
|
||||
const lastMatchTimestamp = Math.floor(new Date(lastMatchDate).getTime() / 1000);
|
||||
|
||||
isLoadingMore = true;
|
||||
try {
|
||||
const matchesData = await api.matches.getMatches({
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
map: data.filters.map,
|
||||
player_id: data.filters.playerId,
|
||||
before_time: nextPageTime
|
||||
player_id: data.filters.playerId ? String(data.filters.playerId) : undefined,
|
||||
before_time: lastMatchTimestamp
|
||||
});
|
||||
|
||||
// Append new matches to existing list
|
||||
matches = [...matches, ...matchesData.matches];
|
||||
hasMore = matchesData.has_more;
|
||||
nextPageTime = matchesData.next_page_time;
|
||||
console.log('Updated state:', { matchesLength: matches.length, hasMore, nextPageTime });
|
||||
} catch (error) {
|
||||
console.error('Failed to load more matches:', error);
|
||||
// Show error toast or message here
|
||||
@@ -118,18 +323,183 @@
|
||||
'de_ancient',
|
||||
'de_anubis'
|
||||
];
|
||||
|
||||
// Export handlers
|
||||
const handleExportCSV = () => {
|
||||
try {
|
||||
exportMatchesToCSV(displayMatches);
|
||||
exportMessage = `Successfully exported ${displayMatches.length} matches to CSV`;
|
||||
exportDropdownOpen = false;
|
||||
setTimeout(() => {
|
||||
exportMessage = null;
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
exportMessage = error instanceof Error ? error.message : 'Failed to export matches';
|
||||
setTimeout(() => {
|
||||
exportMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJSON = () => {
|
||||
try {
|
||||
exportMatchesToJSON(displayMatches);
|
||||
exportMessage = `Successfully exported ${displayMatches.length} matches to JSON`;
|
||||
exportDropdownOpen = false;
|
||||
setTimeout(() => {
|
||||
exportMessage = null;
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
exportMessage = error instanceof Error ? error.message : 'Failed to export matches';
|
||||
setTimeout(() => {
|
||||
exportMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Table column definitions
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const capitalizeMap = (map: string): string => {
|
||||
return map
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getResultBadge = (match: MatchListItem): string => {
|
||||
const teamAWon = match.score_team_a > match.score_team_b;
|
||||
const teamBWon = match.score_team_b > match.score_team_a;
|
||||
|
||||
if (teamAWon) {
|
||||
return '<span class="badge badge-success">Win</span>';
|
||||
} else if (teamBWon) {
|
||||
return '<span class="badge badge-error">Loss</span>';
|
||||
} else {
|
||||
return '<span class="badge badge-warning">Tie</span>';
|
||||
}
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
key: 'date' as const,
|
||||
label: 'Date',
|
||||
sortable: true,
|
||||
width: '150px',
|
||||
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
|
||||
formatDate(value as string)
|
||||
},
|
||||
{
|
||||
key: 'map' as const,
|
||||
label: 'Map',
|
||||
sortable: true,
|
||||
width: '150px',
|
||||
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
|
||||
capitalizeMap(value as string)
|
||||
},
|
||||
{
|
||||
key: 'score_team_a' as const,
|
||||
label: 'Score',
|
||||
sortable: true,
|
||||
width: '120px',
|
||||
align: 'center' as const,
|
||||
render: (_value: string | number | boolean | undefined, row: MatchListItem) => {
|
||||
const teamAColor = 'text-[#F97316]'; // Terrorist orange
|
||||
const teamBColor = 'text-[#06B6D4]'; // CT cyan
|
||||
return `<span class="${teamAColor} font-bold">${row.score_team_a}</span> - <span class="${teamBColor} font-bold">${row.score_team_b}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'duration' as const,
|
||||
label: 'Duration',
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
align: 'center' as const,
|
||||
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
|
||||
formatDuration(value as number)
|
||||
},
|
||||
{
|
||||
key: 'player_count' as const,
|
||||
label: 'Players',
|
||||
sortable: false,
|
||||
width: '90px',
|
||||
align: 'center' as const,
|
||||
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
|
||||
value ? `${value as number}` : '-'
|
||||
},
|
||||
{
|
||||
key: 'demo_parsed' as const,
|
||||
label: 'Result',
|
||||
sortable: false,
|
||||
width: '100px',
|
||||
align: 'center' as const,
|
||||
render: (_value: string | number | boolean | undefined, row: MatchListItem) =>
|
||||
getResultBadge(row)
|
||||
},
|
||||
{
|
||||
key: 'match_id' as keyof MatchListItem,
|
||||
label: 'Actions',
|
||||
sortable: false,
|
||||
width: '120px',
|
||||
align: 'center' as const,
|
||||
render: (value: string | number | boolean | undefined, row: MatchListItem) =>
|
||||
`<a href="/match/${value}" class="btn btn-primary btn-sm" data-match-id="${row.match_id}" data-table-link="true">View</a>`
|
||||
}
|
||||
];
|
||||
|
||||
// Handle table link clicks to store navigation state
|
||||
function handleTableLinkClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest('a[data-table-link]');
|
||||
if (link) {
|
||||
const matchId = link.getAttribute('data-match-id');
|
||||
if (matchId) {
|
||||
storeMatchesState(matchId, matches.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Matches - CS2.WTF</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Export Toast Notification -->
|
||||
{#if exportMessage}
|
||||
<div class="toast toast-center toast-top z-50">
|
||||
<div class="alert alert-success shadow-lg">
|
||||
<div>
|
||||
<Download class="h-5 w-5" />
|
||||
<span>{exportMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-4xl font-bold">Matches</h1>
|
||||
<p class="text-base-content/60">Browse and search through CS2 competitive matches</p>
|
||||
</div>
|
||||
|
||||
<!-- Share Code Input -->
|
||||
<Card padding="lg" class="mb-8">
|
||||
<ShareCodeInput />
|
||||
</Card>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<Card padding="lg" class="mb-8">
|
||||
<form
|
||||
@@ -158,12 +528,103 @@
|
||||
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
|
||||
<Filter class="mr-2 h-5 w-5" />
|
||||
Filters
|
||||
{#if activeFilterCount() > 0}
|
||||
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount()}</Badge>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<!-- Export Dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={displayMatches.length === 0}
|
||||
onclick={() => (exportDropdownOpen = !exportDropdownOpen)}
|
||||
>
|
||||
<Download class="mr-2 h-5 w-5" />
|
||||
Export
|
||||
</Button>
|
||||
{#if exportDropdownOpen}
|
||||
<ul class="menu dropdown-content z-[1] mt-2 w-52 rounded-box bg-base-100 p-2 shadow-lg">
|
||||
<li>
|
||||
<button type="button" onclick={handleExportCSV}>
|
||||
<FileDown class="h-4 w-4" />
|
||||
Export as CSV
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" onclick={handleExportJSON}>
|
||||
<FileJson class="h-4 w-4" />
|
||||
Export as JSON
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Panel (Collapsible) -->
|
||||
{#if showFilters}
|
||||
<div class="space-y-4 border-t border-base-300 pt-4">
|
||||
<!-- Date Range Filter -->
|
||||
<div>
|
||||
<h3 class="mb-3 font-semibold text-base-content">Filter by Date Range</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Preset Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm"
|
||||
onclick={() => setDatePreset('today')}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm"
|
||||
onclick={() => setDatePreset('week')}
|
||||
>
|
||||
This Week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm"
|
||||
onclick={() => setDatePreset('month')}
|
||||
>
|
||||
This Month
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm"
|
||||
onclick={() => setDatePreset('all')}
|
||||
>
|
||||
All Time
|
||||
</button>
|
||||
</div>
|
||||
<!-- Date Inputs -->
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<label for="from-date" class="text-sm font-medium">From:</label>
|
||||
<input
|
||||
id="from-date"
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
class="input input-sm input-bordered flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<label for="to-date" class="text-sm font-medium">To:</label>
|
||||
<input
|
||||
id="to-date"
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
class="input input-sm input-bordered flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Filter -->
|
||||
<div>
|
||||
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
|
||||
@@ -180,6 +641,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rank Tier Filter (Coming Soon) -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<h3 class="font-semibold text-base-content">Filter by Rank Tier</h3>
|
||||
<div
|
||||
class="tooltip"
|
||||
data-tip="This filter will be available when the API supports rank data"
|
||||
>
|
||||
<Badge variant="warning" size="sm">Coming Soon</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
bind:value={rankTier}
|
||||
class="select select-bordered select-sm w-full max-w-xs"
|
||||
disabled
|
||||
>
|
||||
<option value="all">All Ranks</option>
|
||||
<option value="0-5000"><5,000 (Gray)</option>
|
||||
<option value="5000-10000">5,000-10,000 (Blue)</option>
|
||||
<option value="10000-15000">10,000-15,000 (Purple)</option>
|
||||
<option value="15000-20000">15,000-20,000 (Pink)</option>
|
||||
<option value="20000-25000">20,000-25,000 (Red)</option>
|
||||
<option value="25000-30000">25,000+ (Gold)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Game Mode Filter (Coming Soon) -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<h3 class="font-semibold text-base-content">Filter by Game Mode</h3>
|
||||
<div
|
||||
class="tooltip"
|
||||
data-tip="This filter will be available when the API supports game mode data"
|
||||
>
|
||||
<Badge variant="warning" size="sm">Coming Soon</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm" disabled>All Modes</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" disabled>Premier</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" disabled>Competitive</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" disabled>Wingman</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Filter -->
|
||||
<div>
|
||||
<h3 class="mb-3 font-semibold text-base-content">Filter by Result</h3>
|
||||
@@ -241,12 +747,19 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear All Filters Button -->
|
||||
<div class="border-t border-base-300 pt-3">
|
||||
<button type="button" class="btn btn-ghost btn-sm w-full" onclick={clearAllFilters}>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Active Filters -->
|
||||
{#if currentMap || currentPlayerId || currentSearch}
|
||||
{#if currentMap || currentPlayerId || currentSearch || fromDate || toDate}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-base-300 pt-4">
|
||||
<span class="text-sm font-medium text-base-content/70">Active Filters:</span>
|
||||
{#if currentSearch}
|
||||
@@ -258,31 +771,104 @@
|
||||
{#if currentPlayerId}
|
||||
<Badge variant="info">Player ID: {currentPlayerId}</Badge>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" href="/matches">Clear All</Button>
|
||||
{#if fromDate}
|
||||
<Badge variant="info">From: {fromDate}</Badge>
|
||||
{/if}
|
||||
{#if toDate}
|
||||
<Badge variant="info">To: {toDate}</Badge>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
clearAllFilters();
|
||||
goto('/matches');
|
||||
}}>Clear All</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Results Summary -->
|
||||
{#if matches.length > 0 && resultFilter !== 'all'}
|
||||
<div class="mb-4">
|
||||
<!-- View Mode Toggle & Results Summary -->
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="join">
|
||||
<button
|
||||
type="button"
|
||||
class="btn join-item"
|
||||
class:btn-active={viewMode === 'grid'}
|
||||
onclick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid class="h-5 w-5" />
|
||||
<span class="ml-2 hidden sm:inline">Grid</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn join-item"
|
||||
class:btn-active={viewMode === 'table'}
|
||||
onclick={() => setViewMode('table')}
|
||||
aria-label="Table view"
|
||||
>
|
||||
<TableIcon class="h-5 w-5" />
|
||||
<span class="ml-2 hidden sm:inline">Table</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
{#if matches.length > 0 && resultFilter !== 'all'}
|
||||
<Badge variant="info">
|
||||
Showing {displayMatches.length} of {matches.length} matches
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Matches Grid -->
|
||||
<!-- Matches Display (Grid or Table) -->
|
||||
{#if displayMatches.length > 0}
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each displayMatches as match}
|
||||
<MatchCard {match} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if viewMode === 'grid'}
|
||||
<!-- Grid View -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each displayMatches as match}
|
||||
<MatchCard {match} loadedCount={matches.length} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Table View -->
|
||||
<div
|
||||
class="rounded-lg border border-base-300 bg-base-100"
|
||||
onclick={handleTableLinkClick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
// Create a mock MouseEvent to match the expected type
|
||||
const mockEvent = {
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
} as unknown as MouseEvent;
|
||||
handleTableLinkClick(mockEvent);
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
data={displayMatches}
|
||||
columns={tableColumns}
|
||||
striped={true}
|
||||
hoverable={true}
|
||||
fixedLayout={true}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Load More Button -->
|
||||
<!-- Load More Trigger (for infinite scroll) -->
|
||||
{#if hasMore}
|
||||
<div class="mt-8 text-center">
|
||||
<!-- Hidden trigger element for intersection observer -->
|
||||
<div bind:this={loadMoreTriggerRef} class="h-1 w-full"></div>
|
||||
|
||||
<!-- Visible load more button for manual loading -->
|
||||
<Button variant="primary" size="lg" onclick={loadMore} disabled={isLoadingMore}>
|
||||
{#if isLoadingMore}
|
||||
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
|
||||
@@ -291,8 +877,11 @@
|
||||
Load More Matches
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isLoadingMore}
|
||||
<p class="mt-2 text-sm text-base-content/60">Loading more matches...</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
Showing {matches.length} matches
|
||||
Showing {matches.length} matches {hasMore ? '(more available)' : '(all loaded)'}
|
||||
</p>
|
||||
</div>
|
||||
{:else if matches.length > 0}
|
||||
@@ -312,10 +901,24 @@
|
||||
<p class="text-base-content/60">
|
||||
No matches match your current filters. Try adjusting your filter settings.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
|
||||
Clear Result Filter
|
||||
</Button>
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{#if resultFilter !== 'all'}
|
||||
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
|
||||
Clear Result Filter
|
||||
</Button>
|
||||
{/if}
|
||||
{#if fromDate || toDate}
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
}}
|
||||
>
|
||||
Clear Date Filter
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" onclick={clearAllFilters}>Clear All Filters</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -7,9 +7,8 @@ import { api } from '$lib/api';
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
// Get query parameters
|
||||
const map = url.searchParams.get('map') || undefined;
|
||||
const playerIdStr = url.searchParams.get('player_id');
|
||||
const playerId = playerIdStr ? Number(playerIdStr) : undefined;
|
||||
const limit = Number(url.searchParams.get('limit')) || 50;
|
||||
const playerId = url.searchParams.get('player_id') || undefined;
|
||||
const limit = Number(url.searchParams.get('limit')) || 20; // Request 20 matches for initial load
|
||||
|
||||
try {
|
||||
// Load matches with filters
|
||||
@@ -33,7 +32,10 @@ export const load: PageLoad = async ({ url }) => {
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load matches:', error instanceof Error ? error.message : String(error));
|
||||
console.error(
|
||||
'Failed to load matches:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
|
||||
// Return empty state on error
|
||||
return {
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { User, Target, TrendingUp, Calendar, Trophy, Heart, Crosshair } from 'lucide-svelte';
|
||||
import {
|
||||
User,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Trophy,
|
||||
Heart,
|
||||
Crosshair,
|
||||
UserCheck
|
||||
} from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
||||
import LineChart from '$lib/components/charts/LineChart.svelte';
|
||||
import BarChart from '$lib/components/charts/BarChart.svelte';
|
||||
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
|
||||
import TrackPlayerModal from '$lib/components/player/TrackPlayerModal.svelte';
|
||||
import { preferences } from '$lib/stores';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { profile, recentMatches, playerStats } = data;
|
||||
|
||||
// Track player modal state
|
||||
let isTrackModalOpen = $state(false);
|
||||
|
||||
// Handle tracking events
|
||||
async function handleTracked() {
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function handleUntracked() {
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
// Calculate stats from PlayerMeta and aggregated match data
|
||||
const kd =
|
||||
profile.avg_deaths > 0
|
||||
@@ -18,6 +42,12 @@
|
||||
: profile.avg_kills.toFixed(2);
|
||||
const winRate = (profile.win_rate * 100).toFixed(1);
|
||||
|
||||
// Get current Premier rating from most recent match
|
||||
const currentRating =
|
||||
playerStats.length > 0 && playerStats[0] ? playerStats[0].rank_new : undefined;
|
||||
const previousRating =
|
||||
playerStats.length > 0 && playerStats[0] ? playerStats[0].rank_old : undefined;
|
||||
|
||||
// Calculate headshot percentage from playerStats if available
|
||||
const totalKills = playerStats.reduce((sum, stat) => sum + stat.kills, 0);
|
||||
const totalHeadshots = playerStats.reduce((sum, stat) => sum + (stat.headshot || 0), 0);
|
||||
@@ -37,7 +67,7 @@
|
||||
|
||||
// Performance trend chart data (K/D ratio over time)
|
||||
const performanceTrendData = {
|
||||
labels: playerStats.map((stat, i) => `Match ${playerStats.length - i}`).reverse(),
|
||||
labels: playerStats.map((_stat, i) => `Match ${playerStats.length - i}`).reverse(),
|
||||
datasets: [
|
||||
{
|
||||
label: 'K/D Ratio',
|
||||
@@ -51,7 +81,7 @@
|
||||
},
|
||||
{
|
||||
label: 'KAST %',
|
||||
data: playerStats.map((stat) => stat.kast).reverse(),
|
||||
data: playerStats.map((stat) => stat.kast || 0).reverse(),
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.4,
|
||||
@@ -176,6 +206,62 @@
|
||||
<Heart class="h-5 w-5 {isFavorite ? 'fill-error text-error' : ''}" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||
<PremierRatingBadge
|
||||
rating={currentRating}
|
||||
oldRating={previousRating}
|
||||
size="lg"
|
||||
showTier={true}
|
||||
showChange={true}
|
||||
/>
|
||||
<!-- VAC/Game Ban Status Badges -->
|
||||
{#if profile.vac_count && profile.vac_count > 0}
|
||||
<div class="badge badge-error badge-lg gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block h-4 w-4 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
VAC Ban{profile.vac_count > 1 ? `s (${profile.vac_count})` : ''}
|
||||
{#if profile.vac_date}
|
||||
<span class="text-xs opacity-80">
|
||||
{new Date(profile.vac_date).toLocaleDateString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if profile.game_ban_count && profile.game_ban_count > 0}
|
||||
<div class="badge badge-warning badge-lg gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block h-4 w-4 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
Game Ban{profile.game_ban_count > 1 ? `s (${profile.game_ban_count})` : ''}
|
||||
{#if profile.game_ban_date}
|
||||
<span class="text-xs opacity-80">
|
||||
{new Date(profile.game_ban_date).toLocaleDateString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-sm text-base-content/60">
|
||||
<span>Steam ID: {profile.id}</span>
|
||||
<span>Last match: {new Date(profile.last_match_date).toLocaleDateString()}</span>
|
||||
@@ -184,6 +270,14 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={profile.tracked ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
onclick={() => (isTrackModalOpen = true)}
|
||||
>
|
||||
<UserCheck class="h-4 w-4" />
|
||||
{profile.tracked ? 'Tracked' : 'Track Player'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" href={`/matches?player_id=${profile.id}`}>
|
||||
View All Matches
|
||||
</Button>
|
||||
@@ -191,6 +285,16 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Track Player Modal -->
|
||||
<TrackPlayerModal
|
||||
playerId={profile.id}
|
||||
playerName={profile.name}
|
||||
isTracked={profile.tracked || false}
|
||||
bind:isOpen={isTrackModalOpen}
|
||||
ontracked={handleTracked}
|
||||
onuntracked={handleUntracked}
|
||||
/>
|
||||
|
||||
<!-- Career Statistics -->
|
||||
<div>
|
||||
<h2 class="mb-4 text-2xl font-bold text-base-content">Career Statistics</h2>
|
||||
|
||||
Reference in New Issue
Block a user