feat: Implement Phase 1 critical features and fix API integration
This commit completes the first phase of feature parity implementation and resolves all API integration issues to match the backend API format. ## API Integration Fixes - Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated) - Update TypeScript types to make fields optional where backend doesn't guarantee them - Update Zod schemas to validate optional fields correctly - Fix mock data to match real API response format (plain arrays, not wrapped objects) - Update UI components to handle undefined values with proper fallbacks - Add comprehensive API documentation for Match and Player endpoints ## Phase 1 Features Implemented (3/6) ### 1. Player Tracking System ✅ - Created TrackPlayerModal.svelte with auth code input - Integrated track/untrack player API endpoints - Added UI for providing optional share code - Displays tracked status on player profiles - Full validation and error handling ### 2. Share Code Parsing ✅ - Created ShareCodeInput.svelte component - Added to matches page for easy match submission - Real-time validation of share code format - Parse status feedback with loading states - Auto-redirect to match page on success ### 3. VAC/Game Ban Status ✅ - Added VAC and game ban count/date fields to Player type - Display status badges on player profile pages - Show ban count and date when available - Visual indicators using DaisyUI badge components ## Component Improvements - Modal.svelte: Added Svelte 5 Snippet types, actions slot support - ThemeToggle.svelte: Removed deprecated svelte:component usage - Tooltip.svelte: Fixed type safety with Snippet type - All new components follow Svelte 5 runes pattern ($state, $derived, $bindable) ## Type Safety & Linting - Fixed all ESLint errors (any types → proper types) - Fixed form label accessibility issues - Replaced error: any with error: unknown + proper type guards - Added Snippet type imports where needed - Updated all catch blocks to use instanceof Error checks ## Static Assets - Migrated all files from public/ to static/ directory per SvelteKit best practices - Moved 200+ map icons, screenshots, and other assets - Updated all import paths to use /images/ (served from static/) ## Documentation - Created IMPLEMENTATION_STATUS.md tracking all 15 missing features - Updated API.md with optional field annotations - Created MATCHES_API.md with comprehensive endpoint documentation - Added inline comments marking optional vs required fields ## Testing - Updated mock fixtures to remove default values - Fixed mock handlers to return plain arrays like real API - Ensured all components handle undefined gracefully ## Remaining Phase 1 Tasks - [ ] Add VAC status column to match scoreboard - [ ] Create weapons statistics tab for matches - [ ] Implement recently visited players on home page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,38 +4,32 @@ import { APIException } from '$lib/types';
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
// Check if we're running on the server (SSR) or in production
|
||||
// On the server, we must use the actual API URL, not the proxy
|
||||
if (import.meta.env.SSR || import.meta.env.PROD) {
|
||||
return apiUrl;
|
||||
function getAPIBaseURL(): string {
|
||||
// During SSR, call backend API directly (relative URLs don't work server-side)
|
||||
if (import.meta.env.SSR) {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
}
|
||||
|
||||
// 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
|
||||
// In browser, use SvelteKit route
|
||||
return '/api';
|
||||
};
|
||||
}
|
||||
|
||||
const API_BASE_URL = getAPIBaseURL();
|
||||
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
|
||||
* Provides centralized HTTP communication with error handling
|
||||
|
||||
@@ -93,23 +93,54 @@ export const matchesAPI = {
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @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> {
|
||||
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, {
|
||||
params: {
|
||||
limit: params?.limit,
|
||||
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 (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
|
||||
return transformMatchesListResponse(data);
|
||||
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -118,19 +149,32 @@ export const matchesAPI = {
|
||||
* @returns List of matching matches
|
||||
*/
|
||||
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
|
||||
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
|
||||
params: {
|
||||
limit: params?.limit || 20,
|
||||
limit: limit + 1, // Request one extra to check if there are more
|
||||
map: params?.map,
|
||||
player_id: params?.player_id,
|
||||
before_time: params?.before_time
|
||||
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
|
||||
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
|
||||
return transformMatchesListResponse(data);
|
||||
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ export const playersAPI = {
|
||||
const transformedData = transformPlayerProfile(legacyData);
|
||||
|
||||
// Validate the player data
|
||||
// parsePlayer throws on validation failure, so player is always defined if we reach this point
|
||||
const player = parsePlayer(transformedData);
|
||||
|
||||
// Calculate aggregated stats from matches
|
||||
@@ -60,18 +61,19 @@ export const playersAPI = {
|
||||
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
|
||||
|
||||
// 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
|
||||
const playerMeta: PlayerMeta = {
|
||||
id: parseInt(player.id),
|
||||
id: parseInt(player.id, 10),
|
||||
name: player.name,
|
||||
avatar: player.avatar, // Already transformed by transformPlayerProfile
|
||||
recent_matches: recentMatches.length,
|
||||
last_match_date: lastMatchDate,
|
||||
avg_kills: avgKills,
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
/**
|
||||
* API Response Transformers
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
match_id: string;
|
||||
map: string;
|
||||
date: number; // Unix timestamp
|
||||
score: [number, number]; // [team_a, team_b]
|
||||
duration: number;
|
||||
match_result: number;
|
||||
max_rounds: number;
|
||||
parsed: boolean;
|
||||
vac: boolean;
|
||||
game_ban: boolean;
|
||||
match_id: string; // uint64 as string
|
||||
map: string; // Can be empty string if not parsed
|
||||
date: number; // Unix timestamp (seconds since epoch)
|
||||
score: [number, number]; // [team_a_score, team_b_score]
|
||||
duration: number; // Match duration in seconds
|
||||
match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win
|
||||
max_rounds: number; // 24 for MR12, 30 for MR15
|
||||
parsed: boolean; // Whether demo has been parsed (NOT demo_parsed)
|
||||
vac: boolean; // Whether any player has VAC ban (NOT vac_present)
|
||||
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 {
|
||||
match_id: string;
|
||||
@@ -33,14 +51,21 @@ export interface LegacyMatchDetail {
|
||||
duration: number;
|
||||
match_result: number;
|
||||
max_rounds: number;
|
||||
parsed: boolean;
|
||||
vac: boolean;
|
||||
game_ban: boolean;
|
||||
stats?: LegacyPlayerStats[];
|
||||
parsed: boolean; // NOT demo_parsed
|
||||
vac: boolean; // NOT vac_present
|
||||
game_ban: boolean; // NOT gameban_present
|
||||
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 {
|
||||
team_id: number;
|
||||
@@ -82,6 +107,16 @@ export interface LegacyPlayerStats {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return {
|
||||
@@ -91,21 +126,36 @@ export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListIt
|
||||
score_team_a: legacy.score[0],
|
||||
score_team_b: legacy.score[1],
|
||||
duration: legacy.duration,
|
||||
demo_parsed: legacy.parsed,
|
||||
player_count: 10 // Default to 10 players (5v5)
|
||||
demo_parsed: legacy.parsed // Rename: parsed → demo_parsed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
legacyMatches: LegacyMatchListItem[]
|
||||
legacyMatches: LegacyMatchListItem[],
|
||||
hasMore: boolean = false,
|
||||
nextPageTime?: number
|
||||
): MatchesListResponse {
|
||||
return {
|
||||
matches: legacyMatches.map(transformMatchListItem),
|
||||
has_more: false, // Legacy API doesn't provide pagination info
|
||||
next_page_time: undefined
|
||||
has_more: hasMore,
|
||||
next_page_time: nextPageTime
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +163,13 @@ export function transformMatchesListResponse(
|
||||
* Transform legacy player stats to new format
|
||||
*/
|
||||
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 {
|
||||
id: legacy.player.steamid64,
|
||||
name: legacy.player.name,
|
||||
@@ -124,7 +181,9 @@ export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
|
||||
headshot: legacy.headshot,
|
||||
mvp: legacy.mvp,
|
||||
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
|
||||
mk_2: legacy.multi_kills?.duo,
|
||||
mk_3: legacy.multi_kills?.triple,
|
||||
@@ -157,7 +216,6 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
||||
demo_parsed: legacy.parsed,
|
||||
vac_present: legacy.vac,
|
||||
gameban_present: legacy.game_ban,
|
||||
tick_rate: 64, // Default to 64, not provided by API
|
||||
players: legacy.stats?.map(transformPlayerStats)
|
||||
};
|
||||
}
|
||||
@@ -216,46 +274,59 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
|
||||
id: legacy.steamid64,
|
||||
name: legacy.name,
|
||||
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_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null,
|
||||
game_ban_count: legacy.game_ban ? 1 : 0,
|
||||
game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null,
|
||||
tracked: legacy.tracked,
|
||||
wins: legacy.match_stats?.win,
|
||||
losses: legacy.match_stats?.loss,
|
||||
matches: legacy.matches?.map((match) => ({
|
||||
match_id: match.match_id,
|
||||
map: match.map || 'unknown',
|
||||
date: new Date(match.date * 1000).toISOString(),
|
||||
score_team_a: match.score[0],
|
||||
score_team_b: match.score[1],
|
||||
duration: match.duration,
|
||||
match_result: match.match_result,
|
||||
max_rounds: match.max_rounds,
|
||||
demo_parsed: match.parsed,
|
||||
vac_present: match.vac,
|
||||
gameban_present: match.game_ban,
|
||||
tick_rate: 64, // Not provided by API
|
||||
stats: {
|
||||
id: legacy.steamid64,
|
||||
name: legacy.name,
|
||||
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
|
||||
// Fix team_id: API returns 1/2, but schema expects min 2
|
||||
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
|
||||
team_id:
|
||||
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
|
||||
kills: match.stats.kills,
|
||||
deaths: match.stats.deaths,
|
||||
assists: match.stats.assists,
|
||||
headshot: match.stats.headshot,
|
||||
mvp: match.stats.mvp,
|
||||
score: match.stats.score,
|
||||
kast: 0,
|
||||
mk_2: match.stats.multi_kills?.duo,
|
||||
mk_3: match.stats.multi_kills?.triple,
|
||||
mk_4: match.stats.multi_kills?.quad,
|
||||
mk_5: match.stats.multi_kills?.ace
|
||||
}
|
||||
}))
|
||||
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,
|
||||
map: match.map || 'unknown',
|
||||
date: new Date(match.date * 1000).toISOString(),
|
||||
score_team_a: match.score[0],
|
||||
score_team_b: match.score[1],
|
||||
duration: match.duration,
|
||||
match_result: match.match_result,
|
||||
max_rounds: match.max_rounds,
|
||||
demo_parsed: match.parsed,
|
||||
vac_present: match.vac,
|
||||
gameban_present: match.game_ban,
|
||||
stats: {
|
||||
id: legacy.steamid64,
|
||||
name: legacy.name,
|
||||
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
|
||||
// Fix team_id: API returns 1/2, but schema expects min 2
|
||||
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
|
||||
team_id:
|
||||
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
|
||||
kills: match.stats.kills,
|
||||
deaths: match.stats.deaths,
|
||||
assists: match.stats.assists,
|
||||
headshot: match.stats.headshot,
|
||||
mvp: match.stats.mvp,
|
||||
score: match.stats.score,
|
||||
// Premier rating (CS2: 0-30000)
|
||||
rank_old: rankOld,
|
||||
rank_new: rankNew,
|
||||
mk_2: match.stats.multi_kills?.duo,
|
||||
mk_3: match.stats.multi_kills?.triple,
|
||||
mk_4: match.stats.multi_kills?.quad,
|
||||
mk_5: match.stats.multi_kills?.ace
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
268
src/lib/components/RoundTimeline.svelte
Normal file
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;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
options = {},
|
||||
height = 300,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
let { data, options = {}, height = 300, class: className = '' }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'line'> | null = null;
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<a
|
||||
href="/"
|
||||
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>
|
||||
<a href="/" class="transition-transform hover:scale-105" aria-label="CS2.WTF Home">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
|
||||
</h1>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each $search.recentSearches as recent}
|
||||
<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)}
|
||||
>
|
||||
<Search class="h-3 w-3" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
||||
import { Moon, Sun, Monitor } from 'lucide-svelte';
|
||||
import { preferences } from '$lib/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -10,9 +10,8 @@
|
||||
{ value: 'auto', label: 'Auto', icon: Monitor }
|
||||
] as const;
|
||||
|
||||
const currentIcon = $derived(
|
||||
themes.find((t) => t.value === $preferences.theme)?.icon || Monitor
|
||||
);
|
||||
// Get current theme data
|
||||
const currentTheme = $derived(themes.find((t) => t.value === $preferences.theme) || themes[2]);
|
||||
|
||||
const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
if (!browser) return;
|
||||
@@ -50,19 +49,19 @@
|
||||
|
||||
<!-- Theme Toggle Dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle" aria-label="Theme">
|
||||
<svelte:component this={currentIcon} class="h-5 w-5" />
|
||||
<button tabindex="0" class="btn btn-circle btn-ghost" aria-label="Theme">
|
||||
<currentTheme.icon class="h-5 w-5" />
|
||||
</button>
|
||||
<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>
|
||||
<button
|
||||
class:active={$preferences.theme === value}
|
||||
onclick={() => handleThemeChange(value)}
|
||||
class:active={$preferences.theme === theme.value}
|
||||
onclick={() => handleThemeChange(theme.value)}
|
||||
>
|
||||
<svelte:component this={icon} class="h-4 w-4" />
|
||||
{label}
|
||||
{#if value === 'auto'}
|
||||
<theme.icon class="h-4 w-4" />
|
||||
{theme.label}
|
||||
{#if theme.value === 'auto'}
|
||||
<span class="text-xs text-base-content/60">(System)</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
import { storeMatchesState } from '$lib/utils/navigation';
|
||||
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
|
||||
|
||||
interface Props {
|
||||
match: MatchListItem;
|
||||
loadedCount?: number;
|
||||
}
|
||||
|
||||
let { match }: Props = $props();
|
||||
let { match, loadedCount = 0 }: Props = $props();
|
||||
|
||||
const formattedDate = new Date(match.date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
@@ -15,26 +18,53 @@
|
||||
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>
|
||||
|
||||
<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
|
||||
class="overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-shadow hover:shadow-xl"
|
||||
>
|
||||
<!-- Map Header -->
|
||||
<div class="relative h-32 bg-gradient-to-br from-base-300 to-base-200">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-5xl font-bold text-base-content/10">{mapName}</span>
|
||||
</div>
|
||||
<div class="absolute bottom-3 left-3">
|
||||
<Badge variant="default">{match.map}</Badge>
|
||||
</div>
|
||||
{#if match.demo_parsed}
|
||||
<div class="absolute right-3 top-3">
|
||||
<Badge variant="success" size="sm">Parsed</Badge>
|
||||
<!-- Map Header with Background Image -->
|
||||
<div class="relative h-32 overflow-hidden">
|
||||
<!-- Background Image -->
|
||||
<img
|
||||
src={mapBg}
|
||||
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>
|
||||
{/if}
|
||||
<span class="text-lg font-bold text-white drop-shadow-lg">{mapName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if match.demo_parsed}
|
||||
<Badge variant="success" size="sm">Parsed</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Info -->
|
||||
|
||||
155
src/lib/components/match/ShareCodeInput.svelte
Normal file
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
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">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
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 = {
|
||||
sm: 'max-w-md',
|
||||
@@ -44,9 +46,15 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
@@ -82,6 +90,13 @@
|
||||
<div class="p-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if actions}
|
||||
<div class="flex justify-end gap-2 border-t border-base-300 p-6">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
68
src/lib/components/ui/PremierRatingBadge.svelte
Normal file
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 sizeClass = size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
|
||||
const variantClass =
|
||||
variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
|
||||
const sizeClass =
|
||||
size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
|
||||
</script>
|
||||
|
||||
<div role="tablist" class="tabs {variantClass} {sizeClass} {className}">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
children?: any;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { text, position = 'top', children }: Props = $props();
|
||||
|
||||
@@ -19,7 +19,7 @@ export const matchPlayerSchema = z.object({
|
||||
headshot: z.number().int().nonnegative(),
|
||||
mvp: 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_old: z.number().int().min(0).max(30000).optional(),
|
||||
@@ -74,7 +74,7 @@ export const matchSchema = z.object({
|
||||
demo_parsed: z.boolean(),
|
||||
vac_present: z.boolean(),
|
||||
gameban_present: z.boolean(),
|
||||
tick_rate: z.number().positive(),
|
||||
tick_rate: z.number().positive().optional(),
|
||||
players: z.array(matchPlayerSchema).optional()
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export const matchListItemSchema = z.object({
|
||||
score_team_b: z.number().int().nonnegative(),
|
||||
duration: z.number().int().positive(),
|
||||
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 */
|
||||
|
||||
@@ -12,7 +12,7 @@ export const playerSchema = z.object({
|
||||
avatar: z.string().url(),
|
||||
vanity_url: 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(),
|
||||
wins: 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_date: z.string().datetime().nullable().optional(),
|
||||
oldest_sharecode_seen: z.string().optional(),
|
||||
tracked: z.boolean().optional(),
|
||||
matches: z
|
||||
.array(
|
||||
matchSchema.extend({
|
||||
|
||||
@@ -39,8 +39,8 @@ export interface Match {
|
||||
/** Whether any player has a game ban */
|
||||
gameban_present: boolean;
|
||||
|
||||
/** Server tick rate (64 or 128) */
|
||||
tick_rate: number;
|
||||
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
||||
tick_rate?: number;
|
||||
|
||||
/** Array of player statistics (optional, included in detailed match view) */
|
||||
players?: MatchPlayer[];
|
||||
@@ -57,7 +57,7 @@ export interface MatchListItem {
|
||||
score_team_b: number;
|
||||
duration: number;
|
||||
demo_parsed: boolean;
|
||||
player_count: number;
|
||||
player_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,8 +91,14 @@ export interface MatchPlayer {
|
||||
/** In-game score */
|
||||
score: number;
|
||||
|
||||
/** KAST percentage (0-100): Kill/Assist/Survive/Trade */
|
||||
kast: number;
|
||||
/** KAST percentage (0-100): Kill/Assist/Survive/Trade - optional, not always provided by API */
|
||||
kast?: number;
|
||||
|
||||
/** Average Damage per Round */
|
||||
adr?: number;
|
||||
|
||||
/** Headshot percentage */
|
||||
hs_percent?: number;
|
||||
|
||||
// Rank tracking (CS2 Premier rating: 0-30000)
|
||||
rank_old?: number;
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface Player {
|
||||
/** Actual vanity URL (may differ from vanity_url) */
|
||||
vanity_url_real?: string;
|
||||
|
||||
/** Last time Steam profile was updated (ISO 8601) */
|
||||
steam_updated: string;
|
||||
/** Last time Steam profile was updated (ISO 8601) - optional, not always provided by API */
|
||||
steam_updated?: string;
|
||||
|
||||
/** Steam account creation date (ISO 8601) */
|
||||
profile_created?: string;
|
||||
@@ -53,6 +53,9 @@ export interface Player {
|
||||
/** Oldest match share code seen for this player */
|
||||
oldest_sharecode_seen?: string;
|
||||
|
||||
/** Whether this player is being tracked for automatic match updates */
|
||||
tracked?: boolean;
|
||||
|
||||
/** Recent matches with player statistics */
|
||||
matches?: PlayerMatch[];
|
||||
}
|
||||
|
||||
154
src/lib/utils/export.ts
Normal file
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
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
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
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'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user