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:
2025-11-12 19:31:18 +01:00
parent a861b1c1b6
commit 8f3b652740
422 changed files with 106174 additions and 102193 deletions

View File

@@ -57,12 +57,12 @@ pipeline:
settings: settings:
hostname: hostname:
from_secret: ftp_host from_secret: ftp_host
src_dir: "/build/" src_dir: '/build/'
clean_dir: true clean_dir: true
secrets: [ ftp_username, ftp_password ] secrets: [ftp_username, ftp_password]
when: when:
branch: master branch: master
event: [ push, tag ] event: [push, tag]
status: success status: success
deploy-dev: deploy-dev:
@@ -70,7 +70,7 @@ pipeline:
settings: settings:
hostname: hostname:
from_secret: ftp_host from_secret: ftp_host
src_dir: "/build/" src_dir: '/build/'
clean_dir: true clean_dir: true
secrets: secrets:
- source: ftp_username_dev - source: ftp_username_dev
@@ -79,7 +79,7 @@ pipeline:
target: ftp_password target: ftp_password
when: when:
branch: dev branch: dev
event: [ push, tag ] event: [push, tag]
status: success status: success
deploy-cs2: deploy-cs2:
@@ -87,7 +87,7 @@ pipeline:
settings: settings:
hostname: hostname:
from_secret: ftp_host_cs2 from_secret: ftp_host_cs2
src_dir: "/build/" src_dir: '/build/'
clean_dir: true clean_dir: true
secrets: secrets:
- source: ftp_username_cs2 - source: ftp_username_cs2
@@ -96,5 +96,5 @@ pipeline:
target: ftp_password target: ftp_password
when: when:
branch: cs2-port branch: cs2-port
event: [ push ] event: [push]
status: success status: success

View File

@@ -10,6 +10,21 @@
## Table of Contents ## Table of Contents
- [Overview](#overview)
- [API Endpoints](#api-endpoints)
- [Player Endpoints](#player-endpoints)
- [Match Endpoints](#match-endpoints)
- [Matches Listing](#matches-listing)
- [Data Models](#data-models)
- [Integration Guide](#integration-guide)
- [Error Handling](#error-handling)
- [CS2 Migration Notes](#cs2-migration-notes)
- [Rate Limiting](#rate-limiting)
- [Caching Strategy](#caching-strategy)
- [Testing](#testing)
- [Swagger Documentation](#swagger-documentation)
- [Support & Updates](#support--updates)
1. [Overview](#overview) 1. [Overview](#overview)
2. [API Endpoints](#api-endpoints) 2. [API Endpoints](#api-endpoints)
- [Player Endpoints](#player-endpoints) - [Player Endpoints](#player-endpoints)
@@ -35,17 +50,18 @@ The CSGOWTFD backend is a REST API service that provides Counter-Strike match st
### Configuration ### Configuration
Default backend configuration: Default backend configuration:
```yaml ```yaml
httpd: httpd:
listen: ":8000" listen: ':8000'
cors_allow_domains: ["*"] cors_allow_domains: ['*']
database: database:
driver: "pgx" driver: 'pgx'
connection: "postgres://username:password@localhost:5432/database_name" connection: 'postgres://username:password@localhost:5432/database_name'
redis: redis:
addr: "localhost:6379" addr: 'localhost:6379'
``` ```
### CORS ### CORS
@@ -56,6 +72,10 @@ The backend supports CORS with configurable allowed domains. By default, all ori
## API Endpoints ## API Endpoints
For detailed documentation on the matches API specifically, see [MATCHES_API.md](MATCHES_API.md).
## API Endpoints
### Player Endpoints ### Player Endpoints
#### 1. Get Player Profile #### 1. Get Player Profile
@@ -66,10 +86,12 @@ Retrieves comprehensive player statistics and match history.
**Alternative**: `GET /player/:id/next/:time` **Alternative**: `GET /player/:id/next/:time`
**Parameters**: **Parameters**:
- `id` (path, required): Steam ID (uint64) - `id` (path, required): Steam ID (uint64)
- `time` (path, optional): Unix timestamp for pagination (get matches before this time) - `time` (path, optional): Unix timestamp for pagination (get matches before this time)
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"id": 76561198012345678, "id": 76561198012345678,
@@ -77,7 +99,7 @@ Retrieves comprehensive player statistics and match history.
"avatar": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/...", "avatar": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/...",
"vanity_url": "custom-url", "vanity_url": "custom-url",
"vanity_url_real": "custom-url", "vanity_url_real": "custom-url",
"steam_updated": "2024-11-04T10:30:00Z", "steam_updated": "2024-11-04T10:30:00Z", // Optional: may not always be provided
"profile_created": "2015-03-12T00:00:00Z", "profile_created": "2015-03-12T00:00:00Z",
"wins": 1250, "wins": 1250,
"looses": 980, "looses": 980,
@@ -100,7 +122,7 @@ Retrieves comprehensive player statistics and match history.
"demo_parsed": true, "demo_parsed": true,
"vac_present": false, "vac_present": false,
"gameban_present": false, "gameban_present": false,
"tick_rate": 64.0, "tick_rate": 64.0, // Optional: not always provided by API
"stats": { "stats": {
"team_id": 2, "team_id": 2,
"kills": 24, "kills": 24,
@@ -109,7 +131,7 @@ Retrieves comprehensive player statistics and match history.
"headshot": 12, "headshot": 12,
"mvp": 3, "mvp": 3,
"score": 56, "score": 56,
"kast": 78, "kast": 78, // Optional: not always provided by API
"rank_old": 18500, "rank_old": 18500,
"rank_new": 18650, "rank_new": 18650,
"dmg_enemy": 2450, "dmg_enemy": 2450,
@@ -141,10 +163,12 @@ Retrieves lightweight player metadata (recent matches summary).
**Alternative**: `GET /player/:id/meta/:limit` **Alternative**: `GET /player/:id/meta/:limit`
**Parameters**: **Parameters**:
- `id` (path, required): Steam ID - `id` (path, required): Steam ID
- `limit` (path, optional): Number of recent matches to include (default: 10) - `limit` (path, optional): Number of recent matches to include (default: 10)
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"id": 76561198012345678, "id": 76561198012345678,
@@ -170,9 +194,11 @@ Adds a player to the tracking system for automatic match updates.
**Endpoint**: `POST /player/:id/track` **Endpoint**: `POST /player/:id/track`
**Parameters**: **Parameters**:
- `id` (path, required): Steam ID - `id` (path, required): Steam ID
**Request Body**: **Request Body**:
```json ```json
{ {
"auth_code": "XXXX-XXXXX-XXXX" "auth_code": "XXXX-XXXXX-XXXX"
@@ -180,6 +206,7 @@ Adds a player to the tracking system for automatic match updates.
``` ```
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"success": true, "success": true,
@@ -198,9 +225,11 @@ Removes a player from the tracking system.
**Endpoint**: `DELETE /player/:id/track` **Endpoint**: `DELETE /player/:id/track`
**Parameters**: **Parameters**:
- `id` (path, required): Steam ID - `id` (path, required): Steam ID
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"success": true, "success": true,
@@ -219,9 +248,11 @@ Triggers parsing of a CS:GO/CS2 match from a share code.
**Endpoint**: `GET /match/parse/:sharecode` **Endpoint**: `GET /match/parse/:sharecode`
**Parameters**: **Parameters**:
- `sharecode` (path, required): CS:GO match share code (e.g., `CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`) - `sharecode` (path, required): CS:GO match share code (e.g., `CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`)
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"match_id": 3589487716842078322, "match_id": 3589487716842078322,
@@ -231,6 +262,7 @@ Triggers parsing of a CS:GO/CS2 match from a share code.
``` ```
**Response** (202 Accepted): **Response** (202 Accepted):
```json ```json
{ {
"match_id": 3589487716842078322, "match_id": 3589487716842078322,
@@ -250,9 +282,11 @@ Retrieves full match details including all player statistics.
**Endpoint**: `GET /match/:id` **Endpoint**: `GET /match/:id`
**Parameters**: **Parameters**:
- `id` (path, required): Match ID (uint64) - `id` (path, required): Match ID (uint64)
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"match_id": 3589487716842078322, "match_id": 3589487716842078322,
@@ -320,9 +354,11 @@ Retrieves weapon statistics for all players in a match.
**Endpoint**: `GET /match/:id/weapons` **Endpoint**: `GET /match/:id/weapons`
**Parameters**: **Parameters**:
- `id` (path, required): Match ID - `id` (path, required): Match ID
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"match_id": 3589487716842078322, "match_id": 3589487716842078322,
@@ -363,9 +399,11 @@ Retrieves round-by-round statistics for a match.
**Endpoint**: `GET /match/:id/rounds` **Endpoint**: `GET /match/:id/rounds`
**Parameters**: **Parameters**:
- `id` (path, required): Match ID - `id` (path, required): Match ID
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"match_id": 3589487716842078322, "match_id": 3589487716842078322,
@@ -400,9 +438,11 @@ Retrieves in-game chat messages from a match.
**Endpoint**: `GET /match/:id/chat` **Endpoint**: `GET /match/:id/chat`
**Parameters**: **Parameters**:
- `id` (path, required): Match ID - `id` (path, required): Match ID
**Response** (200 OK): **Response** (200 OK):
```json ```json
{ {
"match_id": 3589487716842078322, "match_id": 3589487716842078322,
@@ -434,32 +474,53 @@ Retrieves a paginated list of matches.
**Alternative**: `GET /matches/next/:time` **Alternative**: `GET /matches/next/:time`
**Parameters**: **Parameters**:
- `time` (path, optional): Unix timestamp for pagination
- `time` (path, optional): Unix timestamp for pagination (use with `/matches/next/:time`)
- Query parameters: - Query parameters:
- `limit` (optional): Number of matches to return (default: 50, max: 100) - `limit` (optional): Number of matches to return (default: 50, max: 100)
- `map` (optional): Filter by map name (e.g., `de_inferno`) - `map` (optional): Filter by map name (e.g., `de_inferno`)
- `player_id` (optional): Filter by player Steam ID - `player_id` (optional): Filter by player Steam ID
**Response** (200 OK): **Response** (200 OK):
**IMPORTANT**: This endpoint returns a **plain array**, not an object with properties.
```json ```json
{ [
"matches": [
{ {
"match_id": 3589487716842078322, "match_id": "3589487716842078322",
"map": "de_inferno", "map": "de_inferno",
"date": "2024-11-01T18:45:00Z", "date": 1730487900,
"score_team_a": 13, "score": [13, 10],
"score_team_b": 10,
"duration": 2456, "duration": 2456,
"demo_parsed": true, "match_result": 1,
"player_count": 10 "max_rounds": 24,
"parsed": true,
"vac": false,
"game_ban": false
} }
], ]
"next_page_time": 1698871200,
"has_more": true
}
``` ```
**Field Descriptions**:
- `match_id`: Unique match identifier (uint64 as string)
- `map`: Map name (can be empty string if not parsed)
- `date`: Unix timestamp (seconds since epoch)
- `score`: Array with two elements `[team_a_score, team_b_score]`
- `duration`: Match duration in seconds
- `match_result`: 0 = tie, 1 = team_a win, 2 = team_b win
- `max_rounds`: Maximum rounds (24 for MR12, 30 for MR15)
- `parsed`: Whether the demo has been parsed
- `vac`: Whether any player has a VAC ban
- `game_ban`: Whether any player has a game ban
**Pagination**:
- To get the next page, use the `date` field from the last match in the array
- Request `/matches/next/{timestamp}` where `{timestamp}` is the Unix timestamp
- Continue until the response returns fewer matches than requested
**Use Case**: Display matches listing page with filters. **Use Case**: Display matches listing page with filters.
--- ---
@@ -473,6 +534,7 @@ Returns XML sitemap index for SEO.
**Endpoint**: `GET /sitemap.xml` **Endpoint**: `GET /sitemap.xml`
**Response** (200 OK): **Response** (200 OK):
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@@ -492,9 +554,11 @@ Returns XML sitemap for specific page range.
**Endpoint**: `GET /sitemap/:id` **Endpoint**: `GET /sitemap/:id`
**Parameters**: **Parameters**:
- `id` (path, required): Sitemap page number - `id` (path, required): Sitemap page number
**Response** (200 OK): **Response** (200 OK):
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@@ -673,8 +737,8 @@ class APIClient {
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: API_TIMEOUT, timeout: API_TIMEOUT,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
}, }
}); });
// Response interceptor for error handling // Response interceptor for error handling
@@ -720,9 +784,7 @@ import type { Player, Match } from '$lib/types';
export const playersAPI = { export const playersAPI = {
async getPlayer(steamId: string | number, beforeTime?: number): Promise<Player> { async getPlayer(steamId: string | number, beforeTime?: number): Promise<Player> {
const url = beforeTime const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`;
? `/player/${steamId}/next/${beforeTime}`
: `/player/${steamId}`;
return apiClient.get<Player>(url); return apiClient.get<Player>(url);
}, },
@@ -736,7 +798,7 @@ export const playersAPI = {
async untrackPlayer(steamId: string | number) { async untrackPlayer(steamId: string | number) {
return apiClient.delete(`/player/${steamId}/track`); return apiClient.delete(`/player/${steamId}/track`);
}, }
}; };
``` ```
@@ -769,16 +831,34 @@ export const matchesAPI = {
async getMatches(params?: { async getMatches(params?: {
limit?: number; limit?: number;
time?: number; before_time?: number;
map?: string; map?: string;
player_id?: number; player_id?: string;
}) { }) {
const queryString = params ? `?${new URLSearchParams(params as any).toString()}` : ''; const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const url = params?.time const limit = params?.limit || 50;
? `/matches/next/${params.time}${queryString}`
: `/matches${queryString}`; // API returns a plain array, not an object
return apiClient.get(url); const data = await apiClient.get<LegacyMatchListItem[]>(url, {
}, params: {
limit: limit + 1, // Request one extra to check if there are more
map: params?.map,
player_id: params?.player_id
}
});
// Check if there are more matches
const hasMore = data.length > limit;
const matches = hasMore ? data.slice(0, limit) : data;
// Get timestamp for next page
const lastMatch = matches[matches.length - 1];
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Transform to new format
return transformMatchesListResponse(matches, hasMore, nextPageTime);
}
}; };
``` ```
@@ -806,7 +886,7 @@ export const load: PageLoad = async ({ params }) => {
### HTTP Status Codes ### HTTP Status Codes
| Status | Meaning | Common Causes | | Status | Meaning | Common Causes |
|--------|---------|---------------| | ------ | --------------------- | --------------------------------------- |
| 200 | Success | Request completed successfully | | 200 | Success | Request completed successfully |
| 202 | Accepted | Request queued (async operations) | | 202 | Accepted | Request queued (async operations) |
| 400 | Bad Request | Invalid parameters or malformed request | | 400 | Bad Request | Invalid parameters or malformed request |
@@ -844,7 +924,7 @@ async function retryRequest<T>(
if (i === maxRetries - 1) throw error; if (i === maxRetries - 1) throw error;
const delay = baseDelay * Math.pow(2, i); const delay = baseDelay * Math.pow(2, i);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
throw new Error('Max retries exceeded'); throw new Error('Max retries exceeded');
@@ -858,13 +938,16 @@ async function retryRequest<T>(
### Rank System Changes ### Rank System Changes
**CS:GO** used 18 ranks (Silver I to Global Elite): **CS:GO** used 18 ranks (Silver I to Global Elite):
- Values: 0-18 - Values: 0-18
**CS2** uses Premier Rating: **CS2** uses Premier Rating:
- Values: 0-30,000 - Values: 0-30,000
- No traditional ranks in Premier mode - No traditional ranks in Premier mode
**Backend Compatibility**: **Backend Compatibility**:
- `rank_old` and `rank_new` fields now store Premier rating (0-30000) - `rank_old` and `rank_new` fields now store Premier rating (0-30000)
- Frontend must detect game version and display accordingly - Frontend must detect game version and display accordingly
@@ -874,12 +957,14 @@ async function retryRequest<T>(
**CS2**: MR12 (max 24 rounds) **CS2**: MR12 (max 24 rounds)
Check `max_rounds` field in Match data: Check `max_rounds` field in Match data:
- `24` = MR12 (CS2) - `24` = MR12 (CS2)
- `30` = MR15 (CS:GO) - `30` = MR15 (CS:GO)
### Game Mode Detection ### Game Mode Detection
To determine if a match is CS:GO or CS2: To determine if a match is CS:GO or CS2:
1. Check `date` field (CS2 released Sept 2023) 1. Check `date` field (CS2 released Sept 2023)
2. Check `max_rounds` (24 = likely CS2) 2. Check `max_rounds` (24 = likely CS2)
3. Backend may add `game_version` field in future updates 3. Backend may add `game_version` field in future updates
@@ -889,11 +974,13 @@ To determine if a match is CS:GO or CS2:
## Rate Limiting ## Rate Limiting
**Current Limits** (may vary by deployment): **Current Limits** (may vary by deployment):
- **Steam API**: 1 request per second (backend handles this) - **Steam API**: 1 request per second (backend handles this)
- **Demo Parsing**: Max 6 concurrent parses - **Demo Parsing**: Max 6 concurrent parses
- **Frontend API**: No explicit limit, but use reasonable request rates - **Frontend API**: No explicit limit, but use reasonable request rates
**Best Practices**: **Best Practices**:
- Implement client-side caching (5-15 minutes for match data) - Implement client-side caching (5-15 minutes for match data)
- Use debouncing for search inputs (300ms) - Use debouncing for search inputs (300ms)
- Batch requests when possible - Batch requests when possible
@@ -905,6 +992,7 @@ To determine if a match is CS:GO or CS2:
### Backend Caching (Redis) ### Backend Caching (Redis)
The backend uses Redis for: The backend uses Redis for:
- Steam API responses (7 days) - Steam API responses (7 days)
- Match data (permanent until invalidated) - Match data (permanent until invalidated)
- Player profiles (7 days) - Player profiles (7 days)
@@ -919,7 +1007,7 @@ class DataCache<T> {
set(key: string, data: T, ttlMs: number) { set(key: string, data: T, ttlMs: number) {
this.cache.set(key, { this.cache.set(key, {
data, data,
expires: Date.now() + ttlMs, expires: Date.now() + ttlMs
}); });
} }
@@ -962,10 +1050,10 @@ export const matchHandlers = [
map: 'de_inferno', map: 'de_inferno',
date: '2024-11-01T18:45:00Z', date: '2024-11-01T18:45:00Z',
score_team_a: 13, score_team_a: 13,
score_team_b: 10, score_team_b: 10
// ... rest of mock data // ... rest of mock data
}); });
}), })
]; ];
``` ```
@@ -978,6 +1066,7 @@ The backend provides interactive API documentation at:
**URL**: `{API_BASE_URL}/api/swagger` **URL**: `{API_BASE_URL}/api/swagger`
This Swagger UI allows you to: This Swagger UI allows you to:
- Explore all endpoints - Explore all endpoints
- Test API calls directly - Test API calls directly
- View request/response schemas - View request/response schemas

View File

@@ -1,234 +1,393 @@
# CORS Proxy Configuration # API Proxying with SvelteKit Server Routes
This document explains how the CORS proxy works in the CS2.WTF frontend. This document explains how API requests are proxied to the backend using SvelteKit server routes.
## Problem: CORS in Development ## Why Use Server Routes?
When developing a frontend that talks to an API on a different origin, browsers enforce CORS (Cross-Origin Resource Sharing) policies. This causes errors like: The CS2.WTF frontend uses **SvelteKit server routes** to proxy API requests to the backend. This approach provides several benefits:
-**Works in all environments**: Development, preview, and production
-**No CORS issues**: Requests are server-side
-**Single code path**: Same behavior everywhere
-**Flexible backend switching**: Change one environment variable
-**Future-proof**: Can add caching, rate limiting, auth later
-**Better security**: Backend URL not exposed to client
## Architecture
### Request Flow
``` ```
Access to fetch at 'https://api.csgow.tf/matches' from origin 'http://localhost:5173' Browser → /api/matches → SvelteKit Server Route → Backend → Response
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
``` ```
## Solution: Vite Development Proxy **Detailed Flow**:
The Vite dev server includes a built-in proxy that solves this problem by making all API requests appear same-origin. ```
1. Browser: GET http://localhost:5173/api/matches?limit=20
### Configuration
2. SvelteKit: Routes to src/routes/api/[...path]/+server.ts
**File**: `vite.config.ts`
3. Server Handler: Reads VITE_API_BASE_URL environment variable
```typescript
import { loadEnv } from 'vite'; 4. Backend Call: GET https://api.csgow.tf/matches?limit=20
export default defineConfig(({ mode }) => { 5. Backend: Returns JSON response
const env = loadEnv(mode, process.cwd(), '');
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'; 6. Server Handler: Forwards response to browser
return { 7. Browser: Receives response (no CORS issues!)
server: {
proxy: {
'/api': {
target: apiBaseUrl,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
secure: false,
ws: true
}
}
}
};
});
``` ```
### How It Works **SSR (Server-Side Rendering) Flow**:
1. **API Client** (in development) makes requests to `/api/*`: ```
```typescript 1. Page Load: +page.ts calls api.matches.getMatches()
// src/lib/api/client.ts
const API_BASE_URL = import.meta.env.DEV ? '/api' : VITE_API_BASE_URL;
```
2. **Vite Proxy** intercepts requests to `/api/*` and forwards them:
```
Browser Request: GET http://localhost:5173/api/matches?limit=6
Vite Proxy: Intercepts /api/* requests 2. API Client: Detects import.meta.env.SSR === true
Backend Request: GET https://api.csgow.tf/matches?limit=6 3. Direct Call: GET https://api.csgow.tf/matches?limit=20
Response: Returns data through proxy 4. Backend: Returns JSON response
Browser: Receives response (appears same-origin) 5. SSR: Renders page with data
``` ```
3. **Browser sees same-origin request** - no CORS error! **Note**: SSR bypasses the SvelteKit route and calls the backend directly because relative URLs (`/api`) don't work during server-side rendering.
### Configuration Options ### Key Components
| Option | Value | Purpose | **1. SvelteKit Server Route** (`src/routes/api/[...path]/+server.ts`)
|--------|-------|---------|
| `target` | `env.VITE_API_BASE_URL` | Where to forward requests | - Catch-all route that matches `/api/*`
| `changeOrigin` | `true` | Updates `Origin` header to match target | - Forwards requests to backend
| `rewrite` | Remove `/api` prefix | Maps `/api/matches` → `/matches` | - Supports GET, POST, DELETE methods
| `secure` | `false` | Allow self-signed certificates | - Handles errors gracefully
| `ws` | `true` | Enable WebSocket proxying |
**2. API Client** (`src/lib/api/client.ts`)
- Browser: Uses `/api` base URL (routes to SvelteKit)
- SSR: Uses `VITE_API_BASE_URL` directly (bypasses SvelteKit route)
- Automatically detects environment with `import.meta.env.SSR`
**3. Environment Variable** (`.env`)
- `VITE_API_BASE_URL` controls which backend to use
- Switch between local and production easily
## Configuration
### Environment Variables ### Environment Variables
**`.env`**: **`.env`**:
```env ```env
# Proxy will forward /api/* to this URL # Production API (default)
VITE_API_BASE_URL=https://api.csgow.tf VITE_API_BASE_URL=https://api.csgow.tf
# Or use local backend # Local backend (for development)
# VITE_API_BASE_URL=http://localhost:8000 # VITE_API_BASE_URL=http://localhost:8000
``` ```
### Logging **Switching Backends**:
The proxy logs all requests for debugging:
```bash ```bash
[Vite Config] API Proxy target: https://api.csgow.tf # Use production API
[Proxy] GET /api/matches?limit=6 -> https://api.csgow.tf/matches?limit=6 echo "VITE_API_BASE_URL=https://api.csgow.tf" > .env
[Proxy ✓] GET /api/matches?limit=6 -> 200 npm run dev
[Proxy] GET /api/match/123 -> https://api.csgow.tf/match/123
[Proxy ✓] GET /api/match/123 -> 200 # Use local backend
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
npm run dev
``` ```
Error logging: ### Server Route Implementation
```bash
[Proxy Error] ECONNREFUSED **File**: `src/routes/api/[...path]/+server.ts`
[Proxy Error] Make sure backend is running at: http://localhost:8000
```typescript
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
export const GET: RequestHandler = async ({ params, url }) => {
const path = params.path; // e.g., "matches"
const queryString = url.search; // e.g., "?limit=20"
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
try {
const response = await fetch(backendUrl);
const data = await response.json();
return json(data);
} catch (err) {
throw error(503, 'Unable to connect to backend');
}
};
``` ```
## API Client Configuration ### API Client Configuration
**File**: `src/lib/api/client.ts` **File**: `src/lib/api/client.ts`
```typescript ```typescript
const getAPIBaseURL = (): string => { // Simple, single configuration
// In production builds, use the configured URL directly const API_BASE_URL = '/api';
if (import.meta.env.PROD) {
return import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
}
// In development mode, ALWAYS use the Vite proxy to avoid CORS issues // Always routes to SvelteKit server routes
// The proxy will forward /api requests to VITE_API_BASE_URL // No environment detection needed
return '/api';
};
``` ```
This ensures: ## Testing the Setup
- ✅ **Development**: Always uses `/api` (proxy handles CORS)
- ✅ **Production**: Uses direct URL (backend has CORS enabled)
## Testing the Proxy ### 1. Check Environment Variable
### 1. Check Vite Config Loads Environment ```bash
cat .env
# Should show:
VITE_API_BASE_URL=https://api.csgow.tf
# or
VITE_API_BASE_URL=http://localhost:8000
```
### 2. Start Development Server
Start dev server and look for:
```bash ```bash
npm run dev npm run dev
# Should show: # Server starts on http://localhost:5173
[Vite Config] API Proxy target: https://api.csgow.tf
```
### 2. Check API Client Configuration
Open browser console, look for:
```
[API Client] Development mode - using Vite proxy
[API Client] Frontend requests: /api/*
[API Client] Proxy target: https://api.csgow.tf
``` ```
### 3. Check Network Requests ### 3. Check Network Requests
Open DevTools → Network tab: Open DevTools → Network tab:
- ✅ Requests should go to `/api/*` (not full URL)
- ✅ Response should be `200 OK` - ✅ Requests go to `/api/matches`, `/api/player/123`, etc.
- ✅ Status should be `200 OK`
- ✅ No CORS errors in console - ✅ No CORS errors in console
### 4. Check Proxy Logs ### 4. Test Both Backends
Terminal should show: **Test Production API**:
```bash
# Set production API
echo "VITE_API_BASE_URL=https://api.csgow.tf" > .env
# Start dev server
npm run dev
# Visit http://localhost:5173/matches
# Should load matches from production API
``` ```
[Proxy] GET /api/matches -> https://api.csgow.tf/matches
[Proxy ✓] GET /api/matches -> 200 **Test Local Backend**:
```bash
# Start local backend first
cd ../csgowtfd
go run main.go
# In another terminal, set local API
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
# Start dev server
npm run dev
# Visit http://localhost:5173/matches
# Should load matches from local backend
``` ```
## Common Issues ## Common Issues
### Issue 1: Proxy Not Loading .env ### Issue 1: 503 Service Unavailable
**Symptom**: Proxy uses default `http://localhost:8000` instead of `.env` value **Symptom**: API requests return 503 error
**Cause**: `vite.config.ts` not loading environment variables **Possible Causes**:
**Fix**: Use `loadEnv()` in config: 1. Backend is not running
```typescript 2. Wrong `VITE_API_BASE_URL` in `.env`
import { loadEnv } from 'vite'; 3. Network connectivity issues
export default defineConfig(({ mode }) => { **Fix**:
const env = loadEnv(mode, process.cwd(), '');
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'; ```bash
// ... # Check .env file
}); cat .env
# If using local backend, make sure it's running
curl http://localhost:8000/matches
# If using production API, check connectivity
curl https://api.csgow.tf/matches
# Restart dev server after changing .env
npm run dev
``` ```
### Issue 2: Still Getting CORS Errors ### Issue 2: 404 Not Found
**Symptom**: `/api/*` routes return 404
**Cause**: SvelteKit server route file missing or not loaded
**Fix**:
```bash
# Check file exists
ls src/routes/api/[...path]/+server.ts
# If missing, create it
mkdir -p src/routes/api/'[...path]'
# Then create +server.ts file
# Restart dev server
npm run dev
```
### Issue 3: Environment Variable Not Loading
**Symptom**: Server route uses wrong backend URL
**Cause**: Changes to `.env` require server restart
**Fix**:
```bash
# Stop dev server (Ctrl+C)
# Update .env
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
# Start dev server again
npm run dev
```
### Issue 4: CORS Errors Still Appearing
**Symptom**: Browser console shows CORS errors **Symptom**: Browser console shows CORS errors
**Possible Causes**: **Cause**: API client is not using `/api` prefix
1. API client not using `/api` prefix in development
2. Request bypassing proxy somehow
3. Running production build instead of dev server
**Fix**: **Fix**:
1. Check API client logs show: `Development mode - using Vite proxy` Check `src/lib/api/client.ts`:
2. Verify Network tab shows requests to `/api/*`
3. Run `npm run dev` (not `npm run preview`)
### Issue 3: Connection Refused
**Symptom**: `[Proxy Error] ECONNREFUSED`
**Cause**: Backend is not running at the configured URL
**Fix**:
- If using local backend: Start `csgowtfd` on port 8000
- If using production API: Check `VITE_API_BASE_URL=https://api.csgow.tf`
## Production Build
In production, the proxy is **not used**. The frontend makes direct requests to the backend:
```typescript ```typescript
// Production build // Should be:
const API_BASE_URL = 'https://api.csgow.tf'; const API_BASE_URL = '/api';
// Direct request (no proxy) // Not:
fetch('https://api.csgow.tf/matches'); const API_BASE_URL = 'https://api.csgow.tf'; // ❌ Wrong
``` ```
The production API must have CORS enabled: ## How It Works Compared to Vite Proxy
### Old Approach (Vite Proxy)
``` ```
Access-Control-Allow-Origin: https://cs2.wtf Development:
Access-Control-Allow-Methods: GET, POST, OPTIONS Browser → /api → Vite Proxy → Backend
Access-Control-Allow-Headers: Content-Type, Authorization
Production:
Browser → Backend (direct, different code path)
```
**Problems**:
- Two different code paths (dev vs prod)
- Proxy only works in development
- SSR has to bypass proxy
- Complex configuration
### New Approach (SvelteKit Server Routes)
```
All Environments:
Browser → /api → SvelteKit Route → Backend
```
**Benefits**:
- Single code path
- Works in dev, preview, and production
- Consistent behavior everywhere
- Simpler configuration
## Adding Features
### Add Request Caching
**File**: `src/routes/api/[...path]/+server.ts`
```typescript
const cache = new Map<string, { data: any; expires: number }>();
export const GET: RequestHandler = async ({ params, url }) => {
const cacheKey = `${params.path}${url.search}`;
// Check cache
const cached = cache.get(cacheKey);
if (cached && Date.now() < cached.expires) {
return json(cached.data);
}
// Fetch from backend
const data = await fetch(`${API_BASE_URL}/${params.path}${url.search}`).then((r) => r.json());
// Cache for 5 minutes
cache.set(cacheKey, {
data,
expires: Date.now() + 5 * 60 * 1000
});
return json(data);
};
```
### Add Rate Limiting
```typescript
import { rateLimit } from '$lib/server/rateLimit';
export const GET: RequestHandler = async ({ request, params, url }) => {
// Check rate limit
await rateLimit(request);
// Continue with normal flow...
};
```
### Add Authentication
```typescript
export const GET: RequestHandler = async ({ request, params, url }) => {
// Get auth token from cookie
const token = request.headers.get('cookie')?.includes('auth_token');
// Forward to backend with auth
const response = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token}`
}
});
// ...
};
``` ```
## Summary ## Summary
| Environment | Frontend URL | API Requests | CORS | | Feature | Vite Proxy | SvelteKit Routes |
|-------------|-------------|--------------|------| | --------------------- | ---------- | ---------------- |
| **Development** | `http://localhost:5173` | `/api/*` → Proxy → Backend | ✅ Proxy handles | | Works in dev | ✅ | ✅ |
| **Production** | `https://cs2.wtf` | Direct to backend | ✅ Backend CORS | | Works in production | ❌ | ✅ |
| Single code path | ❌ | ✅ |
| Can add caching | ❌ | ✅ |
| Can add rate limiting | ❌ | ✅ |
| Can add auth | ❌ | ✅ |
| SSR compatible | ❌ | ✅ |
The proxy is a **development-only** feature that makes local development smooth and eliminates CORS headaches. **SvelteKit server routes provide a production-ready, maintainable solution for API proxying that works in all environments.**

View File

@@ -0,0 +1,480 @@
# CS2.WTF Feature Implementation Status
**Last Updated:** 2025-11-12
**Branch:** cs2-port
**Status:** In Progress (~70% Complete)
## Overview
This document tracks the implementation status of missing features from the original CS:GO WTF frontend that need to be ported to the new CS2.WTF SvelteKit application.
---
## Phase 1: Critical Features (HIGH PRIORITY)
### ✅ 1. Player Tracking System
**Status:** COMPLETED
- ✅ Added `tracked` field to Player type
- ✅ Updated player schema validation
- ✅ Updated API transformer to pass through `tracked` field
- ✅ Created `TrackPlayerModal.svelte` component
- Auth code input
- Optional share code input
- Track/Untrack functionality
- Help text with instructions
- Loading states and error handling
- ✅ Integrated modal into player profile page
- ✅ Added tracking status indicator button
- ✅ Connected to API endpoints: `POST /player/:id/track` and `DELETE /player/:id/track`
**Files Modified:**
- `src/lib/types/Player.ts`
- `src/lib/schemas/player.schema.ts`
- `src/lib/api/transformers.ts`
- `src/routes/player/[id]/+page.svelte`
**Files Created:**
- `src/lib/components/player/TrackPlayerModal.svelte`
---
### ✅ 2. Match Share Code Parsing
**Status:** COMPLETED
- ✅ Created `ShareCodeInput.svelte` component
- Share code input with validation
- Submit button with loading state
- Parse status feedback (parsing/success/error)
- Auto-redirect to match page on success
- Help text with instructions
- ✅ Added component to matches page
- ✅ Connected to API endpoint: `GET /match/parse/:sharecode`
- ✅ Share code format validation
**Files Created:**
- `src/lib/components/match/ShareCodeInput.svelte`
**Files Modified:**
- `src/routes/matches/+page.svelte`
---
### ✅ 3. VAC/Game Ban Status Display (Player Profile)
**Status:** COMPLETED
- ✅ Added VAC ban badge with count and date
- ✅ Added Game ban badge with count and date
- ✅ Styled with error/warning colors
- ✅ Displays on player profile header
- ✅ Shows ban dates when available
**Files Modified:**
- `src/routes/player/[id]/+page.svelte`
---
### 🔄 4. VAC Status Column on Match Scoreboard
**Status:** NOT STARTED
**TODO:**
- Add VAC status indicator column to scoreboard in `src/routes/match/[id]/+page.svelte`
- Add VAC status indicator to details tab table
- Style with red warning icon for players with VAC bans
- Tooltip with ban date on hover
**Files to Modify:**
- `src/routes/match/[id]/+page.svelte`
- `src/routes/match/[id]/details/+page.svelte`
---
### 🔄 5. Weapons Statistics Tab
**Status:** NOT STARTED
**Requires:**
- New tab on match detail page
- Component to display weapon statistics
- Hitgroup visualization (similar to old HitgroupPuppet.vue)
- Weapon breakdown table with kills, damage, hits per weapon
- API endpoint already exists: `GET /match/:id/weapons`
- API method already exists: `matchesAPI.getMatchWeapons()`
**TODO:**
- Create `src/routes/match/[id]/weapons/+page.svelte`
- Create `src/routes/match/[id]/weapons/+page.ts` (load function)
- Create `src/lib/components/match/WeaponStats.svelte`
- Create `src/lib/components/match/HitgroupVisualization.svelte`
- Update match layout tabs to include weapons tab
**Estimated Effort:** 8-16 hours
---
### 🔄 6. Recently Visited Players (Home Page)
**Status:** NOT STARTED
**Requires:**
- localStorage tracking of visited player profiles
- Display on home page as cards
- Delete/clear functionality
- Limit to last 6-10 players
**TODO:**
- Create utility functions for localStorage management
- Create `src/lib/components/player/RecentlyVisitedPlayers.svelte`
- Add to home page (`src/routes/+page.svelte`)
- Track player visits in player profile page
- Add to preferences store
**Estimated Effort:** 4-6 hours
---
## Phase 2: Important Features (MEDIUM-HIGH PRIORITY)
### 🔄 7. Complete Scoreboard Columns
**Status:** NOT STARTED
**Missing Columns:**
- Player avatars (Steam avatar images)
- Color indicators (in-game player colors)
- In-game score column
- MVP stars column
- K/D ratio column (separate from K/D difference)
- Multi-kill indicators on scoreboard (currently only in Details tab)
**TODO:**
- Update `src/routes/match/[id]/+page.svelte` scoreboard table
- Add avatar column with Steam profile images
- Add color-coded player indicators
- Add Score, MVP, K/D ratio columns
- Move multi-kill indicators to scoreboard or add as tooltips
**Estimated Effort:** 6-8 hours
---
### 🔄 8. Sitemap Generation
**Status:** NOT STARTED
**Requires:**
- Dynamic sitemap generation based on players and matches
- XML sitemap endpoint
- Sitemap index for pagination
- robots.txt configuration
**TODO:**
- Create `src/routes/sitemap.xml/+server.ts`
- Create `src/routes/sitemap/[id]/+server.ts`
- Implement sitemap generation logic
- Add robots.txt to static folder
- Connect to backend sitemap endpoints if they exist
**Estimated Effort:** 6-8 hours
---
### 🔄 9. Team Average Rank Badges (Match Header)
**Status:** NOT STARTED
**Requires:**
- Calculate average Premier rating per team
- Display in match header/layout
- Show tier badges for each team
- Rank change indicators
**TODO:**
- Add calculation logic in `src/routes/match/[id]/+layout.svelte`
- Create component for team rank display
- Style with tier colors
**Estimated Effort:** 3-4 hours
---
### 🔄 10. Chat Message Translation
**Status:** NOT STARTED
**Requires:**
- Translation API integration (Google Translate, DeepL, or similar)
- Translate button on each chat message
- Language detection
- Cache translations
**TODO:**
- Choose translation API provider
- Add API key configuration
- Create translation service in `src/lib/services/translation.ts`
- Update `src/routes/match/[id]/chat/+page.svelte`
- Add translate button to chat messages
- Handle loading and error states
**Estimated Effort:** 8-12 hours
---
## Phase 3: Polish & Nice-to-Have (MEDIUM-LOW PRIORITY)
### 🔄 11. Steam Profile Links
**Status:** NOT STARTED
**TODO:**
- Add Steam profile link to player name on player profile page
- Add links to scoreboard player names
- Support for vanity URLs
- Open in new tab
**Files to Modify:**
- `src/routes/player/[id]/+page.svelte`
- `src/routes/match/[id]/+page.svelte`
- `src/routes/match/[id]/details/+page.svelte`
**Estimated Effort:** 2-3 hours
---
### 🔄 12. Win/Loss/Tie Statistics
**Status:** NOT STARTED
**TODO:**
- Display total wins, losses, ties on player profile
- Calculate win rate from these totals
- Add to player stats cards section
**Files to Modify:**
- `src/routes/player/[id]/+page.svelte`
**Estimated Effort:** 1-2 hours
---
### 🔄 13. Privacy Policy Page
**Status:** NOT STARTED
**TODO:**
- Create `src/routes/privacy-policy/+page.svelte`
- Write privacy policy content
- Add GDPR compliance information
- Link from footer
**Estimated Effort:** 2-4 hours
---
### 🔄 14. Player Color Indicators (Scoreboard)
**Status:** NOT STARTED
**TODO:**
- Display in-game player colors on scoreboard
- Color-code player rows or names
- Match CS2 color scheme (green/yellow/purple/blue/orange)
**Files to Modify:**
- `src/routes/match/[id]/+page.svelte`
**Estimated Effort:** 1-2 hours
---
### 🔄 15. Additional Utility Statistics
**Status:** NOT STARTED
**Missing Stats:**
- Self-flash statistics
- Smoke grenade usage
- Decoy grenade usage
- Team flash statistics
**TODO:**
- Display in match details or player profile
- Add to utility effectiveness section
**Estimated Effort:** 2-3 hours
---
## Feature Parity Comparison
### What's BETTER in Current Implementation ✨
- Modern SvelteKit architecture with TypeScript
- Superior filtering and search functionality
- Data export (CSV/JSON)
- Better data visualizations (Chart.js)
- Premier rating system (CS2-specific)
- Dark/light theme toggle
- Infinite scroll
- Better responsive design
### What's Currently Missing ⚠️
- Weapon statistics page (high impact)
- Complete scoreboard columns (medium impact)
- Recently visited players (medium impact)
- Sitemap/SEO (medium impact)
- Chat translation (low-medium impact)
- Various polish features (low impact)
---
## Estimated Remaining Effort
### By Priority
| Priority | Tasks Remaining | Est. Hours | Status |
| ------------------- | --------------- | --------------- | ---------------- |
| Phase 1 (Critical) | 3 | 16-30 hours | 50% Complete |
| Phase 2 (Important) | 4 | 23-36 hours | 0% Complete |
| Phase 3 (Polish) | 5 | 8-14 hours | 0% Complete |
| **TOTAL** | **12** | **47-80 hours** | **25% Complete** |
### Overall Project Status
- **Completed:** 3 critical features
- **In Progress:** API cleanup and optimization
- **Remaining:** 12 features across 3 phases
- **Estimated Completion:** 2-3 weeks of full-time development
---
## Next Steps
### Immediate (This Session)
1. ✅ Player tracking UI - DONE
2. ✅ Share code parsing UI - DONE
3. ✅ VAC/ban status display (profile) - DONE
4. ⏭️ VAC status on scoreboard - NEXT
5. ⏭️ Weapons statistics tab - NEXT
6. ⏭️ Recently visited players - NEXT
### Short Term (Next Session)
- Complete remaining Phase 1 features
- Start Phase 2 features (scoreboard completion, sitemap)
### Medium Term
- Complete Phase 2 features
- Begin Phase 3 polish features
### Long Term
- Full feature parity with old frontend
- Additional CS2-specific features
- Performance optimizations
---
## Testing Checklist
### Completed Features
- [x] Player tracking modal opens and closes
- [x] Player tracking modal validates auth code input
- [x] Track/untrack API calls work
- [x] Tracking status updates after track/untrack
- [x] Share code input validates format
- [x] Share code parsing submits to API
- [x] Parse status feedback displays correctly
- [x] Redirect to match page after successful parse
- [x] VAC/ban badges display on player profile
- [x] VAC/ban dates show when available
### TODO Testing
- [ ] VAC status displays on scoreboard
- [ ] Weapons tab loads and displays data
- [ ] Hitgroup visualization renders correctly
- [ ] Recently visited players tracked correctly
- [ ] Recently visited players display on home page
- [ ] All Phase 2 and 3 features
---
## Known Issues
### Current
- None
### Potential
- Translation API rate limiting (once implemented)
- Sitemap generation performance with large datasets
- Weapons tab may need pagination for long matches
---
## Notes
### Architecture Decisions
- Using SvelteKit server routes for API proxying (no CORS issues)
- Transformers pattern for legacy API format conversion
- Component-based approach for reusability
- TypeScript + Zod for type safety
### API Endpoints Used
-`POST /player/:id/track`
-`DELETE /player/:id/track`
-`GET /match/parse/:sharecode`
- ⏭️ `GET /match/:id/weapons` (available but not used yet)
- ⏭️ `GET /player/:id/meta` (available but not optimized yet)
---
## Contributors
- Initial Analysis: Claude (Anthropic AI)
- Implementation: In Progress
- Testing: Pending
---
**For questions or updates, refer to the main project README.md**

View File

@@ -21,6 +21,7 @@ npm install
The `.env` file already exists in the project. You can use it as-is or modify it: The `.env` file already exists in the project. You can use it as-is or modify it:
**Option A: Use Production API** (Recommended for frontend development) **Option A: Use Production API** (Recommended for frontend development)
```env ```env
# Use the live production API - no local backend needed # Use the live production API - no local backend needed
VITE_API_BASE_URL=https://api.csgow.tf VITE_API_BASE_URL=https://api.csgow.tf
@@ -30,6 +31,7 @@ VITE_ENABLE_ANALYTICS=false
``` ```
**Option B: Use Local Backend** (For full-stack development) **Option B: Use Local Backend** (For full-stack development)
```env ```env
# Use local backend (requires csgowtfd running on port 8000) # Use local backend (requires csgowtfd running on port 8000)
VITE_API_BASE_URL=http://localhost:8000 VITE_API_BASE_URL=http://localhost:8000
@@ -47,13 +49,12 @@ npm run dev
The frontend will be available at `http://localhost:5173` The frontend will be available at `http://localhost:5173`
You should see output like: You should see output like:
``` ```
[Vite Config] API Proxy target: https://api.csgow.tf VITE v5.x.x ready in xxx ms
[API Client] Development mode - using Vite proxy
[API Client] Frontend requests: /api/*
[API Client] Proxy target: https://api.csgow.tf
➜ Local: http://localhost:5173/ ➜ Local: http://localhost:5173/
➜ Network: use --host to expose
``` ```
### 4. (Optional) Start Local Backend ### 4. (Optional) Start Local Backend
@@ -67,45 +68,57 @@ go run cmd/csgowtfd/main.go
``` ```
Or use Docker: Or use Docker:
```bash ```bash
docker-compose up csgowtfd docker-compose up csgowtfd
``` ```
## How the CORS Proxy Works ## How SvelteKit API Routes Work
The Vite dev server includes a **built-in proxy** that eliminates CORS issues during development: All API requests go through **SvelteKit server routes** which proxy to the backend. This works consistently in all environments.
### Request Flow (All Environments)
### Development Mode Flow
``` ```
1. Browser makes request to: http://localhost:5173/api/matches 1. Browser makes request to: http://localhost:5173/api/matches
2. Vite intercepts and proxies to: ${VITE_API_BASE_URL}/matches 2. SvelteKit routes to: src/routes/api/[...path]/+server.ts
3. Backend responds 3. Server handler reads VITE_API_BASE_URL environment variable
4. Vite forwards response to browser 4. Server fetches from backend: ${VITE_API_BASE_URL}/matches
5. Backend responds
6. Server handler forwards response to browser
``` ```
### Benefits ### Benefits
-**No CORS errors** - All requests appear same-origin to the browser
-**Works with any backend** - Local or remote
-**No backend CORS config needed** - Proxy handles it
-**Simple configuration** - Just set `VITE_API_BASE_URL`
### Proxy Logs -**No CORS errors** - All requests are server-side
-**Works in all environments** - Dev, preview, and production
-**Single code path** - Same behavior everywhere
-**Easy backend switching** - Change one environment variable
-**Future-proof** - Can add caching, rate limiting, auth later
-**Backend URL not exposed** - Hidden from client
You'll see detailed proxy activity in the terminal: ### Switching Between Backends
Simply update `.env` and restart the dev server:
```bash ```bash
[Proxy] GET /api/matches?limit=6 -> https://api.csgow.tf/matches?limit=6 # Use production API
[Proxy ✓] GET /api/matches?limit=6 -> 200 echo "VITE_API_BASE_URL=https://api.csgow.tf" > .env
[Proxy] GET /api/match/123 -> https://api.csgow.tf/match/123 npm run dev
[Proxy ✓] GET /api/match/123 -> 200
# Use local backend
echo "VITE_API_BASE_URL=http://localhost:8000" > .env
npm run dev
``` ```
### Production vs Development ### Development vs Production
| Mode | API Base URL | CORS | | Mode | Request Flow | Backend URL From |
|------|-------------|------| | -------------------------------- | ---------------------------------------------- | ------------------------------ |
| **Development** (`npm run dev`) | `/api` (proxied to `VITE_API_BASE_URL`) | ✅ No issues | | **Development** (`npm run dev`) | Browser → `/api/*` → SvelteKit Route → Backend | `.env` `VITE_API_BASE_URL` |
| **Production** (`npm run build`) | `VITE_API_BASE_URL` (direct) | ✅ Backend has CORS enabled | | **Production** (`npm run build`) | Browser → `/api/*` → SvelteKit Route → Backend | Build-time `VITE_API_BASE_URL` |
**Note**: The flow is identical in both modes - this is the key advantage over the old Vite proxy approach.
## Troubleshooting ## Troubleshooting
@@ -116,34 +129,40 @@ You'll see detailed proxy activity in the terminal:
**Solutions**: **Solutions**:
1. **Check what backend you're using**: 1. **Check what backend you're using**:
```bash ```bash
# Look at your .env file # Look at your .env file
cat .env | grep VITE_API_BASE_URL cat .env | grep VITE_API_BASE_URL
``` ```
2. **If using production API** (`https://api.csgow.tf`): 2. **If using production API** (`https://api.csgow.tf`):
```bash ```bash
# Test if production API is accessible # Test if production API is accessible
curl https://api.csgow.tf/matches?limit=1 curl https://api.csgow.tf/matches?limit=1
``` ```
Should return JSON data. If not, production API may be down. Should return JSON data. If not, production API may be down.
3. **If using local backend** (`http://localhost:8000`): 3. **If using local backend** (`http://localhost:8000`):
```bash ```bash
# Test if local backend is running # Test if local backend is running
curl http://localhost:8000/matches?limit=1 curl http://localhost:8000/matches?limit=1
``` ```
If you get "Connection refused", start the backend service. If you get "Connection refused", start the backend service.
4. **Check proxy logs**: 4. **Check browser console**:
- Look at the terminal running `npm run dev`
- You should see `[Proxy]` messages showing requests being forwarded
- If you see `[Proxy Error]`, check the error message
5. **Check browser console**:
- Open DevTools → Console tab - Open DevTools → Console tab
- Look for `[API Client]` messages showing proxy configuration - Look for `[API Route]` error messages from the server route handler
- Network tab should show requests to `/api/*` (not external URLs) - Network tab should show requests to `/api/*` (not external URLs)
- Check if requests return 503 (backend unreachable) or 500 (server error)
5. **Check server logs**:
- Look at the terminal running `npm run dev`
- Server route errors will appear with `[API Route] Error fetching...`
- This will show you the exact backend URL being requested
6. **Restart dev server**: 6. **Restart dev server**:
```bash ```bash
@@ -151,18 +170,16 @@ You'll see detailed proxy activity in the terminal:
npm run dev npm run dev
``` ```
### CORS Errors (Should Not Happen) ### CORS Errors (Should Never Happen)
If you see CORS errors in the browser console, the proxy isn't working: CORS errors should be impossible with SvelteKit server routes since all requests are server-side.
**Symptoms**: **If you somehow see CORS errors:**
- Browser console shows: `CORS policy: No 'Access-Control-Allow-Origin' header`
- Network tab shows requests going to `https://api.csgow.tf` directly (not `/api`)
**Fix**: - This means the API client is bypassing the `/api` routes
1. Verify you're in development mode (not production build) - Check that `src/lib/api/client.ts` has `API_BASE_URL = '/api'`
2. Check API client logs show: `Development mode - using Vite proxy` - Verify `src/routes/api/[...path]/+server.ts` exists
3. Restart dev server with clean cache: - Clear cache and restart:
```bash ```bash
rm -rf .svelte-kit rm -rf .svelte-kit
npm run dev npm run dev
@@ -196,6 +213,7 @@ Then restart the dev server.
### 1. Make Changes ### 1. Make Changes
Edit files in `src/`. The dev server has hot module replacement (HMR): Edit files in `src/`. The dev server has hot module replacement (HMR):
- Component changes reload instantly - Component changes reload instantly
- Route changes reload the page - Route changes reload the page
- Store changes reload affected components - Store changes reload affected components
@@ -243,33 +261,32 @@ The backend provides these endpoints (see `docs/API.md` for full details):
### How Requests Work ### How Requests Work
**In Development** (`npm run dev`): **All Environments** (dev, preview, production):
``` ```
Frontend code: api.matches.getMatches() Frontend code: api.matches.getMatches()
API Client: GET /api/matches API Client: GET /api/matches
Vite Proxy: GET https://api.csgow.tf/matches SvelteKit Route: src/routes/api/[...path]/+server.ts
Server Handler: GET ${VITE_API_BASE_URL}/matches
Response: ← Data returned to frontend Response: ← Data returned to frontend
``` ```
**In Production** (deployed app): The request flow is identical in all environments. The only difference is which backend URL `VITE_API_BASE_URL` points to:
```
Frontend code: api.matches.getMatches()
API Client: GET https://api.csgow.tf/matches (direct)
Response: ← Data returned to frontend
```
The API client automatically uses the correct URL based on environment. - Development: Usually `https://api.csgow.tf` (production API)
- Local full-stack: `http://localhost:8000` (local backend)
- Production: `https://api.csgow.tf` (or custom backend URL)
## Mock Data (Alternative: No Backend) ## Mock Data (Alternative: No Backend)
If you want to develop without any backend (local or production), enable MSW mocking: If you want to develop without any backend (local or production), enable MSW mocking:
1. Update `.env`: 1. Update `.env`:
```env ```env
VITE_ENABLE_MSW_MOCKING=true VITE_ENABLE_MSW_MOCKING=true
``` ```
@@ -295,7 +312,7 @@ The preview server runs on `http://localhost:4173` and uses the production API c
## Environment Variables Reference ## Environment Variables Reference
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| | -------------------------- | ----------------------- | ---------------------------- |
| `VITE_API_BASE_URL` | `http://localhost:8000` | Backend API base URL | | `VITE_API_BASE_URL` | `http://localhost:8000` | Backend API base URL |
| `VITE_API_TIMEOUT` | `10000` | Request timeout (ms) | | `VITE_API_TIMEOUT` | `10000` | Request timeout (ms) |
| `VITE_ENABLE_LIVE_MATCHES` | `false` | Enable live match polling | | `VITE_ENABLE_LIVE_MATCHES` | `false` | Enable live match polling |

460
docs/MATCHES_API.md Normal file
View File

@@ -0,0 +1,460 @@
# Matches API Endpoint Documentation
This document provides detailed information about the matches API endpoints used by CS2.WTF to retrieve match data from the backend CSGOWTFD service.
## Overview
The matches API provides access to Counter-Strike 2 match data including match listings, detailed match statistics, and related match information such as weapons, rounds, and chat data.
## Base URL
All endpoints are relative to the API base URL: `https://api.csgow.tf`
During development, requests are proxied through `/api` to avoid CORS issues.
## Authentication
No authentication is required for read operations. All match data is publicly accessible.
## Rate Limiting
The API does not currently enforce rate limiting, but clients should implement reasonable request throttling to avoid overwhelming the service.
## Endpoints
### 1. Get Matches List
Retrieves a paginated list of matches.
**Endpoint**: `GET /matches`
**Alternative**: `GET /matches/next/:time`
**Parameters**:
- `time` (path, optional): Unix timestamp for pagination (use with `/matches/next/:time`)
- Query parameters:
- `limit` (optional): Number of matches to return (default: 50, max: 100)
- `map` (optional): Filter by map name (e.g., `de_inferno`)
- `player_id` (optional): Filter by player Steam ID
**Response** (200 OK):
**IMPORTANT**: This endpoint returns a **plain array**, not an object with properties.
```json
[
{
"match_id": "3589487716842078322",
"map": "de_inferno",
"date": 1730487900,
"score": [13, 10],
"duration": 2456,
"match_result": 1,
"max_rounds": 24,
"parsed": true,
"vac": false,
"game_ban": false
}
]
```
**Field Descriptions**:
- `match_id`: Unique match identifier (uint64 as string)
- `map`: Map name (can be empty string if not parsed)
- `date`: Unix timestamp (seconds since epoch)
- `score`: Array with two elements `[team_a_score, team_b_score]`
- `duration`: Match duration in seconds
- `match_result`: 0 = tie, 1 = team_a win, 2 = team_b win
- `max_rounds`: Maximum rounds (24 for MR12, 30 for MR15)
- `parsed`: Whether the demo has been parsed
- `vac`: Whether any player has a VAC ban
- `game_ban`: Whether any player has a game ban
**Pagination**:
- The API returns a plain array of matches, sorted by date (newest first)
- To get the next page, use the `date` field from the **last match** in the array
- Request `/matches/next/{timestamp}` where `{timestamp}` is the Unix timestamp
- Continue until the response returns fewer matches than your `limit` parameter
- Example: If you request `limit=20` and get back 15 matches, you've reached the end
### 2. Get Match Details
Retrieves detailed information about a specific match including player statistics.
**Endpoint**: `GET /match/{match_id}`
**Parameters**:
- `match_id` (path): The unique match identifier (uint64 as string)
**Response** (200 OK):
```json
{
"match_id": "3589487716842078322",
"share_code": "CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
"map": "de_inferno",
"date": "2024-11-01T18:45:00Z",
"score_team_a": 13,
"score_team_b": 10,
"duration": 2456,
"match_result": 1,
"max_rounds": 24,
"demo_parsed": true,
"vac_present": false,
"gameban_present": false,
"tick_rate": 64.0, // Optional: not always provided by API
"players": [
{
"id": "765611980123456",
"name": "Player1",
"avatar": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg",
"team_id": 2,
"kills": 24,
"deaths": 18,
"assists": 6,
"headshot": 12,
"mvp": 3,
"score": 56,
"kast": 78, // Optional: not always provided by API
"rank_old": 18500,
"rank_new": 18650,
"dmg_enemy": 2450,
"dmg_team": 120,
"flash_assists": 4,
"flash_duration_enemy": 15.6,
"flash_total_enemy": 8,
"ud_he": 450,
"ud_flames": 230,
"ud_flash": 5,
"ud_smoke": 3,
"avg_ping": 25.5,
"color": "yellow"
}
]
}
```
### 3. Get Match Weapons
Retrieves weapon statistics for all players in a match.
**Endpoint**: `GET /match/{match_id}/weapons`
**Parameters**:
- `match_id` (path): The unique match identifier
**Response** (200 OK):
```json
{
"match_id": 3589487716842078322,
"weapons": [
{
"player_id": 765611980123456,
"weapon_stats": [
{
"eq_type": 17,
"weapon_name": "AK-47",
"kills": 12,
"damage": 1450,
"hits": 48,
"hit_groups": {
"head": 8,
"chest": 25,
"stomach": 8,
"left_arm": 3,
"right_arm": 2,
"left_leg": 1,
"right_leg": 1
},
"headshot_pct": 16.7
}
]
}
]
}
```
### 4. Get Match Rounds
Retrieves round-by-round statistics for a match.
**Endpoint**: `GET /match/{match_id}/rounds`
**Parameters**:
- `match_id` (path): The unique match identifier
**Response** (200 OK):
```json
{
"match_id": 3589487716842078322,
"rounds": [
{
"round": 1,
"winner": 2,
"win_reason": "elimination",
"players": [
{
"round": 1,
"player_id": 765611980123456,
"bank": 800,
"equipment": 650,
"spent": 650,
"kills_in_round": 2,
"damage_in_round": 120
}
]
}
]
}
```
### 5. Get Match Chat
Retrieves chat messages from a match.
**Endpoint**: `GET /match/{match_id}/chat`
**Parameters**:
- `match_id` (path): The unique match identifier
**Response** (200 OK):
```json
{
"match_id": 3589487716842078322,
"messages": [
{
"player_id": 765611980123456,
"player_name": "Player1",
"message": "nice shot!",
"tick": 15840,
"round": 8,
"all_chat": true,
"timestamp": "2024-11-01T19:12:34Z"
}
]
}
```
### 6. Parse Match from Share Code
Initiates parsing of a match from a CS:GO/CS2 share code.
**Endpoint**: `GET /match/parse/{sharecode}`
**Parameters**:
- `sharecode` (path): The CS:GO/CS2 match share code
**Response** (200 OK):
```json
{
"match_id": "3589487716842078322",
"status": "parsing",
"message": "Demo download and parsing initiated",
"estimated_time": 120
}
```
## Data Models
### Match
```typescript
interface Match {
match_id: string; // Unique match identifier (uint64 as string)
share_code?: string; // CS:GO/CS2 share code (optional)
map: string; // Map name (e.g., "de_inferno")
date: string; // Match date and time (ISO 8601)
score_team_a: number; // Final score for team A
score_team_b: number; // Final score for team B
duration: number; // Match duration in seconds
match_result: number; // Match result: 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: number; // Maximum rounds (24 for MR12, 30 for MR15)
demo_parsed: boolean; // Whether the demo has been successfully parsed
vac_present: boolean; // Whether any player has a VAC ban
gameban_present: boolean; // Whether any player has a game ban
tick_rate?: number; // Server tick rate (64 or 128) - optional, not always provided by API
players?: MatchPlayer[]; // Array of player statistics (optional)
}
```
### MatchPlayer
```typescript
interface MatchPlayer {
id: string; // Player Steam ID (uint64 as string)
name: string; // Player display name
avatar: string; // Steam avatar URL
team_id: number; // Team ID: 2 = T side, 3 = CT side
kills: number; // Kills
deaths: number; // Deaths
assists: number; // Assists
headshot: number; // Headshot kills
mvp: number; // MVP stars earned
score: number; // In-game score
kast?: number; // KAST percentage (0-100) - optional, not always provided by API
rank_old?: number; // Premier rating before match (0-30000)
rank_new?: number; // Premier rating after match (0-30000)
dmg_enemy?: number; // Damage to enemies
dmg_team?: number; // Damage to teammates
flash_assists?: number; // Flash assist count
flash_duration_enemy?: number; // Total enemy blind time
flash_total_enemy?: number; // Enemies flashed count
ud_he?: number; // HE grenade damage
ud_flames?: number; // Molotov/Incendiary damage
ud_flash?: number; // Flash grenades used
ud_smoke?: number; // Smoke grenades used
avg_ping?: number; // Average ping
color?: string; // Player color
}
```
### MatchListItem
```typescript
interface MatchListItem {
match_id: string; // Unique match identifier (uint64 as string)
map: string; // Map name
date: string; // Match date and time (ISO 8601)
score_team_a: number; // Final score for team A
score_team_b: number; // Final score for team B
duration: number; // Match duration in seconds
demo_parsed: boolean; // Whether the demo has been successfully parsed
player_count?: number; // Number of players in the match - optional, not provided by API
}
```
## Error Handling
All API errors follow a consistent format:
```json
{
"error": "Error message",
"code": 404,
"details": {
"match_id": "3589487716842078322"
}
}
```
### Common HTTP Status Codes
- `200 OK`: Request successful
- `400 Bad Request`: Invalid parameters
- `404 Not Found`: Resource not found
- `500 Internal Server Error`: Server error
## Implementation Notes
### Pagination
The matches API implements cursor-based pagination using timestamps:
1. Initial request to `/matches` returns a plain array of matches (sorted newest first)
2. Extract the `date` field from the **last match** in the array
3. Request `/matches/next/{timestamp}` to get older matches
4. Continue until the response returns fewer matches than your `limit` parameter
5. The API does **not** provide `has_more` or `next_page_time` fields - you must calculate these yourself
### Data Transformation
The frontend application transforms legacy API responses to a modern schema-validated format:
- Unix timestamps are converted to ISO strings
- Avatar hashes are converted to full URLs (if provided)
- Team IDs are normalized (1/2 → 2/3 if needed)
- Score arrays `[team_a, team_b]` are split into separate fields
- Field names are mapped: `parsed``demo_parsed`, `vac``vac_present`, `game_ban``gameban_present`
- Missing fields are provided with defaults (e.g., `tick_rate: 64`)
### Steam ID Handling
All Steam IDs and Match IDs are handled as strings to preserve uint64 precision. Never convert these to numbers as it causes precision loss.
## Examples
### Fetching Matches with Pagination
```javascript
// Initial request - API returns a plain array
const matches = await fetch('/api/matches?limit=20').then((r) => r.json());
// matches is an array: [{ match_id, map, date, ... }, ...]
console.log(`Loaded ${matches.length} matches`);
// Get the timestamp of the last match for pagination
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
const lastTimestamp = lastMatch.date; // Unix timestamp
// Fetch next page using the timestamp
const moreMatches = await fetch(`/api/matches/next/${lastTimestamp}?limit=20`).then((r) =>
r.json()
);
console.log(`Loaded ${moreMatches.length} more matches`);
// Check if we've reached the end
if (moreMatches.length < 20) {
console.log('Reached the end of matches');
}
}
```
### Complete Pagination Loop
```javascript
async function loadAllMatches(limit = 50) {
let allMatches = [];
let hasMore = true;
let lastTimestamp = null;
while (hasMore) {
// Build URL based on whether we have a timestamp
const url = lastTimestamp
? `/api/matches/next/${lastTimestamp}?limit=${limit}`
: `/api/matches?limit=${limit}`;
// Fetch matches
const matches = await fetch(url).then((r) => r.json());
// Add to collection
allMatches.push(...matches);
// Check if there are more
if (matches.length < limit) {
hasMore = false;
} else {
// Get timestamp of last match for next iteration
lastTimestamp = matches[matches.length - 1].date;
}
}
return allMatches;
}
```
### Filtering Matches by Map
```javascript
const response = await fetch('/api/matches?map=de_inferno&limit=20');
const data = await response.json();
```
### Filtering Matches by Player
```javascript
const response = await fetch('/api/matches?player_id=765611980123456&limit=20');
const data = await response.json();
```

View File

@@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<meta content="Track your CSGO matches and see your match details."
name="description">
<meta content="index, follow, archive"
name="robots">
<meta content="Track your CSGO matches and see your match details."
property="st:section">
<meta content="csgoWTF - Open source CSGO data platform"
name="twitter:title">
<meta content="Track your CSGO matches and see your match details."
name="twitter:description">
<meta content="summary_large_image"
name="twitter:card">
<meta content="https://csgow.tf/"
property="og:url">
<meta content="csgoWTF - Open source CSGO data platform"
property="og:title">
<meta content="Track your CSGO matches and see your match details."
property="og:description">
<meta content="website"
property="og:type">
<meta content="en_US"
property="og:locale">
<meta content="csgoWTF - Open source CSGO data platform"
property="og:site_name">
<meta content="https://csgow.tf/images/logo.png"
name="twitter:image">
<meta content="https://csgow.tf/images/logo.png"
property="og:image">
<meta content="1024"
property="og:image:width">
<meta content="526"
property="og:image:height">
<meta content="https://csgow.tf/images/logo.png"
property="og:image:secure_url">
<link href="<%= BASE_URL %>images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link href="<%= BASE_URL %>images/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png">
<link href="<%= BASE_URL %>images/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png">
<link href="<%= BASE_URL %>site.webmanifest" rel="manifest">
<link rel="preconnect" href="https://steamcdn-a.akamaihd.net" crossorigin>
<link rel="dns-prefetch" href="https://steamcdn-a.akamaihd.net">
<link rel="preconnect" href="https://api.csgow.tf" crossorigin>
<link rel="dns-prefetch" href="https://api.csgow.tf">
<link rel="preconnect" href="https://piwik.harting.hosting" crossorigin>
<link rel="dns-prefetch" href="https://piwik.harting.hosting">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app" class="d-flex flex-column min-vh-100"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -2,6 +2,17 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* CS2 Custom Font */
@font-face {
font-family: 'CS Regular';
src:
url('/fonts/cs_regular.woff2') format('woff2'),
url('/fonts/cs_regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@layer base { @layer base {
:root { :root {
/* Default to dark theme */ /* Default to dark theme */
@@ -10,10 +21,34 @@
body { body {
@apply bg-base-100 text-base-content; @apply bg-base-100 text-base-content;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
font-feature-settings: font-feature-settings:
'rlig' 1, 'rlig' 1,
'calt' 1; 'calt' 1;
} }
/* CS2 Font for headlines only */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family:
'CS Regular',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
} }
@layer components { @layer components {

View File

@@ -4,38 +4,32 @@ import { APIException } from '$lib/types';
/** /**
* API Client Configuration * API Client Configuration
*
* Uses SvelteKit server routes (/api/[...path]/+server.ts) to proxy requests to the backend.
* This approach:
* - Works in all environments (dev, preview, production)
* - No CORS issues
* - Single code path for consistency
* - Can add caching, rate limiting, auth in the future
*
* Backend selection is controlled by VITE_API_BASE_URL environment variable:
* - Local development: VITE_API_BASE_URL=http://localhost:8000
* - Production: VITE_API_BASE_URL=https://api.csgow.tf
*
* Note: During SSR, we call the backend directly since relative URLs don't work server-side.
*/ */
const getAPIBaseURL = (): string => { function getAPIBaseURL(): string {
const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'; // During SSR, call backend API directly (relative URLs don't work server-side)
if (import.meta.env.SSR) {
// Check if we're running on the server (SSR) or in production return import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
// On the server, we must use the actual API URL, not the proxy
if (import.meta.env.SSR || import.meta.env.PROD) {
return apiUrl;
} }
// In browser, use SvelteKit route
// In development mode on the client, use the Vite proxy to avoid CORS issues
// The proxy will forward /api requests to VITE_API_BASE_URL
return '/api'; return '/api';
}; }
const API_BASE_URL = getAPIBaseURL(); const API_BASE_URL = getAPIBaseURL();
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000; const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
// Log the API configuration
if (import.meta.env.DEV) {
if (import.meta.env.SSR) {
console.log('[API Client] SSR mode - using direct API URL:', API_BASE_URL);
} else {
console.log('[API Client] Browser mode - using Vite proxy');
console.log('[API Client] Frontend requests: /api/*');
console.log(
'[API Client] Proxy target:',
import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'
);
}
}
/** /**
* Base API Client * Base API Client
* Provides centralized HTTP communication with error handling * Provides centralized HTTP communication with error handling

View File

@@ -93,23 +93,54 @@ export const matchesAPI = {
/** /**
* Get paginated list of matches * Get paginated list of matches
*
* IMPORTANT: The API returns a plain array, not an object with properties.
* We must manually implement pagination by:
* 1. Requesting limit + 1 matches
* 2. Checking if we got more than limit (means there are more pages)
* 3. Extracting timestamp from last match for next page
*
* Pagination flow:
* - First call: GET /matches?limit=20 → returns array of up to 20 matches
* - Next call: GET /matches/next/{timestamp}?limit=20 → returns next 20 matches
* - Continue until response.length < limit (reached the end)
*
* @param params - Query parameters (filters, pagination) * @param params - Query parameters (filters, pagination)
* @returns List of matches with pagination * @param params.limit - Number of matches to return (default: 50)
* @param params.before_time - Unix timestamp for pagination (get matches before this time)
* @param params.map - Filter by map name (e.g., "de_inferno")
* @param params.player_id - Filter by player Steam ID
* @returns List of matches with pagination metadata
*/ */
async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> { async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches'; const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 50;
// API returns a plain array, not a wrapped object // CRITICAL: API returns a plain array, not a wrapped object
// We request limit + 1 to detect if there are more pages
const data = await apiClient.get<LegacyMatchListItem[]>(url, { const data = await apiClient.get<LegacyMatchListItem[]>(url, {
params: { params: {
limit: params?.limit, limit: limit + 1, // Request one extra to check if there are more
map: params?.map, map: params?.map,
player_id: params?.player_id player_id: params?.player_id
} }
}); });
// Check if there are more matches (if we got the extra one)
const hasMore = data.length > limit;
// Remove the extra match if we have more
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
// If there are more matches, use the timestamp of the last match for pagination
// This timestamp is used in the next request: /matches/next/{timestamp}
const lastMatch =
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Transform legacy API response to new format // Transform legacy API response to new format
return transformMatchesListResponse(data); return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
}, },
/** /**
@@ -118,19 +149,32 @@ export const matchesAPI = {
* @returns List of matching matches * @returns List of matching matches
*/ */
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> { async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = '/matches'; const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 20;
// API returns a plain array, not a wrapped object // API returns a plain array, not a wrapped object
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', { const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
params: { params: {
limit: params?.limit || 20, limit: limit + 1, // Request one extra to check if there are more
map: params?.map, map: params?.map,
player_id: params?.player_id, player_id: params?.player_id
before_time: params?.before_time
} }
}); });
// Check if there are more matches (if we got the extra one)
const hasMore = data.length > limit;
// Remove the extra match if we have more
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
// If there are more matches, use the timestamp of the last match for pagination
const lastMatch =
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Transform legacy API response to new format // Transform legacy API response to new format
return transformMatchesListResponse(data); return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
}, },
/** /**

View File

@@ -36,6 +36,7 @@ export const playersAPI = {
const transformedData = transformPlayerProfile(legacyData); const transformedData = transformPlayerProfile(legacyData);
// Validate the player data // Validate the player data
// parsePlayer throws on validation failure, so player is always defined if we reach this point
const player = parsePlayer(transformedData); const player = parsePlayer(transformedData);
// Calculate aggregated stats from matches // Calculate aggregated stats from matches
@@ -60,18 +61,19 @@ export const playersAPI = {
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0; const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
// Find the most recent match date // Find the most recent match date
const lastMatchDate = matches.length > 0 ? matches[0].date : new Date().toISOString(); const lastMatchDate =
matches.length > 0 && matches[0] ? matches[0].date : new Date().toISOString();
// Transform to PlayerMeta format // Transform to PlayerMeta format
const playerMeta: PlayerMeta = { const playerMeta: PlayerMeta = {
id: parseInt(player.id), id: parseInt(player.id, 10),
name: player.name, name: player.name,
avatar: player.avatar, // Already transformed by transformPlayerProfile avatar: player.avatar, // Already transformed by transformPlayerProfile
recent_matches: recentMatches.length, recent_matches: recentMatches.length,
last_match_date: lastMatchDate, last_match_date: lastMatchDate,
avg_kills: avgKills, avg_kills: avgKills,
avg_deaths: avgDeaths, avg_deaths: avgDeaths,
avg_kast: totalKast / recentMatches.length || 0, // Placeholder KAST calculation avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation
win_rate: winRate win_rate: winRate
}; };

View File

@@ -1,28 +1,46 @@
/** /**
* API Response Transformers * API Response Transformers
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format * Converts legacy CSGO:WTF API responses to the new CS2.WTF format
*
* IMPORTANT: The backend API returns data in a legacy format that differs from our TypeScript schemas.
* These transformers bridge that gap by:
* 1. Converting Unix timestamps to ISO 8601 strings
* 2. Splitting score arrays [team_a, team_b] into separate fields
* 3. Renaming fields (parsed → demo_parsed, vac → vac_present, etc.)
* 4. Constructing full avatar URLs from hashes
* 5. Normalizing team IDs (1/2 → 2/3)
*
* Always use these transformers before passing API data to Zod schemas or TypeScript types.
*/ */
import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types'; import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types';
/** /**
* Legacy API match format (from api.csgow.tf) * Legacy API match list item format (from api.csgow.tf)
*
* VERIFIED: This interface matches the actual API response from GET /matches
* Tested: 2025-11-12 via curl https://api.csgow.tf/matches?limit=2
*/ */
export interface LegacyMatchListItem { export interface LegacyMatchListItem {
match_id: string; match_id: string; // uint64 as string
map: string; map: string; // Can be empty string if not parsed
date: number; // Unix timestamp date: number; // Unix timestamp (seconds since epoch)
score: [number, number]; // [team_a, team_b] score: [number, number]; // [team_a_score, team_b_score]
duration: number; duration: number; // Match duration in seconds
match_result: number; match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: number; max_rounds: number; // 24 for MR12, 30 for MR15
parsed: boolean; parsed: boolean; // Whether demo has been parsed (NOT demo_parsed)
vac: boolean; vac: boolean; // Whether any player has VAC ban (NOT vac_present)
game_ban: boolean; game_ban: boolean; // Whether any player has game ban (NOT gameban_present)
} }
/** /**
* Legacy API match detail format * Legacy API match detail format (from GET /match/:id)
*
* VERIFIED: This interface matches the actual API response
* Tested: 2025-11-12 via curl https://api.csgow.tf/match/3589487716842078322
*
* Note: Uses 'stats' array, not 'players' array
*/ */
export interface LegacyMatchDetail { export interface LegacyMatchDetail {
match_id: string; match_id: string;
@@ -33,14 +51,21 @@ export interface LegacyMatchDetail {
duration: number; duration: number;
match_result: number; match_result: number;
max_rounds: number; max_rounds: number;
parsed: boolean; parsed: boolean; // NOT demo_parsed
vac: boolean; vac: boolean; // NOT vac_present
game_ban: boolean; game_ban: boolean; // NOT gameban_present
stats?: LegacyPlayerStats[]; stats?: LegacyPlayerStats[]; // Player stats array
} }
/** /**
* Legacy player stats format * Legacy player stats format (nested within match detail)
*
* VERIFIED: Matches actual API response structure
* - Player info nested under 'player' object
* - Rank as object with 'old' and 'new' properties
* - Multi-kills as object with 'duo', 'triple', 'quad', 'ace'
* - Damage as object with 'enemy' and 'team'
* - Flash stats with nested 'duration' and 'total' objects
*/ */
export interface LegacyPlayerStats { export interface LegacyPlayerStats {
team_id: number; team_id: number;
@@ -82,6 +107,16 @@ export interface LegacyPlayerStats {
/** /**
* Transform legacy match list item to new format * Transform legacy match list item to new format
*
* Converts a single match from the API's legacy format to our schema format.
*
* Key transformations:
* - date: Unix timestamp → ISO 8601 string
* - score: [a, b] array → score_team_a, score_team_b fields
* - parsed → demo_parsed (rename)
*
* @param legacy - Match data from API in legacy format
* @returns Match data in schema-compatible format
*/ */
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem { export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
return { return {
@@ -91,21 +126,36 @@ export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListIt
score_team_a: legacy.score[0], score_team_a: legacy.score[0],
score_team_b: legacy.score[1], score_team_b: legacy.score[1],
duration: legacy.duration, duration: legacy.duration,
demo_parsed: legacy.parsed, demo_parsed: legacy.parsed // Rename: parsed → demo_parsed
player_count: 10 // Default to 10 players (5v5)
}; };
} }
/** /**
* Transform legacy matches list response to new format * Transform legacy matches list response to new format
*
* IMPORTANT: The API returns a plain array, NOT an object with properties.
* This function wraps the array and adds pagination metadata that we calculate ourselves.
*
* How pagination works:
* 1. API returns plain array: [match1, match2, ...]
* 2. We request limit + 1 to check if there are more matches
* 3. If we get > limit matches, hasMore = true
* 4. We extract timestamp from last match for next page: matches[length-1].date
*
* @param legacyMatches - Array of matches from API (already requested limit + 1)
* @param hasMore - Whether there are more matches available (calculated by caller)
* @param nextPageTime - Unix timestamp for next page (extracted from last match by caller)
* @returns Wrapped response with pagination metadata
*/ */
export function transformMatchesListResponse( export function transformMatchesListResponse(
legacyMatches: LegacyMatchListItem[] legacyMatches: LegacyMatchListItem[],
hasMore: boolean = false,
nextPageTime?: number
): MatchesListResponse { ): MatchesListResponse {
return { return {
matches: legacyMatches.map(transformMatchListItem), matches: legacyMatches.map(transformMatchListItem),
has_more: false, // Legacy API doesn't provide pagination info has_more: hasMore,
next_page_time: undefined next_page_time: nextPageTime
}; };
} }
@@ -113,6 +163,13 @@ export function transformMatchesListResponse(
* Transform legacy player stats to new format * Transform legacy player stats to new format
*/ */
export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer { export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
// Extract Premier rating from rank object
// API provides rank as { old: number, new: number }
const rankOld =
legacy.rank && typeof legacy.rank.old === 'number' ? (legacy.rank.old as number) : undefined;
const rankNew =
legacy.rank && typeof legacy.rank.new === 'number' ? (legacy.rank.new as number) : undefined;
return { return {
id: legacy.player.steamid64, id: legacy.player.steamid64,
name: legacy.player.name, name: legacy.player.name,
@@ -124,7 +181,9 @@ export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
headshot: legacy.headshot, headshot: legacy.headshot,
mvp: legacy.mvp, mvp: legacy.mvp,
score: legacy.score, score: legacy.score,
kast: 0, // Not provided by legacy API // Premier rating (CS2: 0-30000)
rank_old: rankOld,
rank_new: rankNew,
// Multi-kills: map legacy names to new format // Multi-kills: map legacy names to new format
mk_2: legacy.multi_kills?.duo, mk_2: legacy.multi_kills?.duo,
mk_3: legacy.multi_kills?.triple, mk_3: legacy.multi_kills?.triple,
@@ -157,7 +216,6 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
demo_parsed: legacy.parsed, demo_parsed: legacy.parsed,
vac_present: legacy.vac, vac_present: legacy.vac,
gameban_present: legacy.game_ban, gameban_present: legacy.game_ban,
tick_rate: 64, // Default to 64, not provided by API
players: legacy.stats?.map(transformPlayerStats) players: legacy.stats?.map(transformPlayerStats)
}; };
} }
@@ -216,14 +274,25 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
id: legacy.steamid64, id: legacy.steamid64,
name: legacy.name, name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`, avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
steam_updated: new Date().toISOString(), // Not provided by API
vac_count: legacy.vac ? 1 : 0, vac_count: legacy.vac ? 1 : 0,
vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null, vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null,
game_ban_count: legacy.game_ban ? 1 : 0, game_ban_count: legacy.game_ban ? 1 : 0,
game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null, game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null,
tracked: legacy.tracked,
wins: legacy.match_stats?.win, wins: legacy.match_stats?.win,
losses: legacy.match_stats?.loss, losses: legacy.match_stats?.loss,
matches: legacy.matches?.map((match) => ({ matches: legacy.matches?.map((match) => {
// Extract Premier rating from rank object
const rankOld =
match.stats.rank && typeof match.stats.rank.old === 'number'
? (match.stats.rank.old as number)
: undefined;
const rankNew =
match.stats.rank && typeof match.stats.rank.new === 'number'
? (match.stats.rank.new as number)
: undefined;
return {
match_id: match.match_id, match_id: match.match_id,
map: match.map || 'unknown', map: match.map || 'unknown',
date: new Date(match.date * 1000).toISOString(), date: new Date(match.date * 1000).toISOString(),
@@ -235,7 +304,6 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
demo_parsed: match.parsed, demo_parsed: match.parsed,
vac_present: match.vac, vac_present: match.vac,
gameban_present: match.game_ban, gameban_present: match.game_ban,
tick_rate: 64, // Not provided by API
stats: { stats: {
id: legacy.steamid64, id: legacy.steamid64,
name: legacy.name, name: legacy.name,
@@ -250,12 +318,15 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
headshot: match.stats.headshot, headshot: match.stats.headshot,
mvp: match.stats.mvp, mvp: match.stats.mvp,
score: match.stats.score, score: match.stats.score,
kast: 0, // Premier rating (CS2: 0-30000)
rank_old: rankOld,
rank_new: rankNew,
mk_2: match.stats.multi_kills?.duo, mk_2: match.stats.multi_kills?.duo,
mk_3: match.stats.multi_kills?.triple, mk_3: match.stats.multi_kills?.triple,
mk_4: match.stats.multi_kills?.quad, mk_4: match.stats.multi_kills?.quad,
mk_5: match.stats.multi_kills?.ace mk_5: match.stats.multi_kills?.ace
} }
})) };
})
}; };
} }

View File

@@ -0,0 +1,268 @@
<script lang="ts">
import { Bomb, Shield, Clock, Target, Skull } from 'lucide-svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Card from '$lib/components/ui/Card.svelte';
import type { RoundDetail } from '$lib/types/RoundStats';
let { rounds }: { rounds: RoundDetail[] } = $props();
// State for hover/click details
let selectedRound = $state<number | null>(null);
// Helper to get win reason icon
const getWinReasonIcon = (reason: string) => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return Bomb;
if (reasonLower.includes('defused')) return Shield;
if (reasonLower.includes('elimination')) return Skull;
if (reasonLower.includes('time')) return Clock;
if (reasonLower.includes('target')) return Target;
return null;
};
// Helper to get win reason display text
const getWinReasonText = (reason: string) => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'Bomb Exploded';
if (reasonLower.includes('defused')) return 'Bomb Defused';
if (reasonLower.includes('elimination')) return 'Elimination';
if (reasonLower.includes('time')) return 'Time Expired';
if (reasonLower.includes('target')) return 'Target Saved';
return reason;
};
// Helper to format win reason for badge
const formatWinReason = (reason: string): string => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'BOOM';
if (reasonLower.includes('defused')) return 'DEF';
if (reasonLower.includes('elimination')) return 'ELIM';
if (reasonLower.includes('time')) return 'TIME';
if (reasonLower.includes('target')) return 'SAVE';
return 'WIN';
};
// Toggle round selection
const toggleRound = (roundNum: number) => {
selectedRound = selectedRound === roundNum ? null : roundNum;
};
// Calculate team scores up to a given round
const getScoreAtRound = (roundNumber: number): { teamA: number; teamB: number } => {
let teamA = 0;
let teamB = 0;
for (let i = 0; i < roundNumber && i < rounds.length; i++) {
const round = rounds[i];
if (round && round.winner === 2) teamA++;
else if (round && round.winner === 3) teamB++;
}
return { teamA, teamB };
};
// Get selected round details
const selectedRoundData = $derived(
selectedRound ? rounds.find((r) => r.round === selectedRound) : null
);
</script>
<Card padding="lg">
<div class="mb-6">
<h2 class="text-2xl font-bold text-base-content">Round Timeline</h2>
<p class="mt-2 text-sm text-base-content/60">
Click on a round to see detailed information. T = Terrorists, CT = Counter-Terrorists
</p>
</div>
<!-- Timeline -->
<div class="relative">
<!-- Horizontal scroll container for mobile -->
<div class="overflow-x-auto pb-4">
<div class="min-w-max">
<!-- Round markers -->
<div class="flex gap-1">
{#each rounds as round (round.round)}
{@const isWinner2 = round.winner === 2}
{@const isWinner3 = round.winner === 3}
{@const isSelected = selectedRound === round.round}
{@const Icon = getWinReasonIcon(round.win_reason)}
{@const scoreAtRound = getScoreAtRound(round.round)}
<button
class="group relative flex flex-col items-center transition-all hover:scale-110"
style="width: 60px;"
onclick={() => toggleRound(round.round)}
aria-label={`Round ${round.round}`}
>
<!-- Round number -->
<div
class="mb-2 text-xs font-semibold transition-colors"
class:text-primary={isSelected}
class:opacity-60={!isSelected}
>
{round.round}
</div>
<!-- Round indicator circle -->
<div
class="relative flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all"
class:border-terrorist={isWinner2}
class:bg-terrorist={isWinner2}
class:bg-opacity-20={isWinner2 || isWinner3}
class:border-ct={isWinner3}
class:bg-ct={isWinner3}
class:ring-4={isSelected}
class:ring-primary={isSelected}
class:ring-opacity-30={isSelected}
class:scale-110={isSelected}
>
<!-- Win reason icon or T/CT badge -->
{#if Icon}
<Icon class={`h-5 w-5 ${isWinner2 ? 'text-terrorist' : 'text-ct'}`} />
{:else}
<span
class="text-sm font-bold"
class:text-terrorist={isWinner2}
class:text-ct={isWinner3}
>
{isWinner2 ? 'T' : 'CT'}
</span>
{/if}
<!-- Small win reason badge on bottom -->
<div
class="absolute -bottom-1 rounded px-1 py-0.5 text-[9px] font-bold leading-none"
class:bg-terrorist={isWinner2}
class:bg-ct={isWinner3}
class:text-white={true}
>
{formatWinReason(round.win_reason)}
</div>
</div>
<!-- Connecting line to next round -->
{#if round.round < rounds.length}
<div
class="absolute left-[60px] top-[34px] h-0.5 w-[calc(100%-60px)] bg-base-300"
></div>
{/if}
<!-- Hover tooltip -->
<div
class="pointer-events-none absolute top-full z-10 mt-2 hidden w-48 rounded-lg bg-base-100 p-3 text-left shadow-xl ring-1 ring-base-300 group-hover:block"
>
<div class="text-xs font-semibold text-base-content">
Round {round.round}
</div>
<div class="mt-1 text-xs text-base-content/80">
Winner:
<span
class="font-bold"
class:text-terrorist={isWinner2}
class:text-ct={isWinner3}
>
{isWinner2 ? 'Terrorists' : 'Counter-Terrorists'}
</span>
</div>
<div class="mt-1 text-xs text-base-content/60">
{getWinReasonText(round.win_reason)}
</div>
<div class="mt-2 text-xs text-base-content/60">
Score: {scoreAtRound.teamA} - {scoreAtRound.teamB}
</div>
</div>
</button>
{/each}
</div>
<!-- Half marker (round 13 for MR12) -->
{#if rounds.length > 12}
<div class="relative mt-2 flex gap-1">
<div class="ml-[calc(60px*12-30px)] w-[60px] text-center">
<Badge variant="info" size="sm">Halftime</Badge>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Selected Round Details -->
{#if selectedRoundData}
<div class="mt-6 border-t border-base-300 pt-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold text-base-content">
Round {selectedRoundData.round} Details
</h3>
<button
class="btn btn-ghost btn-sm"
onclick={() => (selectedRound = null)}
aria-label="Close details"
>
Close
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="text-sm text-base-content/60">Winner</div>
<div
class="text-lg font-bold"
class:text-terrorist={selectedRoundData.winner === 2}
class:text-ct={selectedRoundData.winner === 3}
>
{selectedRoundData.winner === 2 ? 'Terrorists' : 'Counter-Terrorists'}
</div>
</div>
<div>
<div class="text-sm text-base-content/60">Win Reason</div>
<div class="text-lg font-semibold text-base-content">
{getWinReasonText(selectedRoundData.win_reason)}
</div>
</div>
</div>
<!-- Player stats for the round if available -->
{#if selectedRoundData.players && selectedRoundData.players.length > 0}
<div class="mt-4">
<h4 class="mb-2 text-sm font-semibold text-base-content">Round Economy</h4>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="border-base-300">
<th>Player</th>
<th>Bank</th>
<th>Equipment</th>
<th>Spent</th>
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
<th>Kills</th>
{/if}
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
<th>Damage</th>
{/if}
</tr>
</thead>
<tbody>
{#each selectedRoundData.players as player}
<tr class="border-base-300">
<td class="font-medium"
>Player {player.player_id || player.match_player_id || '?'}</td
>
<td class="font-mono text-success">${player.bank.toLocaleString()}</td>
<td class="font-mono">${player.equipment.toLocaleString()}</td>
<td class="font-mono text-error">${player.spent.toLocaleString()}</td>
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
<td class="font-mono">{player.kills_in_round || 0}</td>
{/if}
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
<td class="font-mono">{player.damage_in_round || 0}</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
</Card>

View File

@@ -44,12 +44,7 @@
class?: string; class?: string;
} }
let { let { data, options = {}, height = 300, class: className = '' }: Props = $props();
data,
options = {},
height = 300,
class: className = ''
}: Props = $props();
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
let chart: Chart<'line'> | null = null; let chart: Chart<'line'> | null = null;

View File

@@ -17,11 +17,10 @@
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<div class="flex h-16 items-center justify-between"> <div class="flex h-16 items-center justify-between">
<!-- Logo --> <!-- Logo -->
<a <a href="/" class="transition-transform hover:scale-105" aria-label="CS2.WTF Home">
href="/" <h1 class="text-2xl font-bold">
class="flex items-center gap-2 text-2xl font-bold transition-transform hover:scale-105"
>
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span> <span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
</h1>
</a> </a>
<!-- Desktop Navigation --> <!-- Desktop Navigation -->

View File

@@ -92,7 +92,7 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each $search.recentSearches as recent} {#each $search.recentSearches as recent}
<button <button
class="badge badge-lg badge-outline gap-2 hover:badge-primary" class="badge badge-outline badge-lg gap-2 hover:badge-primary"
onclick={() => handleRecentClick(recent)} onclick={() => handleRecentClick(recent)}
> >
<Search class="h-3 w-3" /> <Search class="h-3 w-3" />

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Sun, Moon, Monitor } from 'lucide-svelte'; import { Moon, Sun, Monitor } from 'lucide-svelte';
import { preferences } from '$lib/stores'; import { preferences } from '$lib/stores';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -10,9 +10,8 @@
{ value: 'auto', label: 'Auto', icon: Monitor } { value: 'auto', label: 'Auto', icon: Monitor }
] as const; ] as const;
const currentIcon = $derived( // Get current theme data
themes.find((t) => t.value === $preferences.theme)?.icon || Monitor const currentTheme = $derived(themes.find((t) => t.value === $preferences.theme) || themes[2]);
);
const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => { const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
if (!browser) return; if (!browser) return;
@@ -50,19 +49,19 @@
<!-- Theme Toggle Dropdown --> <!-- Theme Toggle Dropdown -->
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle" aria-label="Theme"> <button tabindex="0" class="btn btn-circle btn-ghost" aria-label="Theme">
<svelte:component this={currentIcon} class="h-5 w-5" /> <currentTheme.icon class="h-5 w-5" />
</button> </button>
<ul class="menu dropdown-content z-[1] mt-3 w-52 rounded-box bg-base-100 p-2 shadow-lg"> <ul class="menu dropdown-content z-[1] mt-3 w-52 rounded-box bg-base-100 p-2 shadow-lg">
{#each themes as { value, label, icon }} {#each themes as theme}
<li> <li>
<button <button
class:active={$preferences.theme === value} class:active={$preferences.theme === theme.value}
onclick={() => handleThemeChange(value)} onclick={() => handleThemeChange(theme.value)}
> >
<svelte:component this={icon} class="h-4 w-4" /> <theme.icon class="h-4 w-4" />
{label} {theme.label}
{#if value === 'auto'} {#if theme.value === 'auto'}
<span class="text-xs text-base-content/60">(System)</span> <span class="text-xs text-base-content/60">(System)</span>
{/if} {/if}
</button> </button>

View File

@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte'; import Badge from '$lib/components/ui/Badge.svelte';
import type { MatchListItem } from '$lib/types'; import type { MatchListItem } from '$lib/types';
import { storeMatchesState } from '$lib/utils/navigation';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
interface Props { interface Props {
match: MatchListItem; match: MatchListItem;
loadedCount?: number;
} }
let { match }: Props = $props(); let { match, loadedCount = 0 }: Props = $props();
const formattedDate = new Date(match.date).toLocaleString('en-US', { const formattedDate = new Date(match.date).toLocaleString('en-US', {
month: 'short', month: 'short',
@@ -15,27 +18,54 @@
minute: '2-digit' minute: '2-digit'
}); });
const mapName = match.map.replace('de_', '').toUpperCase(); const mapName = formatMapName(match.map);
const mapBg = getMapBackground(match.map);
function handleClick() {
// Store navigation state before navigating
storeMatchesState(match.match_id, loadedCount);
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.src = '/images/map_screenshots/default.webp';
}
</script> </script>
<a href={`/match/${match.match_id}`} class="block transition-transform hover:scale-[1.02]"> <a
href={`/match/${match.match_id}`}
class="block transition-transform hover:scale-[1.02]"
data-match-id={match.match_id}
onclick={handleClick}
>
<div <div
class="overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-shadow hover:shadow-xl" class="overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-shadow hover:shadow-xl"
> >
<!-- Map Header --> <!-- Map Header with Background Image -->
<div class="relative h-32 bg-gradient-to-br from-base-300 to-base-200"> <div class="relative h-32 overflow-hidden">
<div class="absolute inset-0 flex items-center justify-center"> <!-- Background Image -->
<span class="text-5xl font-bold text-base-content/10">{mapName}</span> <img
</div> src={mapBg}
<div class="absolute bottom-3 left-3"> alt={mapName}
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
onerror={handleImageError}
/>
<!-- Overlay for better text contrast -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/20"></div>
<!-- Content -->
<div class="relative flex h-full items-end justify-between p-3">
<div class="flex flex-col gap-1">
{#if match.map}
<Badge variant="default">{match.map}</Badge> <Badge variant="default">{match.map}</Badge>
{/if}
<span class="text-lg font-bold text-white drop-shadow-lg">{mapName}</span>
</div> </div>
{#if match.demo_parsed} {#if match.demo_parsed}
<div class="absolute right-3 top-3">
<Badge variant="success" size="sm">Parsed</Badge> <Badge variant="success" size="sm">Parsed</Badge>
</div>
{/if} {/if}
</div> </div>
</div>
<!-- Match Info --> <!-- Match Info -->
<div class="p-4"> <div class="p-4">

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
import { matchesAPI } from '$lib/api/matches';
import { showToast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
let shareCode = $state('');
let isLoading = $state(false);
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
let statusMessage = $state('');
let parsedMatchId = $state('');
// Validate share code format
function isValidShareCode(code: string): boolean {
// Format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
const pattern = /^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/;
return pattern.test(code.toUpperCase());
}
async function handleSubmit() {
const trimmedCode = shareCode.trim().toUpperCase();
if (!trimmedCode) {
showToast('Please enter a share code', 'error');
return;
}
if (!isValidShareCode(trimmedCode)) {
showToast('Invalid share code format', 'error');
parseStatus = 'error';
statusMessage = 'Share code must be in format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX';
return;
}
isLoading = true;
parseStatus = 'parsing';
statusMessage = 'Submitting share code for parsing...';
try {
const response = await matchesAPI.parseMatch(trimmedCode);
if (response.match_id) {
parsedMatchId = response.match_id;
parseStatus = 'success';
statusMessage =
response.message ||
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
showToast('Match submitted for parsing!', 'success');
// Wait a moment then redirect to the match page
setTimeout(() => {
goto(`/match/${response.match_id}`);
}, 2000);
} else {
parseStatus = 'error';
statusMessage = response.message || 'Failed to parse share code';
showToast(statusMessage, 'error');
}
} catch (error: unknown) {
parseStatus = 'error';
statusMessage = error instanceof Error ? error.message : 'Failed to parse share code';
showToast(statusMessage, 'error');
} finally {
isLoading = false;
}
}
function resetForm() {
shareCode = '';
parseStatus = 'idle';
statusMessage = '';
parsedMatchId = '';
}
</script>
<div class="space-y-4">
<!-- Input Section -->
<div class="form-control">
<label class="label" for="shareCode">
<span class="label-text font-medium">Submit Match Share Code</span>
</label>
<div class="flex gap-2">
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered flex-1"
bind:value={shareCode}
disabled={isLoading}
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
/>
<button
class="btn btn-primary"
onclick={handleSubmit}
disabled={isLoading || !shareCode.trim()}
>
{#if isLoading}
<Loader2 class="h-5 w-5 animate-spin" />
{:else}
<Upload class="h-5 w-5" />
{/if}
Parse
</button>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
Submit a CS2 match share code to add it to the database
</span>
</div>
</div>
<!-- Status Messages -->
{#if parseStatus !== 'idle'}
<div
class="alert {parseStatus === 'success'
? 'alert-success'
: parseStatus === 'error'
? 'alert-error'
: 'alert-info'}"
>
{#if parseStatus === 'parsing'}
<Loader2 class="h-6 w-6 shrink-0 animate-spin stroke-current" />
{:else if parseStatus === 'success'}
<Check class="h-6 w-6 shrink-0 stroke-current" />
{:else}
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
{/if}
<div class="flex-1">
<p>{statusMessage}</p>
{#if parseStatus === 'success' && parsedMatchId}
<p class="mt-1 text-sm">Redirecting to match page...</p>
{/if}
</div>
{#if parseStatus !== 'parsing'}
<button class="btn btn-ghost btn-sm" onclick={resetForm}>Dismiss</button>
{/if}
</div>
{/if}
<!-- Help Text -->
<div class="text-sm text-base-content/70">
<p class="mb-2 font-medium">How to get your match share code:</p>
<ol class="list-inside list-decimal space-y-1">
<li>Open CS2 and navigate to your Matches tab</li>
<li>Click on a match you want to analyze</li>
<li>Click the "Copy Share Link" button</li>
<li>Paste the share code here</li>
</ol>
<p class="mt-2 text-xs">
Note: Demo parsing can take 1-5 minutes depending on match length. You'll be able to view
basic match info immediately, but detailed statistics will be available after parsing
completes.
</p>
</div>
</div>

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import { playersAPI } from '$lib/api/players';
import { showToast } from '$lib/stores/toast';
interface Props {
playerId: string;
playerName: string;
isTracked: boolean;
isOpen: boolean;
}
let { playerId, playerName, isTracked, isOpen = $bindable() }: Props = $props();
const dispatch = createEventDispatcher();
let authCode = $state('');
let shareCode = $state('');
let isLoading = $state(false);
let error = $state('');
async function handleTrack() {
if (!authCode.trim()) {
error = 'Auth code is required';
return;
}
isLoading = true;
error = '';
try {
await playersAPI.trackPlayer(playerId, authCode, shareCode || undefined);
showToast('Player tracking activated successfully!', 'success');
isOpen = false;
dispatch('tracked');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to track player';
showToast(error, 'error');
} finally {
isLoading = false;
}
}
async function handleUntrack() {
if (!authCode.trim()) {
error = 'Auth code is required to untrack';
return;
}
isLoading = true;
error = '';
try {
await playersAPI.untrackPlayer(playerId, authCode);
showToast('Player tracking removed successfully', 'success');
isOpen = false;
dispatch('untracked');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to untrack player';
showToast(error, 'error');
} finally {
isLoading = false;
}
}
function handleClose() {
isOpen = false;
authCode = '';
shareCode = '';
error = '';
}
</script>
<Modal bind:isOpen onClose={handleClose} title={isTracked ? 'Untrack Player' : 'Track Player'}>
<div class="space-y-4">
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div class="text-sm">
{#if isTracked}
<p>Remove <strong>{playerName}</strong> from automatic match tracking.</p>
{:else}
<p>
Add <strong>{playerName}</strong> to the tracking system to automatically fetch new matches.
</p>
{/if}
</div>
</div>
<!-- Auth Code Input -->
<div class="form-control">
<label class="label" for="authCode">
<span class="label-text font-medium">Authentication Code *</span>
</label>
<input
id="authCode"
type="text"
placeholder="Enter your auth code"
class="input input-bordered w-full"
bind:value={authCode}
disabled={isLoading}
required
/>
<div class="label">
<span class="label-text-alt text-base-content/60">
Required to verify ownership of this Steam account
</span>
</div>
</div>
<!-- Share Code Input (only for tracking) -->
{#if !isTracked}
<div class="form-control">
<label class="label" for="shareCode">
<span class="label-text font-medium">Share Code (Optional)</span>
</label>
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered w-full"
bind:value={shareCode}
disabled={isLoading}
/>
<div class="label">
<span class="label-text-alt text-base-content/60">
Optional: Provide a share code if you have no matches yet
</span>
</div>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Help Text -->
<div class="text-sm text-base-content/70">
<p class="mb-2 font-medium">How to get your authentication code:</p>
<ol class="list-inside list-decimal space-y-1">
<li>Open CS2 and go to Settings → Game</li>
<li>Enable the Developer Console</li>
<li>Press <kbd class="kbd kbd-sm">~</kbd> to open the console</li>
<li>Type: <code class="rounded bg-base-300 px-1">status</code></li>
<li>Copy the code shown next to "Account:"</li>
</ol>
</div>
</div>
{#snippet actions()}
<button class="btn" onclick={handleClose} disabled={isLoading}>Cancel</button>
{#if isTracked}
<button class="btn btn-error" onclick={handleUntrack} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Untrack Player
</button>
{:else}
<button class="btn btn-primary" onclick={handleTrack} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Track Player
</button>
{/if}
{/snippet}
</Modal>

View File

@@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import { X } from 'lucide-svelte'; import { X } from 'lucide-svelte';
import { fly, fade } from 'svelte/transition'; import { fly, fade } from 'svelte/transition';
import type { Snippet } from 'svelte';
interface Props { interface Props {
open?: boolean; open?: boolean;
title?: string; title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl'; size?: 'sm' | 'md' | 'lg' | 'xl';
onClose?: () => void; onClose?: () => void;
children?: any; children?: Snippet;
actions?: Snippet;
} }
let { open = $bindable(false), title, size = 'md', onClose, children }: Props = $props(); let { open = $bindable(false), title, size = 'md', onClose, children, actions }: Props = $props();
const sizeClasses = { const sizeClasses = {
sm: 'max-w-md', sm: 'max-w-md',
@@ -44,9 +46,15 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
onclick={handleBackdropClick} onclick={handleBackdropClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
handleClose();
}
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined} aria-labelledby={title ? 'modal-title' : undefined}
tabindex="-1"
> >
<!-- Backdrop --> <!-- Backdrop -->
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div> <div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
@@ -82,6 +90,13 @@
<div class="p-6"> <div class="p-6">
{@render children?.()} {@render children?.()}
</div> </div>
<!-- Actions -->
{#if actions}
<div class="flex justify-end gap-2 border-t border-base-300 p-6">
{@render actions()}
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
interface Props {
rating: number | undefined | null;
oldRating?: number | undefined | null;
size?: 'sm' | 'md' | 'lg';
showTier?: boolean;
showChange?: boolean;
showIcon?: boolean;
class?: string;
}
let {
rating,
oldRating,
size = 'md',
showTier = false,
showChange = false,
showIcon = true,
class: className = ''
}: Props = $props();
const tierInfo = $derived(formatPremierRating(rating));
const changeInfo = $derived(showChange ? getPremierRatingChange(oldRating, rating) : null);
const baseClasses = 'inline-flex items-center gap-1.5 border rounded-lg font-medium';
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base'
};
const iconSizes = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
};
const classes = $derived(
`${baseClasses} ${tierInfo.cssClasses} ${sizeClasses[size]} ${className}`
);
</script>
<div class={classes}>
{#if showIcon}
<Trophy class={iconSizes[size]} />
{/if}
<span>{tierInfo.formatted}</span>
{#if showTier}
<span class="opacity-75">({tierInfo.tier})</span>
{/if}
{#if showChange && changeInfo}
<span class="ml-1 flex items-center gap-0.5 {changeInfo.cssClasses}">
{#if changeInfo.isPositive}
<TrendingUp class={iconSizes[size]} />
{:else if changeInfo.change < 0}
<TrendingDown class={iconSizes[size]} />
{/if}
{changeInfo.display}
</span>
{/if}
</div>

View File

@@ -43,8 +43,10 @@
} }
}; };
const variantClass = variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : ''; const variantClass =
const sizeClass = size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : ''; variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
const sizeClass =
size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
</script> </script>
<div role="tablist" class="tabs {variantClass} {sizeClass} {className}"> <div role="tablist" class="tabs {variantClass} {sizeClass} {className}">

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
interface Props { interface Props {
text: string; text: string;
position?: 'top' | 'bottom' | 'left' | 'right'; position?: 'top' | 'bottom' | 'left' | 'right';
children?: any; children?: Snippet;
} }
let { text, position = 'top', children }: Props = $props(); let { text, position = 'top', children }: Props = $props();

View File

@@ -19,7 +19,7 @@ export const matchPlayerSchema = z.object({
headshot: z.number().int().nonnegative(), headshot: z.number().int().nonnegative(),
mvp: z.number().int().nonnegative(), mvp: z.number().int().nonnegative(),
score: z.number().int().nonnegative(), score: z.number().int().nonnegative(),
kast: z.number().int().min(0).max(100), kast: z.number().int().min(0).max(100).optional(),
// Rank (CS2 Premier rating: 0-30000) // Rank (CS2 Premier rating: 0-30000)
rank_old: z.number().int().min(0).max(30000).optional(), rank_old: z.number().int().min(0).max(30000).optional(),
@@ -74,7 +74,7 @@ export const matchSchema = z.object({
demo_parsed: z.boolean(), demo_parsed: z.boolean(),
vac_present: z.boolean(), vac_present: z.boolean(),
gameban_present: z.boolean(), gameban_present: z.boolean(),
tick_rate: z.number().positive(), tick_rate: z.number().positive().optional(),
players: z.array(matchPlayerSchema).optional() players: z.array(matchPlayerSchema).optional()
}); });
@@ -87,7 +87,7 @@ export const matchListItemSchema = z.object({
score_team_b: z.number().int().nonnegative(), score_team_b: z.number().int().nonnegative(),
duration: z.number().int().positive(), duration: z.number().int().positive(),
demo_parsed: z.boolean(), demo_parsed: z.boolean(),
player_count: z.number().int().min(2).max(10) player_count: z.number().int().min(2).max(10).optional()
}); });
/** Parser functions for safe data validation */ /** Parser functions for safe data validation */

View File

@@ -12,7 +12,7 @@ export const playerSchema = z.object({
avatar: z.string().url(), avatar: z.string().url(),
vanity_url: z.string().optional(), vanity_url: z.string().optional(),
vanity_url_real: z.string().optional(), vanity_url_real: z.string().optional(),
steam_updated: z.string().datetime(), steam_updated: z.string().datetime().optional(),
profile_created: z.string().datetime().optional(), profile_created: z.string().datetime().optional(),
wins: z.number().int().nonnegative().optional(), wins: z.number().int().nonnegative().optional(),
losses: z.number().int().nonnegative().optional(), losses: z.number().int().nonnegative().optional(),
@@ -24,6 +24,7 @@ export const playerSchema = z.object({
game_ban_count: z.number().int().nonnegative().optional(), game_ban_count: z.number().int().nonnegative().optional(),
game_ban_date: z.string().datetime().nullable().optional(), game_ban_date: z.string().datetime().nullable().optional(),
oldest_sharecode_seen: z.string().optional(), oldest_sharecode_seen: z.string().optional(),
tracked: z.boolean().optional(),
matches: z matches: z
.array( .array(
matchSchema.extend({ matchSchema.extend({

View File

@@ -39,8 +39,8 @@ export interface Match {
/** Whether any player has a game ban */ /** Whether any player has a game ban */
gameban_present: boolean; gameban_present: boolean;
/** Server tick rate (64 or 128) */ /** Server tick rate (64 or 128) - optional, not always provided by API */
tick_rate: number; tick_rate?: number;
/** Array of player statistics (optional, included in detailed match view) */ /** Array of player statistics (optional, included in detailed match view) */
players?: MatchPlayer[]; players?: MatchPlayer[];
@@ -57,7 +57,7 @@ export interface MatchListItem {
score_team_b: number; score_team_b: number;
duration: number; duration: number;
demo_parsed: boolean; demo_parsed: boolean;
player_count: number; player_count?: number;
} }
/** /**
@@ -91,8 +91,14 @@ export interface MatchPlayer {
/** In-game score */ /** In-game score */
score: number; score: number;
/** KAST percentage (0-100): Kill/Assist/Survive/Trade */ /** KAST percentage (0-100): Kill/Assist/Survive/Trade - optional, not always provided by API */
kast: number; kast?: number;
/** Average Damage per Round */
adr?: number;
/** Headshot percentage */
hs_percent?: number;
// Rank tracking (CS2 Premier rating: 0-30000) // Rank tracking (CS2 Premier rating: 0-30000)
rank_old?: number; rank_old?: number;

View File

@@ -20,8 +20,8 @@ export interface Player {
/** Actual vanity URL (may differ from vanity_url) */ /** Actual vanity URL (may differ from vanity_url) */
vanity_url_real?: string; vanity_url_real?: string;
/** Last time Steam profile was updated (ISO 8601) */ /** Last time Steam profile was updated (ISO 8601) - optional, not always provided by API */
steam_updated: string; steam_updated?: string;
/** Steam account creation date (ISO 8601) */ /** Steam account creation date (ISO 8601) */
profile_created?: string; profile_created?: string;
@@ -53,6 +53,9 @@ export interface Player {
/** Oldest match share code seen for this player */ /** Oldest match share code seen for this player */
oldest_sharecode_seen?: string; oldest_sharecode_seen?: string;
/** Whether this player is being tracked for automatic match updates */
tracked?: boolean;
/** Recent matches with player statistics */ /** Recent matches with player statistics */
matches?: PlayerMatch[]; matches?: PlayerMatch[];
} }

154
src/lib/utils/export.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Export utilities for match data
* Provides CSV and JSON export functionality for match listings
*/
import type { MatchListItem } from '$lib/types';
import { formatDuration } from './formatters';
/**
* Format date to readable string (YYYY-MM-DD HH:MM)
* @param dateString - ISO date string
* @returns Formatted date string
*/
function formatDateForExport(dateString: string): string {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
/**
* Convert matches array to CSV format
* @param matches - Array of match items to export
* @returns CSV string
*/
function matchesToCSV(matches: MatchListItem[]): string {
// CSV Headers
const headers = [
'Match ID',
'Date',
'Map',
'Score Team A',
'Score Team B',
'Duration',
'Demo Parsed',
'Player Count'
];
// CSV rows
const rows = matches.map((match) => {
return [
match.match_id,
formatDateForExport(match.date),
match.map,
match.score_team_a.toString(),
match.score_team_b.toString(),
formatDuration(match.duration),
match.demo_parsed ? 'Yes' : 'No',
match.player_count?.toString() || '-'
];
});
// Combine headers and rows
const csvContent = [
headers.join(','),
...rows.map((row) =>
row
.map((cell) => {
// Escape cells containing commas or quotes
if (cell.includes(',') || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
})
.join(',')
)
].join('\n');
return csvContent;
}
/**
* Convert matches array to formatted JSON
* @param matches - Array of match items to export
* @returns Formatted JSON string
*/
function matchesToJSON(matches: MatchListItem[]): string {
// Create clean export format
const exportData = {
export_date: new Date().toISOString(),
total_matches: matches.length,
matches: matches.map((match) => ({
match_id: match.match_id,
date: formatDateForExport(match.date),
map: match.map,
score: `${match.score_team_a} - ${match.score_team_b}`,
score_team_a: match.score_team_a,
score_team_b: match.score_team_b,
duration: formatDuration(match.duration),
duration_seconds: match.duration,
demo_parsed: match.demo_parsed,
player_count: match.player_count
}))
};
return JSON.stringify(exportData, null, 2);
}
/**
* Trigger browser download for a file
* @param content - File content
* @param filename - Name of file to download
* @param mimeType - MIME type of file
*/
function triggerDownload(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Export matches to CSV file
* Generates and downloads a CSV file with match data
* @param matches - Array of match items to export
* @throws Error if matches array is empty
*/
export function exportMatchesToCSV(matches: MatchListItem[]): void {
if (!matches || matches.length === 0) {
throw new Error('No matches to export');
}
const csvContent = matchesToCSV(matches);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `cs2wtf-matches-${timestamp}.csv`;
triggerDownload(csvContent, filename, 'text/csv;charset=utf-8;');
}
/**
* Export matches to JSON file
* Generates and downloads a JSON file with match data
* @param matches - Array of match items to export
* @throws Error if matches array is empty
*/
export function exportMatchesToJSON(matches: MatchListItem[]): void {
if (!matches || matches.length === 0) {
throw new Error('No matches to export');
}
const jsonContent = matchesToJSON(matches);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `cs2wtf-matches-${timestamp}.json`;
triggerDownload(jsonContent, filename, 'application/json;charset=utf-8;');
}

196
src/lib/utils/formatters.ts Normal file
View File

@@ -0,0 +1,196 @@
/**
* Formatting utilities for CS2 data
*/
/**
* Premier rating tier information
*/
export interface PremierRatingTier {
/** Formatted rating with comma separator (e.g., "15,000") */
formatted: string;
/** Hex color for this tier */
color: string;
/** Tier name */
tier: string;
/** Tailwind CSS classes for styling */
cssClasses: string;
}
/**
* Format Premier rating and return tier information
* CS2 Premier rating range: 0-30000
* Color tiers: <5000 (gray), 5000-9999 (blue), 10000-14999 (purple),
* 15000-19999 (pink), 20000-24999 (red), 25000+ (gold)
*
* @param rating - Premier rating (0-30000)
* @returns Tier information with formatted rating and colors
*/
export function formatPremierRating(rating: number | undefined | null): PremierRatingTier {
// Default for unranked/unknown
if (rating === undefined || rating === null || rating === 0) {
return {
formatted: 'Unranked',
color: '#9CA3AF',
tier: 'Unranked',
cssClasses: 'bg-base-300/50 border-base-content/20 text-base-content/60'
};
}
// Ensure rating is within valid range
const validRating = Math.max(0, Math.min(30000, rating));
const formatted = validRating.toLocaleString('en-US');
// Determine tier based on rating
if (validRating >= 25000) {
return {
formatted,
color: '#EAB308',
tier: 'Legendary',
cssClasses:
'bg-gradient-to-br from-yellow-500/20 to-amber-600/20 border-yellow-500/40 text-yellow-400 font-bold shadow-lg shadow-yellow-500/20'
};
} else if (validRating >= 20000) {
return {
formatted,
color: '#EF4444',
tier: 'Elite',
cssClasses:
'bg-gradient-to-br from-red-500/20 to-rose-600/20 border-red-500/40 text-red-400 font-semibold shadow-md shadow-red-500/10'
};
} else if (validRating >= 15000) {
return {
formatted,
color: '#EC4899',
tier: 'Expert',
cssClasses:
'bg-gradient-to-br from-pink-500/20 to-fuchsia-500/20 border-pink-500/40 text-pink-400 font-semibold shadow-md shadow-pink-500/10'
};
} else if (validRating >= 10000) {
return {
formatted,
color: '#A855F7',
tier: 'Advanced',
cssClasses:
'bg-gradient-to-br from-purple-500/20 to-violet-600/20 border-purple-500/40 text-purple-400 font-medium'
};
} else if (validRating >= 5000) {
return {
formatted,
color: '#3B82F6',
tier: 'Intermediate',
cssClasses:
'bg-gradient-to-br from-blue-500/20 to-indigo-500/20 border-blue-500/40 text-blue-400'
};
} else {
return {
formatted,
color: '#9CA3AF',
tier: 'Beginner',
cssClasses: 'bg-gray-500/10 border-gray-500/30 text-gray-400'
};
}
}
/**
* Get Tailwind CSS classes for Premier rating badge
* @param rating - Premier rating (0-30000)
* @returns Tailwind CSS class string
*/
export function getPremierRatingClass(rating: number | undefined | null): string {
return formatPremierRating(rating).cssClasses;
}
/**
* Calculate rating change display
* @param oldRating - Previous rating
* @param newRating - New rating
* @returns Object with change amount and display string
*/
export function getPremierRatingChange(
oldRating: number | undefined | null,
newRating: number | undefined | null
): {
change: number;
display: string;
isPositive: boolean;
cssClasses: string;
} | null {
if (
oldRating === undefined ||
oldRating === null ||
newRating === undefined ||
newRating === null
) {
return null;
}
const change = newRating - oldRating;
if (change === 0) {
return {
change: 0,
display: '±0',
isPositive: false,
cssClasses: 'text-base-content/60'
};
}
const isPositive = change > 0;
const display = isPositive ? `+${change}` : change.toString();
return {
change,
display,
isPositive,
cssClasses: isPositive ? 'text-success font-semibold' : 'text-error font-semibold'
};
}
/**
* Format K/D ratio
* @param kills - Number of kills
* @param deaths - Number of deaths
* @returns Formatted K/D ratio
*/
export function formatKD(kills: number, deaths: number): string {
if (deaths === 0) {
return kills.toFixed(2);
}
return (kills / deaths).toFixed(2);
}
/**
* Format percentage
* @param value - Percentage value (0-100)
* @param decimals - Number of decimal places (default: 1)
* @returns Formatted percentage string
*/
export function formatPercent(value: number | undefined | null, decimals = 1): string {
if (value === undefined || value === null) {
return '0.0%';
}
return `${value.toFixed(decimals)}%`;
}
/**
* Format duration in seconds to MM:SS
* @param seconds - Duration in seconds
* @returns Formatted duration string
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Format large numbers with comma separators
* @param value - Number to format
* @returns Formatted number string
*/
export function formatNumber(value: number | undefined | null): string {
if (value === undefined || value === null) {
return '0';
}
return value.toLocaleString('en-US');
}

View File

@@ -0,0 +1,75 @@
/**
* Utility functions for accessing CS2 map assets (icons, backgrounds, screenshots)
*/
/**
* Get the background image URL for a map
* @param mapName - The map name (e.g., "de_dust2")
* @returns URL to the map screenshot/background
*/
export function getMapBackground(mapName: string | null | undefined): string {
// If no map name provided, use default
if (!mapName || mapName.trim() === '') {
return getDefaultMapBackground();
}
// For "unknown" maps, use default background directly
if (mapName.toLowerCase() === 'unknown') {
return getDefaultMapBackground();
}
// Try WebP first (better compression), fallback to PNG
return `/images/map_screenshots/${mapName}.webp`;
}
/**
* Get the icon SVG URL for a map
* @param mapName - The map name (e.g., "de_dust2")
* @returns URL to the map icon SVG
*/
export function getMapIcon(mapName: string | null | undefined): string {
if (!mapName || mapName.trim() === '') {
return `/images/map_icons/map_icon_lobby_mapveto.svg`; // Generic map icon
}
return `/images/map_icons/map_icon_${mapName}.svg`;
}
/**
* Get fallback default map background if specific map is not found
*/
export function getDefaultMapBackground(): string {
return '/images/map_screenshots/default.webp';
}
/**
* Format map name for display (remove de_ prefix, capitalize)
* @param mapName - The map name (e.g., "de_dust2")
* @returns Formatted name (e.g., "Dust 2")
*/
export function formatMapName(mapName: string | null | undefined): string {
if (!mapName || mapName.trim() === '') {
return 'Unknown Map';
}
return mapName
.replace(/^(de|cs|ar|dz|gd|coop)_/, '')
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Get team logo URL
* @param team - "t" or "ct"
* @param variant - "logo" (color) or "logo_1c" (monochrome)
* @returns URL to the team logo SVG
*/
export function getTeamLogo(team: 't' | 'ct', variant: 'logo' | 'logo_1c' = 'logo'): string {
return `/images/icons/${team}_${variant}.svg`;
}
/**
* Get team character background
* @param team - "t" or "ct"
* @returns URL to the team character background SVG
*/
export function getTeamBackground(team: 't' | 'ct'): string {
return `/images/icons/${team}_char_bg.svg`;
}

102
src/lib/utils/navigation.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Navigation utility for preserving scroll state and match position
* when navigating between matches and the matches listing page.
*/
const STORAGE_KEY = 'matches-navigation-state';
interface NavigationState {
matchId: string;
scrollY: number;
timestamp: number;
loadedCount: number; // Number of matches loaded (for pagination)
}
/**
* Store navigation state when leaving the matches page
*/
export function storeMatchesState(matchId: string, loadedCount: number): void {
if (typeof window === 'undefined') return;
const state: NavigationState = {
matchId,
scrollY: window.scrollY,
timestamp: Date.now(),
loadedCount
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Failed to store navigation state:', e);
}
}
/**
* Retrieve stored navigation state
*/
export function getMatchesState(): NavigationState | null {
if (typeof window === 'undefined') return null;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const state: NavigationState = JSON.parse(stored);
// Clear state if older than 5 minutes (likely stale)
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
clearMatchesState();
return null;
}
return state;
} catch (e) {
console.warn('Failed to retrieve navigation state:', e);
return null;
}
}
/**
* Clear stored navigation state
*/
export function clearMatchesState(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn('Failed to clear navigation state:', e);
}
}
/**
* Scroll to a specific match card element by ID
*/
export function scrollToMatch(matchId: string, fallbackScrollY?: number): void {
if (typeof window === 'undefined') return;
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
// Try to find the match card element
const matchElement = document.querySelector(`[data-match-id="${matchId}"]`);
if (matchElement) {
// Found the element, scroll to it with some offset for the header
const offset = 100; // Header height + some padding
const elementPosition = matchElement.getBoundingClientRect().top + window.scrollY;
const offsetPosition = elementPosition - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
} else if (fallbackScrollY !== undefined) {
// Element not found (might be new matches), use stored scroll position
window.scrollTo({
top: fallbackScrollY,
behavior: 'instant'
});
}
});
}

View File

@@ -7,7 +7,7 @@ import type { Player, Match, MatchPlayer, MatchListItem, PlayerMeta } from '$lib
/** Mock players */ /** Mock players */
export const mockPlayers: Player[] = [ export const mockPlayers: Player[] = [
{ {
id: 765611980123456, // Smaller mock Steam ID (safe integer) id: '765611980123456', // Smaller mock Steam ID (safe integer)
name: 'TestPlayer1', name: 'TestPlayer1',
avatar: avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
@@ -21,7 +21,7 @@ export const mockPlayers: Player[] = [
game_ban_count: 0 game_ban_count: 0
}, },
{ {
id: 765611980876543, // Smaller mock Steam ID (safe integer) id: '765611980876543', // Smaller mock Steam ID (safe integer)
name: 'TestPlayer2', name: 'TestPlayer2',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg', avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
steam_updated: '2024-11-04T11:15:00Z', steam_updated: '2024-11-04T11:15:00Z',
@@ -50,7 +50,7 @@ export const mockPlayerMeta: PlayerMeta = {
/** Mock match players */ /** Mock match players */
export const mockMatchPlayers: MatchPlayer[] = [ export const mockMatchPlayers: MatchPlayer[] = [
{ {
id: 765611980123456, id: '765611980123456',
name: 'Player1', name: 'Player1',
avatar: avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
@@ -77,7 +77,7 @@ export const mockMatchPlayers: MatchPlayer[] = [
color: 'yellow' color: 'yellow'
}, },
{ {
id: 765611980876543, id: '765611980876543',
name: 'Player2', name: 'Player2',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg', avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
team_id: 2, team_id: 2,
@@ -96,7 +96,7 @@ export const mockMatchPlayers: MatchPlayer[] = [
color: 'blue' color: 'blue'
}, },
{ {
id: 765611980111111, id: '765611980111111',
name: 'Player3', name: 'Player3',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg', avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg',
team_id: 3, team_id: 3,
@@ -119,7 +119,7 @@ export const mockMatchPlayers: MatchPlayer[] = [
/** Mock matches */ /** Mock matches */
export const mockMatches: Match[] = [ export const mockMatches: Match[] = [
{ {
match_id: 358948771684207, // Smaller mock match ID (safe integer) match_id: '358948771684207', // Smaller mock match ID (safe integer)
share_code: 'CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX', share_code: 'CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX',
map: 'de_inferno', map: 'de_inferno',
date: '2024-11-01T18:45:00Z', date: '2024-11-01T18:45:00Z',
@@ -131,11 +131,11 @@ export const mockMatches: Match[] = [
demo_parsed: true, demo_parsed: true,
vac_present: false, vac_present: false,
gameban_present: false, gameban_present: false,
tick_rate: 64.0, // Note: tick_rate is not provided by the API
players: mockMatchPlayers players: mockMatchPlayers
}, },
{ {
match_id: 358948771684208, match_id: '358948771684208',
share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY', share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY',
map: 'de_mirage', map: 'de_mirage',
date: '2024-11-02T20:15:00Z', date: '2024-11-02T20:15:00Z',
@@ -146,11 +146,11 @@ export const mockMatches: Match[] = [
max_rounds: 24, max_rounds: 24,
demo_parsed: true, demo_parsed: true,
vac_present: false, vac_present: false,
gameban_present: false, gameban_present: false
tick_rate: 64.0 // Note: tick_rate is not provided by the API
}, },
{ {
match_id: 358948771684209, match_id: '358948771684209',
share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ', share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ',
map: 'de_dust2', map: 'de_dust2',
date: '2024-11-03T15:30:00Z', date: '2024-11-03T15:30:00Z',
@@ -161,8 +161,8 @@ export const mockMatches: Match[] = [
max_rounds: 24, max_rounds: 24,
demo_parsed: true, demo_parsed: true,
vac_present: false, vac_present: false,
gameban_present: false, gameban_present: false
tick_rate: 64.0 // Note: tick_rate is not provided by the API
} }
]; ];
@@ -174,8 +174,8 @@ export const mockMatchListItems: MatchListItem[] = mockMatches.map((match) => ({
score_team_a: match.score_team_a, score_team_a: match.score_team_a,
score_team_b: match.score_team_b, score_team_b: match.score_team_b,
duration: match.duration, duration: match.duration,
demo_parsed: match.demo_parsed, demo_parsed: match.demo_parsed
player_count: 10 // Note: player_count is not provided by the API, so it's omitted from mocks
})); }));
/** Helper: Generate random Steam ID (safe integer) */ /** Helper: Generate random Steam ID (safe integer) */
@@ -184,11 +184,11 @@ export const generateSteamId = (): number => {
}; };
/** Helper: Get mock player by ID */ /** Helper: Get mock player by ID */
export const getMockPlayer = (id: number): Player | undefined => { export const getMockPlayer = (id: string): Player | undefined => {
return mockPlayers.find((p) => p.id === id); return mockPlayers.find((p) => p.id === id);
}; };
/** Helper: Get mock match by ID */ /** Helper: Get mock match by ID */
export const getMockMatch = (id: number): Match | undefined => { export const getMockMatch = (id: string): Match | undefined => {
return mockMatches.find((m) => m.match_id === id); return mockMatches.find((m) => m.match_id === id);
}; };

View File

@@ -2,7 +2,6 @@ import { http, HttpResponse, delay } from 'msw';
import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures'; import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures';
import type { import type {
MatchParseResponse, MatchParseResponse,
MatchesListResponse,
MatchRoundsResponse, MatchRoundsResponse,
MatchWeaponsResponse, MatchWeaponsResponse,
MatchChatResponse MatchChatResponse
@@ -21,7 +20,7 @@ export const matchesHandlers = [
await delay(500); await delay(500);
const response: MatchParseResponse = { const response: MatchParseResponse = {
match_id: 358948771684207, match_id: '358948771684207',
status: 'parsing', status: 'parsing',
message: 'Demo download and parsing initiated', message: 'Demo download and parsing initiated',
estimated_time: 120 estimated_time: 120
@@ -33,7 +32,7 @@ export const matchesHandlers = [
// GET /match/:id // GET /match/:id
http.get(`${API_BASE_URL}/match/:id`, ({ params }) => { http.get(`${API_BASE_URL}/match/:id`, ({ params }) => {
const { id } = params; const { id } = params;
const matchId = Number(id); const matchId = String(id);
const match = getMockMatch(matchId) || mockMatches[0]; const match = getMockMatch(matchId) || mockMatches[0];
@@ -165,14 +164,11 @@ export const matchesHandlers = [
matches = matches.slice(0, Math.ceil(matches.length / 2)); matches = matches.slice(0, Math.ceil(matches.length / 2));
} }
const response: MatchesListResponse = { // NOTE: The real API returns a plain array, not a MatchesListResponse object
matches: matches.slice(0, limit), // The transformation to MatchesListResponse happens in the API client
next_page_time: Date.now() / 1000 - 86400, const matchArray = matches.slice(0, limit);
has_more: matches.length > limit,
total_count: matches.length
};
return HttpResponse.json(response); return HttpResponse.json(matchArray);
}), }),
// GET /matches/next/:time // GET /matches/next/:time
@@ -181,12 +177,9 @@ export const matchesHandlers = [
const limit = Number(url.searchParams.get('limit')) || 50; const limit = Number(url.searchParams.get('limit')) || 50;
// Return older matches for pagination // Return older matches for pagination
const response: MatchesListResponse = { // NOTE: The real API returns a plain array, not a MatchesListResponse object
matches: mockMatchListItems.slice(limit, limit * 2), const matchArray = mockMatchListItems.slice(limit, limit * 2);
next_page_time: Date.now() / 1000 - 172800,
has_more: mockMatchListItems.length > limit * 2
};
return HttpResponse.json(response); return HttpResponse.json(matchArray);
}) })
]; ];

View File

@@ -12,7 +12,7 @@ export const playersHandlers = [
// GET /player/:id // GET /player/:id
http.get(`${API_BASE_URL}/player/:id`, ({ params }) => { http.get(`${API_BASE_URL}/player/:id`, ({ params }) => {
const { id } = params; const { id } = params;
const playerId = Number(id); const playerId = String(id);
const player = getMockPlayer(playerId); const player = getMockPlayer(playerId);
if (!player) { if (!player) {
@@ -25,7 +25,7 @@ export const playersHandlers = [
// GET /player/:id/next/:time // GET /player/:id/next/:time
http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => { http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => {
const { id } = params; const { id } = params;
const playerId = Number(id); const playerId = String(id);
const player = getMockPlayer(playerId) ?? mockPlayers[0]; const player = getMockPlayer(playerId) ?? mockPlayers[0];

View File

@@ -1,29 +1,105 @@
<script lang="ts"> <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 Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte'; import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte'; import Badge from '$lib/components/ui/Badge.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
// Get data from page loader // Get data from page loader
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// Transform API matches to display format // Use matches directly - already transformed by API client
const featuredMatches = data.featuredMatches.map((match) => ({ const featuredMatches = data.featuredMatches;
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
}));
const stats = [ const stats = [
{ icon: Users, label: 'Players Tracked', value: '1.2M+' }, { icon: Users, label: 'Players Tracked', value: '1.2M+' },
{ icon: TrendingUp, label: 'Matches Analyzed', value: '500K+' }, { icon: TrendingUp, label: 'Matches Analyzed', value: '500K+' },
{ icon: Zap, label: 'Demos Parsed', value: '2M+' } { 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> </script>
<svelte:head> <svelte:head>
@@ -85,47 +161,73 @@
<Button variant="ghost" href="/matches">View All</Button> <Button variant="ghost" href="/matches">View All</Button>
</div> </div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {#if featuredMatches.length > 0}
{#each featuredMatches as match} <!-- Carousel Container -->
<Card variant="interactive" padding="none">
<a href={`/match/${match.id}`} class="block">
<div <div
class="relative h-48 overflow-hidden rounded-t-md bg-gradient-to-br from-base-300 to-base-100" class="relative"
onmouseenter={() => (isPaused = true)}
onmouseleave={() => (isPaused = false)}
role="region"
aria-label="Featured matches carousel"
> >
<div class="absolute inset-0 flex items-center justify-center"> <!-- Matches Grid with Fade Transition -->
<span class="text-6xl font-bold text-base-content/10">{match.mapDisplay}</span> <div class="transition-opacity duration-500" class:opacity-100={true}>
</div> <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div class="absolute bottom-4 left-4"> {#each visibleMatches as match (match.match_id)}
<Badge variant="default">{match.map}</Badge> <MatchCard {match} />
</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>
<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>
<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} {/each}
</div> </div>
</div> </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>
<!-- 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> </section>
<!-- Features Section --> <!-- Features Section -->

View File

@@ -10,11 +10,11 @@ export const load: PageLoad = async ({ parent }) => {
await parent(); await parent();
try { try {
// Load featured matches (limit to 3 for homepage) // Load featured matches for homepage carousel
const matchesData = await api.matches.getMatches({ limit: 3 }); const matchesData = await api.matches.getMatches({ limit: 9 });
return { 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: { meta: {
title: 'CS2.WTF - Statistics for CS2 Matchmaking', title: 'CS2.WTF - Statistics for CS2 Matchmaking',
description: description:

View 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;
}
};

View File

@@ -1,13 +1,20 @@
<script lang="ts"> <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 Badge from '$lib/components/ui/Badge.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte'; import Tabs from '$lib/components/ui/Tabs.svelte';
import type { LayoutData } from './$types'; 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; const { match } = data;
function handleBack() {
// Navigate back to matches page
goto('/matches');
}
const tabs = [ const tabs = [
{ label: 'Overview', href: `/match/${match.match_id}` }, { label: 'Overview', href: `/match/${match.match_id}` },
{ label: 'Economy', href: `/match/${match.match_id}/economy` }, { label: 'Economy', href: `/match/${match.match_id}/economy` },
@@ -26,17 +33,42 @@
? `${Math.floor(match.duration / 60)}:${(match.duration % 60).toString().padStart(2, '0')}` ? `${Math.floor(match.duration / 60)}:${(match.duration % 60).toString().padStart(2, '0')}`
: 'N/A'; : '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> </script>
<!-- Match Header --> <!-- Match Header with Background -->
<div class="border-b border-base-300 bg-gradient-to-r from-primary/5 to-secondary/5"> <div class="relative overflow-hidden border-b border-base-300">
<div class="container mx-auto px-4 py-8"> <!-- 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 --> <!-- Map Name -->
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<div> <div>
{#if match.map}
<Badge variant="default" size="lg">{match.map}</Badge> <Badge variant="default" size="lg">{match.map}</Badge>
<h1 class="mt-2 text-4xl font-bold text-base-content">{mapName}</h1> {/if}
<h1 class="mt-2 text-4xl font-bold text-white drop-shadow-lg">{mapName}</h1>
</div> </div>
{#if match.demo_parsed} {#if match.demo_parsed}
<button class="btn btn-outline btn-primary gap-2"> <button class="btn btn-outline btn-primary gap-2">
@@ -49,18 +81,20 @@
<!-- Score --> <!-- Score -->
<div class="mb-6 flex items-center justify-center gap-6"> <div class="mb-6 flex items-center justify-center gap-6">
<div class="text-center"> <div class="text-center">
<div class="text-sm font-medium text-base-content/60">TERRORISTS</div> <div class="text-sm font-medium text-white/70">TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-terrorist">{match.score_team_a}</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>
<div class="text-3xl font-bold text-white/40">:</div>
<div class="text-center"> <div class="text-center">
<div class="text-sm font-medium text-base-content/60">COUNTER-TERRORISTS</div> <div class="text-sm font-medium text-white/70">COUNTER-TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-ct">{match.score_team_b}</div> <div class="font-mono text-5xl font-bold text-ct drop-shadow-lg">{match.score_team_b}</div>
</div> </div>
</div> </div>
<!-- Match Meta --> <!-- 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"> <div class="flex items-center gap-2">
<Calendar class="h-4 w-4" /> <Calendar class="h-4 w-4" />
<span>{formattedDate}</span> <span>{formattedDate}</span>
@@ -76,7 +110,7 @@
</div> </div>
<!-- Tabs --> <!-- 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" /> <Tabs {tabs} variant="bordered" size="md" />
</div> </div>
</div> </div>

View File

@@ -2,11 +2,13 @@
import { Trophy } from 'lucide-svelte'; import { Trophy } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte'; import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte'; import Badge from '$lib/components/ui/Badge.svelte';
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
import RoundTimeline from '$lib/components/RoundTimeline.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { MatchPlayer } from '$lib/types'; import type { MatchPlayer } from '$lib/types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { match } = data; const { match, rounds } = data;
// Group players by team - use dynamic team IDs from API // Group players by team - use dynamic team IDs from API
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : []; 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 totalKills = players.reduce((sum, p) => sum + p.kills, 0);
const totalDeaths = players.reduce((sum, p) => sum + p.deaths, 0); const totalDeaths = players.reduce((sum, p) => sum + p.deaths, 0);
const totalADR = players.reduce((sum, p) => sum + (p.adr || 0), 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 { return {
kills: totalKills, kills: totalKills,
@@ -116,6 +119,7 @@
<th style="width: 100px;">ADR</th> <th style="width: 100px;">ADR</th>
<th style="width: 100px;">HS%</th> <th style="width: 100px;">HS%</th>
<th style="width: 100px;">KAST%</th> <th style="width: 100px;">KAST%</th>
<th style="width: 180px;">Rating</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -135,9 +139,18 @@
<td class="font-mono font-semibold">{player.kills}</td> <td class="font-mono font-semibold">{player.kills}</td>
<td class="font-mono">{player.deaths}</td> <td class="font-mono">{player.deaths}</td>
<td class="font-mono">{player.assists}</td> <td class="font-mono">{player.assists}</td>
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td> <td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td> <td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</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> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -161,6 +174,7 @@
<th style="width: 100px;">ADR</th> <th style="width: 100px;">ADR</th>
<th style="width: 100px;">HS%</th> <th style="width: 100px;">HS%</th>
<th style="width: 100px;">KAST%</th> <th style="width: 100px;">KAST%</th>
<th style="width: 180px;">Rating</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -180,9 +194,18 @@
<td class="font-mono font-semibold">{player.kills}</td> <td class="font-mono font-semibold">{player.kills}</td>
<td class="font-mono">{player.deaths}</td> <td class="font-mono">{player.deaths}</td>
<td class="font-mono">{player.assists}</td> <td class="font-mono">{player.assists}</td>
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td> <td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td> <td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</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> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -191,15 +214,23 @@
</div> </div>
</Card> </Card>
<!-- Coming Soon Badges for Round Timeline --> <!-- Round Timeline -->
{#if rounds && rounds.rounds && rounds.rounds.length > 0}
<RoundTimeline rounds={rounds.rounds} />
{:else}
<Card padding="lg"> <Card padding="lg">
<div class="text-center"> <div class="text-center">
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3> <h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
<p class="text-base-content/60"> <p class="text-base-content/60">
Round-by-round timeline visualization coming soon. Will show bomb plants, defuses, and round Round-by-round timeline data is not available for this match. This requires the demo to be
winners. fully parsed.
</p> </p>
<Badge variant="warning" size="md" class="mt-4">Coming in Future Update</Badge> {#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> </div>
</Card> </Card>
{/if}
</div> </div>

View 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
};
}
};

View File

@@ -20,8 +20,8 @@
let selectedPlayer = $state<number | null>(null); let selectedPlayer = $state<number | null>(null);
let messagePlayers = $state<MessagePlayer[]>([]); let messagePlayers = $state<MessagePlayer[]>([]);
let filteredMessages = $state<typeof chatData.messages>([]); let filteredMessages = $state<NonNullable<PageData['chatData']>['messages']>([]);
let messagesByRound = $state<Record<number, typeof chatData.messages>>({}); let messagesByRound = $state<Record<number, NonNullable<PageData['chatData']>['messages']>>({});
let rounds = $state<number[]>([]); let rounds = $state<number[]>([]);
let totalMessages = $state(0); let totalMessages = $state(0);
let teamChatCount = $state(0); let teamChatCount = $state(0);
@@ -29,7 +29,7 @@
// Get player info for a message // Get player info for a message
const getPlayerInfo = (playerId: number) => { const getPlayerInfo = (playerId: number) => {
const player = match.players?.find((p) => p.id === playerId); const player = match.players?.find((p) => p.id === String(playerId));
return { return {
name: player?.name || `Player ${playerId}`, name: player?.name || `Player ${playerId}`,
team_id: player?.team_id || 0 team_id: player?.team_id || 0
@@ -38,16 +38,16 @@
if (chatData) { if (chatData) {
// Get unique players who sent messages // Get unique players who sent messages
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id))).map( messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id)))
(playerId) => { .filter((playerId): playerId is number => playerId !== undefined)
const player = match.players?.find((p) => p.id === playerId); .map((playerId) => {
const player = match.players?.find((p) => p.id === String(playerId));
return { return {
id: playerId, id: playerId,
name: player?.name || `Player ${playerId}`, name: player?.name || `Player ${playerId}`,
team_id: player?.team_id team_id: player?.team_id || 0
}; };
} });
);
// Filter messages // Filter messages
const computeFilteredMessages = () => { const computeFilteredMessages = () => {
@@ -199,7 +199,11 @@
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`} {round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
</h3> </h3>
<Badge variant="default" size="sm"> <Badge variant="default" size="sm">
{messagesByRound[round].length} message{messagesByRound[round].length !== 1 {messagesByRound[round] ? messagesByRound[round].length : 0} message{(messagesByRound[
round
]
? messagesByRound[round].length
: 0) !== 1
? 's' ? 's'
: ''} : ''}
</Badge> </Badge>
@@ -209,7 +213,7 @@
<!-- Messages --> <!-- Messages -->
<div class="divide-y divide-base-300"> <div class="divide-y divide-base-300">
{#each messagesByRound[round] as message} {#each messagesByRound[round] as message}
{@const playerInfo = getPlayerInfo(message.player_id)} {@const playerInfo = getPlayerInfo(message.player_id || 0)}
<div class="p-4 transition-colors hover:bg-base-200/50"> <div class="p-4 transition-colors hover:bg-base-200/50">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Player Avatar/Icon --> <!-- Player Avatar/Icon -->
@@ -226,7 +230,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<a <a
href="/player/{message.player_id}" href={`/player/${message.player_id || 0}`}
class="font-semibold hover:underline" class="font-semibold hover:underline"
class:text-terrorist={playerInfo.team_id === 2} class:text-terrorist={playerInfo.team_id === 2}
class:text-ct={playerInfo.team_id === 3} class:text-ct={playerInfo.team_id === 3}

View File

@@ -1,38 +1,291 @@
<script lang="ts"> <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 Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte'; import Badge from '$lib/components/ui/Badge.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import 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> </script>
<div class="space-y-6"> <svelte:head>
<title>Damage Analysis - CS2.WTF</title>
</svelte:head>
{#if !hasPlayerData}
<Card padding="lg"> <Card padding="lg">
<div class="text-center"> <div class="text-center">
<Crosshair class="mx-auto mb-4 h-16 w-16 text-error" /> <AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
<h2 class="mb-2 text-2xl font-bold text-base-content">Damage Analysis</h2> <h2 class="mb-2 text-2xl font-bold text-base-content">No Player Data Available</h2>
<p class="mb-4 text-base-content/60"> <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> </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> </div>
</Card> </Card>
<!-- 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"> <div class="grid gap-6 md:grid-cols-3">
{#each topDamageDealers as player, index}
<Card padding="lg"> <Card padding="lg">
<Crosshair class="mb-2 h-8 w-8 text-error" /> <div class="mb-3 flex items-center justify-between">
<h3 class="mb-1 text-lg font-semibold">Damage Summary</h3> <div class="flex items-center gap-2">
<p class="text-sm text-base-content/60">Total damage dealt and received</p> <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">
<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>
<Card padding="lg"> <!-- Player Damage Table -->
<Target class="mb-2 h-8 w-8 text-primary" /> <Card padding="none">
<h3 class="mb-1 text-lg font-semibold">Hit Groups</h3> <div class="p-6">
<p class="text-sm text-base-content/60">Headshots, chest, legs, arms breakdown</p> <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> </Card>
<!-- Additional Info Note -->
<Card padding="lg"> <Card padding="lg">
<Crosshair class="mb-2 h-8 w-8 text-info" /> <div class="flex items-start gap-3">
<h3 class="mb-1 text-lg font-semibold">Range Analysis</h3> <AlertCircle class="h-5 w-5 flex-shrink-0 text-info" />
<p class="text-sm text-base-content/60">Damage effectiveness by distance</p> <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> </Card>
</div> </div>
</div> {/if}

View File

@@ -45,63 +45,90 @@
// Prepare data table columns // Prepare data table columns
const detailsColumns = [ const detailsColumns = [
{ {
key: 'name', key: 'name' as keyof (typeof playersWithStats)[0],
label: 'Player', label: 'Player',
sortable: true, 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'; 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', label: 'K',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
class: 'font-mono font-semibold' 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', label: 'K/D',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
class: 'font-mono', 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', label: 'ADR',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
class: 'font-mono', 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%', label: 'HS%',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
class: 'font-mono', 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%', label: 'KAST%',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
class: 'font-mono', 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', label: 'Aces',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
render: (value: number) => { render: (
if (value > 0) return `<span class="badge badge-warning badge-sm">${value}</span>`; 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>'; return '<span class="text-base-content/40">-</span>';
} }
} }
@@ -142,7 +169,8 @@
? playersWithStats.filter((p) => p.team_id === secondTeamId) ? playersWithStats.filter((p) => p.team_id === secondTeamId)
: []; : [];
const teamAStats = hasPlayerData const teamAStats =
hasPlayerData && teamAPlayers.length > 0
? { ? {
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0), totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamAPlayers.reduce( totalUtilityDamage: teamAPlayers.reduce(
@@ -159,7 +187,8 @@
} }
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' }; : { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamBStats = hasPlayerData const teamBStats =
hasPlayerData && teamBPlayers.length > 0
? { ? {
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0), totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamBPlayers.reduce( totalUtilityDamage: teamBPlayers.reduce(
@@ -267,8 +296,9 @@
</Card> </Card>
<!-- Top Performers --> <!-- Top Performers -->
// Top Performers
<div class="grid gap-6 md:grid-cols-3"> <div class="grid gap-6 md:grid-cols-3">
{#if sortedPlayers.length > 0} {#if sortedPlayers.length > 0 && sortedPlayers[0]}
<!-- Most Kills --> <!-- Most Kills -->
<Card padding="lg"> <Card padding="lg">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
@@ -276,7 +306,9 @@
<h3 class="font-semibold text-base-content">Most Kills</h3> <h3 class="font-semibold text-base-content">Most Kills</h3>
</div> </div>
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div> <div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
<div class="mt-1 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"> <div class="mt-2 text-xs text-base-content/60">
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D {sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
</div> </div>
@@ -284,6 +316,7 @@
<!-- Best K/D --> <!-- Best K/D -->
{@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]} {@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]}
{#if bestKD}
<Card padding="lg"> <Card padding="lg">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" /> <Target class="h-5 w-5 text-success" />
@@ -295,11 +328,13 @@
{bestKD.kills}K / {bestKD.deaths}D {bestKD.kills}K / {bestKD.deaths}D
</div> </div>
</Card> </Card>
{/if}
<!-- Most Utility Damage --> <!-- Most Utility Damage -->
{@const bestUtility = [...sortedPlayers].sort( {@const bestUtility = [...sortedPlayers].sort(
(a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0)) (a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0))
)[0]} )[0]}
{#if bestUtility}
<Card padding="lg"> <Card padding="lg">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<Flame class="h-5 w-5 text-error" /> <Flame class="h-5 w-5 text-error" />
@@ -314,6 +349,7 @@
</div> </div>
</Card> </Card>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -5,7 +5,6 @@
import LineChart from '$lib/components/charts/LineChart.svelte'; import LineChart from '$lib/components/charts/LineChart.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte'; import DataTable from '$lib/components/data-display/DataTable.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { ChartData } from 'chart.js';
interface TeamEconomy { interface TeamEconomy {
round: number; round: number;
@@ -30,7 +29,17 @@
// Only process if rounds data exists // Only process if rounds data exists
let teamEconomy = $state<TeamEconomy[]>([]); 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 totalRounds = $state(0);
let teamA_fullBuys = $state(0); let teamA_fullBuys = $state(0);
let teamB_fullBuys = $state(0); let teamB_fullBuys = $state(0);
@@ -41,12 +50,12 @@
// Process rounds data to calculate team totals // Process rounds data to calculate team totals
for (const roundData of roundsData.rounds) { for (const roundData of roundsData.rounds) {
const teamAPlayers = roundData.players.filter((p) => { const teamAPlayers = roundData.players.filter((p) => {
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; return matchPlayer?.team_id === firstTeamId;
}); });
const teamBPlayers = roundData.players.filter((p) => { const teamBPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id); const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === secondTeamId; return matchPlayer?.team_id === secondTeamId;
}); });
@@ -116,61 +125,71 @@
// Table columns // Table columns
const tableColumns = [ 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', label: 'T Buy',
sortable: true, sortable: true,
render: (value: string) => { render: (value: string | number | boolean, _row: TeamEconomy) => {
const strValue = value as string;
const variant = const variant =
value === 'Full Buy' strValue === 'Full Buy'
? 'success' ? 'success'
: value === 'Eco' : strValue === 'Eco'
? 'error' ? 'error'
: value === 'Force' : strValue === 'Force'
? 'warning' ? 'warning'
: 'default'; : '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', label: 'T Equipment',
sortable: true, sortable: true,
align: 'right' as const, 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', label: 'CT Buy',
sortable: true, sortable: true,
render: (value: string) => { render: (value: string | number | boolean, _row: TeamEconomy) => {
const strValue = value as string;
const variant = const variant =
value === 'Full Buy' strValue === 'Full Buy'
? 'success' ? 'success'
: value === 'Eco' : strValue === 'Eco'
? 'error' ? 'error'
: value === 'Force' : strValue === 'Force'
? 'warning' ? 'warning'
: 'default'; : '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', label: 'CT Equipment',
sortable: true, sortable: true,
align: 'right' as const, 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', label: 'Winner',
align: 'center' as const, align: 'center' as const,
render: (value: number) => { render: (value: string | number | boolean, _row: TeamEconomy) => {
if (value === 2) 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>'; 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="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
return '<span class="text-base-content/40">-</span>'; return '<span class="text-base-content/40">-</span>';
} }

View File

@@ -50,39 +50,52 @@
const teamBTotals = calcTeamTotals(teamBFlashStats); const teamBTotals = calcTeamTotals(teamBFlashStats);
// Table columns with fixed widths for consistency across multiple tables // 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 = [ 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', label: 'Enemies Blinded',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
width: '150px' width: '150px'
}, },
{ {
key: 'avg_blind_duration', key: 'avg_blind_duration' as const,
label: 'Avg Duration (s)', label: 'Avg Duration (s)',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
formatter: (value: string) => `${value}s`, format: (value: string | number | boolean, _row: FlashStat) => `${value as string}s`,
width: '150px' width: '150px'
}, },
{ {
key: 'flash_assists', key: 'flash_assists' as const,
label: 'Flash Assists', label: 'Flash Assists',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
width: '130px' width: '130px'
}, },
{ {
key: 'teammates_blinded', key: 'teammates_blinded' as const,
label: 'Team Flashed', label: 'Team Flashed',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,
width: '130px' width: '130px'
}, },
{ {
key: 'self_blinded', key: 'self_blinded' as const,
label: 'Self Flashed', label: 'Self Flashed',
sortable: true, sortable: true,
align: 'center' as const, align: 'center' as const,

View File

@@ -1,5 +1,15 @@
<script lang="ts"> <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 { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
@@ -7,8 +17,17 @@
import Card from '$lib/components/ui/Card.svelte'; import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte'; import Badge from '$lib/components/ui/Badge.svelte';
import MatchCard from '$lib/components/match/MatchCard.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 { PageData } from './$types';
import type { MatchListItem } from '$lib/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(); let { data }: { data: PageData } = $props();
@@ -19,6 +38,29 @@
let searchQuery = $state(currentSearch); let searchQuery = $state(currentSearch);
let showFilters = $state(false); 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 // Pagination state
let matches = $state<MatchListItem[]>(data.matches); let matches = $state<MatchListItem[]>(data.matches);
@@ -31,6 +73,13 @@
let sortOrder = $state<'desc' | 'asc'>('desc'); let sortOrder = $state<'desc' | 'asc'>('desc');
let resultFilter = $state<'all' | 'win' | 'loss' | 'tie'>('all'); 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) // Reset pagination when data changes (new filters applied)
$effect(() => { $effect(() => {
matches = data.matches; matches = data.matches;
@@ -38,10 +87,103 @@
nextPageTime = data.nextPageTime; 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 // Computed filtered and sorted matches
const displayMatches = $derived.by(() => { const displayMatches = $derived.by(() => {
let filtered = [...matches]; 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 // Apply result filter
if (resultFilter !== 'all') { if (resultFilter !== 'all') {
filtered = filtered.filter((match) => { filtered = filtered.filter((match) => {
@@ -81,26 +223,89 @@
if (searchQuery) params.set('search', searchQuery); if (searchQuery) params.set('search', searchQuery);
if (currentMap) params.set('map', currentMap); if (currentMap) params.set('map', currentMap);
if (currentPlayerId) params.set('player_id', currentPlayerId); 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()}`); 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 () => { 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; isLoadingMore = true;
try { try {
const matchesData = await api.matches.getMatches({ const matchesData = await api.matches.getMatches({
limit: 50, limit: 20,
map: data.filters.map, map: data.filters.map,
player_id: data.filters.playerId, player_id: data.filters.playerId ? String(data.filters.playerId) : undefined,
before_time: nextPageTime before_time: lastMatchTimestamp
}); });
// Append new matches to existing list // Append new matches to existing list
matches = [...matches, ...matchesData.matches]; matches = [...matches, ...matchesData.matches];
hasMore = matchesData.has_more; hasMore = matchesData.has_more;
nextPageTime = matchesData.next_page_time; nextPageTime = matchesData.next_page_time;
console.log('Updated state:', { matchesLength: matches.length, hasMore, nextPageTime });
} catch (error) { } catch (error) {
console.error('Failed to load more matches:', error); console.error('Failed to load more matches:', error);
// Show error toast or message here // Show error toast or message here
@@ -118,18 +323,183 @@
'de_ancient', 'de_ancient',
'de_anubis' '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> </script>
<svelte:head> <svelte:head>
<title>Matches - CS2.WTF</title> <title>Matches - CS2.WTF</title>
</svelte:head> </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="container mx-auto px-4 py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="mb-2 text-4xl font-bold">Matches</h1> <h1 class="mb-2 text-4xl font-bold">Matches</h1>
<p class="text-base-content/60">Browse and search through CS2 competitive matches</p> <p class="text-base-content/60">Browse and search through CS2 competitive matches</p>
</div> </div>
<!-- Share Code Input -->
<Card padding="lg" class="mb-8">
<ShareCodeInput />
</Card>
<!-- Search & Filters --> <!-- Search & Filters -->
<Card padding="lg" class="mb-8"> <Card padding="lg" class="mb-8">
<form <form
@@ -158,12 +528,103 @@
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}> <Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
<Filter class="mr-2 h-5 w-5" /> <Filter class="mr-2 h-5 w-5" />
Filters Filters
{#if activeFilterCount() > 0}
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount()}</Badge>
{/if}
</Button> </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> </div>
<!-- Filter Panel (Collapsible) --> <!-- Filter Panel (Collapsible) -->
{#if showFilters} {#if showFilters}
<div class="space-y-4 border-t border-base-300 pt-4"> <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 --> <!-- Map Filter -->
<div> <div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3> <h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
@@ -180,6 +641,51 @@
</div> </div>
</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">&lt;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 --> <!-- Result Filter -->
<div> <div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Result</h3> <h3 class="mb-3 font-semibold text-base-content">Filter by Result</h3>
@@ -241,12 +747,19 @@
</button> </button>
</div> </div>
</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> </div>
{/if} {/if}
</form> </form>
<!-- Active Filters --> <!-- 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"> <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> <span class="text-sm font-medium text-base-content/70">Active Filters:</span>
{#if currentSearch} {#if currentSearch}
@@ -258,31 +771,104 @@
{#if currentPlayerId} {#if currentPlayerId}
<Badge variant="info">Player ID: {currentPlayerId}</Badge> <Badge variant="info">Player ID: {currentPlayerId}</Badge>
{/if} {/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> </div>
{/if} {/if}
</Card> </Card>
<!-- 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 --> <!-- Results Summary -->
{#if matches.length > 0 && resultFilter !== 'all'} {#if matches.length > 0 && resultFilter !== 'all'}
<div class="mb-4">
<Badge variant="info"> <Badge variant="info">
Showing {displayMatches.length} of {matches.length} matches Showing {displayMatches.length} of {matches.length} matches
</Badge> </Badge>
{/if}
</div>
<!-- Matches Display (Grid or Table) -->
{#if displayMatches.length > 0}
{#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> </div>
{/if} {/if}
<!-- Matches Grid --> <!-- Load More Trigger (for infinite scroll) -->
{#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>
<!-- Load More Button -->
{#if hasMore} {#if hasMore}
<div class="mt-8 text-center"> <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}> <Button variant="primary" size="lg" onclick={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore} {#if isLoadingMore}
<Loader2 class="mr-2 h-5 w-5 animate-spin" /> <Loader2 class="mr-2 h-5 w-5 animate-spin" />
@@ -291,8 +877,11 @@
Load More Matches Load More Matches
{/if} {/if}
</Button> </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"> <p class="mt-2 text-sm text-base-content/60">
Showing {matches.length} matches Showing {matches.length} matches {hasMore ? '(more available)' : '(all loaded)'}
</p> </p>
</div> </div>
{:else if matches.length > 0} {:else if matches.length > 0}
@@ -312,10 +901,24 @@
<p class="text-base-content/60"> <p class="text-base-content/60">
No matches match your current filters. Try adjusting your filter settings. No matches match your current filters. Try adjusting your filter settings.
</p> </p>
<div class="mt-4"> <div class="mt-4 flex flex-wrap justify-center gap-2">
{#if resultFilter !== 'all'}
<Button variant="primary" onclick={() => (resultFilter = 'all')}> <Button variant="primary" onclick={() => (resultFilter = 'all')}>
Clear Result Filter Clear Result Filter
</Button> </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>
</div> </div>
</Card> </Card>

View File

@@ -7,9 +7,8 @@ import { api } from '$lib/api';
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ url }) => {
// Get query parameters // Get query parameters
const map = url.searchParams.get('map') || undefined; const map = url.searchParams.get('map') || undefined;
const playerIdStr = url.searchParams.get('player_id'); const playerId = url.searchParams.get('player_id') || undefined;
const playerId = playerIdStr ? Number(playerIdStr) : undefined; const limit = Number(url.searchParams.get('limit')) || 20; // Request 20 matches for initial load
const limit = Number(url.searchParams.get('limit')) || 50;
try { try {
// Load matches with filters // Load matches with filters
@@ -33,7 +32,10 @@ export const load: PageLoad = async ({ url }) => {
} }
}; };
} catch (error) { } 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 empty state on error
return { return {

View File

@@ -1,16 +1,40 @@
<script lang="ts"> <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 Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte'; import MatchCard from '$lib/components/match/MatchCard.svelte';
import LineChart from '$lib/components/charts/LineChart.svelte'; import LineChart from '$lib/components/charts/LineChart.svelte';
import BarChart from '$lib/components/charts/BarChart.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 { preferences } from '$lib/stores';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { profile, recentMatches, playerStats } = data; const { profile, recentMatches, playerStats } = 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 // Calculate stats from PlayerMeta and aggregated match data
const kd = const kd =
profile.avg_deaths > 0 profile.avg_deaths > 0
@@ -18,6 +42,12 @@
: profile.avg_kills.toFixed(2); : profile.avg_kills.toFixed(2);
const winRate = (profile.win_rate * 100).toFixed(1); 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 // Calculate headshot percentage from playerStats if available
const totalKills = playerStats.reduce((sum, stat) => sum + stat.kills, 0); const totalKills = playerStats.reduce((sum, stat) => sum + stat.kills, 0);
const totalHeadshots = playerStats.reduce((sum, stat) => sum + (stat.headshot || 0), 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) // Performance trend chart data (K/D ratio over time)
const performanceTrendData = { const performanceTrendData = {
labels: playerStats.map((stat, i) => `Match ${playerStats.length - i}`).reverse(), labels: playerStats.map((_stat, i) => `Match ${playerStats.length - i}`).reverse(),
datasets: [ datasets: [
{ {
label: 'K/D Ratio', label: 'K/D Ratio',
@@ -51,7 +81,7 @@
}, },
{ {
label: 'KAST %', label: 'KAST %',
data: playerStats.map((stat) => stat.kast).reverse(), data: playerStats.map((stat) => stat.kast || 0).reverse(),
borderColor: 'rgb(34, 197, 94)', borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)', backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4, tension: 0.4,
@@ -176,6 +206,62 @@
<Heart class="h-5 w-5 {isFavorite ? 'fill-error text-error' : ''}" /> <Heart class="h-5 w-5 {isFavorite ? 'fill-error text-error' : ''}" />
</button> </button>
</div> </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"> <div class="flex flex-wrap gap-3 text-sm text-base-content/60">
<span>Steam ID: {profile.id}</span> <span>Steam ID: {profile.id}</span>
<span>Last match: {new Date(profile.last_match_date).toLocaleDateString()}</span> <span>Last match: {new Date(profile.last_match_date).toLocaleDateString()}</span>
@@ -184,6 +270,14 @@
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2"> <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}`}> <Button variant="ghost" size="sm" href={`/matches?player_id=${profile.id}`}>
View All Matches View All Matches
</Button> </Button>
@@ -191,6 +285,16 @@
</div> </div>
</Card> </Card>
<!-- Track Player Modal -->
<TrackPlayerModal
playerId={profile.id}
playerName={profile.name}
isTracked={profile.tracked || false}
bind:isOpen={isTrackModalOpen}
ontracked={handleTracked}
onuntracked={handleUntracked}
/>
<!-- Career Statistics --> <!-- Career Statistics -->
<div> <div>
<h2 class="mb-4 text-2xl font-bold text-base-content">Career Statistics</h2> <h2 class="mb-4 text-2xl font-bold text-base-content">Career Statistics</h2>

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 214 KiB

View File

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 210 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 212 KiB

View File

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 217 KiB

View File

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 214 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 209 KiB

View File

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 212 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 214 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 211 KiB

View File

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 208 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 211 KiB

View File

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Some files were not shown because too many files have changed in this diff Show More