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:
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();
|
||||
|
||||
Reference in New Issue
Block a user