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>
@@ -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
|
||||||
|
|||||||
169
docs/API.md
@@ -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
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
480
docs/IMPLEMENTATION_STATUS.md
Normal 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**
|
||||||
@@ -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
@@ -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();
|
||||||
|
```
|
||||||
@@ -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>
|
|
||||||
@@ -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"}
|
|
||||||
35
src/app.css
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
268
src/lib/components/RoundTimeline.svelte
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
155
src/lib/components/match/ShareCodeInput.svelte
Normal 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>
|
||||||
196
src/lib/components/player/TrackPlayerModal.svelte
Normal 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>
|
||||||
@@ -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}
|
||||||
|
|||||||
68
src/lib/components/ui/PremierRatingBadge.svelte
Normal 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>
|
||||||
@@ -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}">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
75
src/lib/utils/mapAssets.ts
Normal 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
@@ -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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
163
src/routes/api/[...path]/+server.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* SvelteKit API Route Handler
|
||||||
|
*
|
||||||
|
* This catch-all route proxies requests to the backend API.
|
||||||
|
* Benefits over Vite proxy:
|
||||||
|
* - Works in development, preview, and production
|
||||||
|
* - Single code path for all environments
|
||||||
|
* - Can add caching, rate limiting, auth in the future
|
||||||
|
* - No CORS issues
|
||||||
|
*
|
||||||
|
* Backend selection:
|
||||||
|
* - Set VITE_API_BASE_URL=http://localhost:8000 for local development
|
||||||
|
* - Set VITE_API_BASE_URL=https://api.csgow.tf for production API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
// Get backend API URL from environment variable
|
||||||
|
// Note: We use $env/dynamic/private instead of import.meta.env for server-side access
|
||||||
|
const API_BASE_URL = env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET request handler
|
||||||
|
* Forwards GET requests to the backend API
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, url, request }) => {
|
||||||
|
const path = params.path;
|
||||||
|
const queryString = url.search;
|
||||||
|
|
||||||
|
// Construct full backend URL
|
||||||
|
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Forward request to backend
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
// Forward relevant headers
|
||||||
|
Accept: request.headers.get('Accept') || 'application/json',
|
||||||
|
'User-Agent': 'CS2.WTF Frontend'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if request was successful
|
||||||
|
if (!response.ok) {
|
||||||
|
throw error(response.status, `Backend API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response data
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Return JSON response
|
||||||
|
return json(data);
|
||||||
|
} catch (err) {
|
||||||
|
// Log error for debugging
|
||||||
|
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
|
||||||
|
|
||||||
|
// Handle fetch errors
|
||||||
|
if (err instanceof Error && err.message.includes('fetch')) {
|
||||||
|
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw SvelteKit errors
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request handler
|
||||||
|
* Forwards POST requests to the backend API
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ params, url, request }) => {
|
||||||
|
const path = params.path;
|
||||||
|
const queryString = url.search;
|
||||||
|
|
||||||
|
// Construct full backend URL
|
||||||
|
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get request body
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
// Forward request to backend
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': request.headers.get('Content-Type') || 'application/json',
|
||||||
|
Accept: request.headers.get('Accept') || 'application/json',
|
||||||
|
'User-Agent': 'CS2.WTF Frontend'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if request was successful
|
||||||
|
if (!response.ok) {
|
||||||
|
throw error(response.status, `Backend API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response data
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Return JSON response
|
||||||
|
return json(data);
|
||||||
|
} catch (err) {
|
||||||
|
// Log error for debugging
|
||||||
|
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
|
||||||
|
|
||||||
|
// Handle fetch errors
|
||||||
|
if (err instanceof Error && err.message.includes('fetch')) {
|
||||||
|
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw SvelteKit errors
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE request handler
|
||||||
|
* Forwards DELETE requests to the backend API
|
||||||
|
*/
|
||||||
|
export const DELETE: RequestHandler = async ({ params, url, request }) => {
|
||||||
|
const path = params.path;
|
||||||
|
const queryString = url.search;
|
||||||
|
|
||||||
|
// Construct full backend URL
|
||||||
|
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Forward request to backend
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: request.headers.get('Accept') || 'application/json',
|
||||||
|
'User-Agent': 'CS2.WTF Frontend'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if request was successful
|
||||||
|
if (!response.ok) {
|
||||||
|
throw error(response.status, `Backend API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response data
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Return JSON response
|
||||||
|
return json(data);
|
||||||
|
} catch (err) {
|
||||||
|
// Log error for debugging
|
||||||
|
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
|
||||||
|
|
||||||
|
// Handle fetch errors
|
||||||
|
if (err instanceof Error && err.message.includes('fetch')) {
|
||||||
|
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw SvelteKit errors
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
<script lang="ts">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
21
src/routes/match/[id]/+page.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
const matchId = params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch rounds data for the timeline visualization
|
||||||
|
const rounds = await api.matches.getMatchRounds(matchId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rounds
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load rounds for match ${matchId}:`, err);
|
||||||
|
// Return empty rounds if the endpoint fails (demo might not be parsed yet)
|
||||||
|
return {
|
||||||
|
rounds: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
let selectedPlayer = $state<number | null>(null);
|
let 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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"><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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 504 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 212 KiB |