feat: Implement Phase 1 critical features and fix API integration

This commit completes the first phase of feature parity implementation and
resolves all API integration issues to match the backend API format.

## API Integration Fixes

- Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated)
- Update TypeScript types to make fields optional where backend doesn't guarantee them
- Update Zod schemas to validate optional fields correctly
- Fix mock data to match real API response format (plain arrays, not wrapped objects)
- Update UI components to handle undefined values with proper fallbacks
- Add comprehensive API documentation for Match and Player endpoints

## Phase 1 Features Implemented (3/6)

### 1. Player Tracking System 
- Created TrackPlayerModal.svelte with auth code input
- Integrated track/untrack player API endpoints
- Added UI for providing optional share code
- Displays tracked status on player profiles
- Full validation and error handling

### 2. Share Code Parsing 
- Created ShareCodeInput.svelte component
- Added to matches page for easy match submission
- Real-time validation of share code format
- Parse status feedback with loading states
- Auto-redirect to match page on success

### 3. VAC/Game Ban Status 
- Added VAC and game ban count/date fields to Player type
- Display status badges on player profile pages
- Show ban count and date when available
- Visual indicators using DaisyUI badge components

## Component Improvements

- Modal.svelte: Added Svelte 5 Snippet types, actions slot support
- ThemeToggle.svelte: Removed deprecated svelte:component usage
- Tooltip.svelte: Fixed type safety with Snippet type
- All new components follow Svelte 5 runes pattern ($state, $derived, $bindable)

## Type Safety & Linting

- Fixed all ESLint errors (any types → proper types)
- Fixed form label accessibility issues
- Replaced error: any with error: unknown + proper type guards
- Added Snippet type imports where needed
- Updated all catch blocks to use instanceof Error checks

## Static Assets

- Migrated all files from public/ to static/ directory per SvelteKit best practices
- Moved 200+ map icons, screenshots, and other assets
- Updated all import paths to use /images/ (served from static/)

## Documentation

- Created IMPLEMENTATION_STATUS.md tracking all 15 missing features
- Updated API.md with optional field annotations
- Created MATCHES_API.md with comprehensive endpoint documentation
- Added inline comments marking optional vs required fields

## Testing

- Updated mock fixtures to remove default values
- Fixed mock handlers to return plain arrays like real API
- Ensured all components handle undefined gracefully

## Remaining Phase 1 Tasks

- [ ] Add VAC status column to match scoreboard
- [ ] Create weapons statistics tab for matches
- [ ] Implement recently visited players on home page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 19:31:18 +01:00
parent a861b1c1b6
commit 8f3b652740
422 changed files with 106174 additions and 102193 deletions

View File

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

View File

@@ -44,12 +44,7 @@
class?: string;
}
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;

View File

@@ -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 -->

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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 -->

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
<script lang="ts">
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}

View File

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

View File

@@ -43,8 +43,10 @@
}
};
const variantClass = variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
const 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}">

View File

@@ -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();