feat: Implement Phase 5 match detail tabs with charts and data visualization
This commit implements significant portions of Phase 5 (Feature Delivery) including: Chart Components (src/lib/components/charts/): - LineChart.svelte: Line charts with Chart.js integration - BarChart.svelte: Vertical/horizontal bar charts with stacking - PieChart.svelte: Pie/Doughnut charts with legend - All charts use Svelte 5 runes ($effect) for reactivity - Responsive design with customizable options Data Display Components (src/lib/components/data-display/): - DataTable.svelte: Generic sortable, filterable table component - TypeScript generics support for type safety - Custom formatters and renderers - Sort indicators and column alignment options Match Detail Pages: - Match layout with header, tabs, and score display - Economy tab: Equipment value charts, buy type classification, round-by-round table - Details tab: Multi-kill distribution charts, team performance, top performers - Chat tab: Chronological messages with filtering, search, and round grouping Additional Components: - SearchBar, ThemeToggle (layout components) - MatchCard, PlayerCard (domain components) - Modal, Skeleton, Tabs, Tooltip (UI components) - Player profile page with stats and recent matches Dependencies: - Installed chart.js for data visualization - Created Svelte 5 compatible chart wrappers Phase 4 marked as complete, Phase 5 at 50% completion. Flashes and Damage tabs deferred for future implementation. Note: Minor linting warnings to be addressed in follow-up commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
126
src/lib/components/charts/BarChart.svelte
Normal file
126
src/lib/components/charts/BarChart.svelte
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
BarController,
|
||||
BarElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string | string[];
|
||||
borderColor?: string | string[];
|
||||
borderWidth?: number;
|
||||
}>;
|
||||
};
|
||||
options?: Partial<ChartConfiguration<'bar'>['options']>;
|
||||
height?: number;
|
||||
horizontal?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
options = {},
|
||||
height = 300,
|
||||
horizontal = false,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'bar'> | null = null;
|
||||
|
||||
const defaultOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: horizontal ? 'y' : 'x',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes and update chart
|
||||
$effect(() => {
|
||||
if (chart) {
|
||||
chart.data = data;
|
||||
chart.options = { ...defaultOptions, ...options };
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full {className}" style="height: {height}px">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
140
src/lib/components/charts/LineChart.svelte
Normal file
140
src/lib/components/charts/LineChart.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
}>;
|
||||
};
|
||||
options?: Partial<ChartConfiguration<'line'>['options']>;
|
||||
height?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
options = {},
|
||||
height = 300,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'line'> | null = null;
|
||||
|
||||
const defaultOptions: ChartConfiguration<'line'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes and update chart
|
||||
$effect(() => {
|
||||
if (chart) {
|
||||
chart.data = data;
|
||||
chart.options = { ...defaultOptions, ...options };
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full {className}" style="height: {height}px">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
101
src/lib/components/charts/PieChart.svelte
Normal file
101
src/lib/components/charts/PieChart.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
DoughnutController,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartConfiguration
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(DoughnutController, ArcElement, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label?: string;
|
||||
data: number[];
|
||||
backgroundColor?: string[];
|
||||
borderColor?: string[];
|
||||
borderWidth?: number;
|
||||
}>;
|
||||
};
|
||||
options?: Partial<ChartConfiguration<'doughnut'>['options']>;
|
||||
height?: number;
|
||||
doughnut?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
options = {},
|
||||
height = 300,
|
||||
doughnut = true,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart<'doughnut'> | null = null;
|
||||
|
||||
const defaultOptions: ChartConfiguration<'doughnut'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: doughnut ? '60%' : '0%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif',
|
||||
size: 12
|
||||
},
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for data changes and update chart
|
||||
$effect(() => {
|
||||
if (chart) {
|
||||
chart.data = data;
|
||||
chart.options = { ...defaultOptions, ...options };
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative w-full {className}" style="height: {height}px">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
117
src/lib/components/data-display/DataTable.svelte
Normal file
117
src/lib/components/data-display/DataTable.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { ArrowUp, ArrowDown } from 'lucide-svelte';
|
||||
|
||||
interface Column<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
format?: (value: any, row: T) => string;
|
||||
render?: (value: any, row: T) => any;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
class?: string;
|
||||
striped?: boolean;
|
||||
hoverable?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
class: className = '',
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
compact = false
|
||||
}: Props = $props();
|
||||
|
||||
let sortKey = $state<keyof T | null>(null);
|
||||
let sortDirection = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
const handleSort = (column: Column<T>) => {
|
||||
if (!column.sortable) return;
|
||||
|
||||
if (sortKey === column.key) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortKey = column.key;
|
||||
sortDirection = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = $derived(() => {
|
||||
if (!sortKey) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = a[sortKey];
|
||||
const bVal = b[sortKey];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1;
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
})();
|
||||
|
||||
const getValue = (row: T, column: Column<T>) => {
|
||||
const value = row[column.key];
|
||||
if (column.format) {
|
||||
return column.format(value, row);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto {className}">
|
||||
<table class="table" class:table-zebra={striped} class:table-xs={compact}>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th
|
||||
class:cursor-pointer={column.sortable}
|
||||
class:hover:bg-base-200={column.sortable}
|
||||
class="text-{column.align || 'left'} {column.class || ''}"
|
||||
onclick={() => handleSort(column)}
|
||||
>
|
||||
<div class="flex items-center gap-2" class:justify-end={column.align === 'right'} class:justify-center={column.align === 'center'}>
|
||||
<span>{column.label}</span>
|
||||
{#if column.sortable}
|
||||
<div class="flex flex-col opacity-40">
|
||||
<ArrowUp
|
||||
class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc'
|
||||
? 'text-primary opacity-100'
|
||||
: ''}"
|
||||
/>
|
||||
<ArrowDown
|
||||
class="h-3 w-3 -mt-1 {sortKey === column.key && sortDirection === 'desc'
|
||||
? 'text-primary opacity-100'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedData as row, i}
|
||||
<tr class:hover={hoverable}>
|
||||
{#each columns as column}
|
||||
<td class="text-{column.align || 'left'} {column.class || ''}">
|
||||
{#if column.render}
|
||||
{@render column.render(row[column.key], row)}
|
||||
{:else}
|
||||
{getValue(row, column)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Search, Menu, X } from 'lucide-svelte';
|
||||
import { Menu, X } from 'lucide-svelte';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
@@ -35,14 +37,9 @@
|
||||
</nav>
|
||||
|
||||
<!-- Search & Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm hidden md:inline-flex"
|
||||
aria-label="Search"
|
||||
title="Search (Cmd+K)"
|
||||
>
|
||||
<Search class="h-5 w-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchBar />
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button
|
||||
|
||||
116
src/lib/components/layout/SearchBar.svelte
Normal file
116
src/lib/components/layout/SearchBar.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Search, Command } from 'lucide-svelte';
|
||||
import { search } from '$lib/stores';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let searchInput: HTMLInputElement;
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl + K
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
// Add to recent searches
|
||||
search.addRecentSearch(query);
|
||||
|
||||
// Navigate to matches page with search query
|
||||
goto(`/matches?search=${encodeURIComponent(query)}`);
|
||||
|
||||
// Close modal and clear
|
||||
open = false;
|
||||
query = '';
|
||||
};
|
||||
|
||||
const handleRecentClick = (recentQuery: string) => {
|
||||
query = recentQuery;
|
||||
handleSearch(new Event('submit'));
|
||||
};
|
||||
|
||||
const handleClearRecent = () => {
|
||||
search.clearRecentSearches();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Search Button (Header) -->
|
||||
<button
|
||||
class="btn btn-ghost gap-2"
|
||||
onclick={() => {
|
||||
open = true;
|
||||
setTimeout(() => searchInput?.focus(), 100);
|
||||
}}
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5" />
|
||||
<span class="hidden md:inline">Search</span>
|
||||
<kbd class="kbd kbd-sm hidden lg:inline-flex">
|
||||
<Command class="h-3 w-3" />
|
||||
K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<Modal bind:open size="lg">
|
||||
<div class="space-y-4">
|
||||
<form onsubmit={handleSearch}>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<Search class="h-5 w-5 text-base-content/60" />
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search matches, players, share codes..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<kbd class="kbd kbd-sm">
|
||||
<Command class="h-3 w-3" />
|
||||
K
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
{#if $search.recentSearches.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-base-content/70">Recent Searches</h3>
|
||||
<button class="btn btn-ghost btn-xs" onclick={handleClearRecent}>Clear</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each $search.recentSearches as recent}
|
||||
<button
|
||||
class="badge badge-lg badge-outline gap-2 hover:badge-primary"
|
||||
onclick={() => handleRecentClick(recent)}
|
||||
>
|
||||
<Search class="h-3 w-3" />
|
||||
{recent}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Tips -->
|
||||
<div class="rounded-lg bg-base-200 p-4">
|
||||
<h4 class="mb-2 text-sm font-semibold text-base-content">Search Tips</h4>
|
||||
<ul class="space-y-1 text-xs text-base-content/70">
|
||||
<li>• Search by player name or Steam ID</li>
|
||||
<li>• Enter share code to find specific match</li>
|
||||
<li>• Use map name to filter matches (e.g., "de_dust2")</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
74
src/lib/components/layout/ThemeToggle.svelte
Normal file
74
src/lib/components/layout/ThemeToggle.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
||||
import { preferences } from '$lib/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const themes = [
|
||||
{ value: 'cs2light', label: 'Light', icon: Sun },
|
||||
{ value: 'cs2dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'auto', label: 'Auto', icon: Monitor }
|
||||
] as const;
|
||||
|
||||
const currentIcon = $derived(
|
||||
themes.find((t) => t.value === $preferences.theme)?.icon || Monitor
|
||||
);
|
||||
|
||||
const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
if (!browser) return;
|
||||
|
||||
let actualTheme = theme;
|
||||
|
||||
if (theme === 'auto') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
actualTheme = isDark ? 'cs2dark' : 'cs2light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', actualTheme);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
|
||||
preferences.setTheme(theme);
|
||||
applyTheme(theme);
|
||||
};
|
||||
|
||||
// Apply theme on mount and when system preference changes
|
||||
onMount(() => {
|
||||
applyTheme($preferences.theme);
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => {
|
||||
if ($preferences.theme === 'auto') {
|
||||
applyTheme('auto');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Theme Toggle Dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle" aria-label="Theme">
|
||||
{@const IconComponent = currentIcon}
|
||||
<IconComponent 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 }}
|
||||
{@const IconComponent = icon}
|
||||
<li>
|
||||
<button
|
||||
class:active={$preferences.theme === value}
|
||||
onclick={() => handleThemeChange(value)}
|
||||
>
|
||||
<IconComponent class="h-4 w-4" />
|
||||
{label}
|
||||
{#if value === 'auto'}
|
||||
<span class="text-xs text-base-content/60">(System)</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
69
src/lib/components/match/MatchCard.svelte
Normal file
69
src/lib/components/match/MatchCard.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { MatchListItem } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
match: MatchListItem;
|
||||
}
|
||||
|
||||
let { match }: Props = $props();
|
||||
|
||||
const formattedDate = new Date(match.date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const mapName = match.map.replace('de_', '').toUpperCase();
|
||||
</script>
|
||||
|
||||
<a href={`/match/${match.match_id}`} class="block transition-transform hover:scale-[1.02]">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Match Info -->
|
||||
<div class="p-4">
|
||||
<!-- Score -->
|
||||
<div class="mb-3 flex items-center justify-center gap-3">
|
||||
<span class="font-mono text-2xl font-bold text-terrorist">{match.score_team_a}</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
<span class="font-mono text-2xl font-bold text-ct">{match.score_team_b}</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex items-center justify-between text-sm text-base-content/60">
|
||||
<span>{formattedDate}</span>
|
||||
{#if match.duration}
|
||||
<span>{Math.floor(match.duration / 60)}m</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Result Badge -->
|
||||
<div class="mt-3 flex justify-center">
|
||||
{#if match.match_result === 0}
|
||||
<Badge variant="warning" size="sm">Tie</Badge>
|
||||
{:else if match.match_result === 1}
|
||||
<Badge variant="success" size="sm">Team A Win</Badge>
|
||||
{:else if match.match_result === 2}
|
||||
<Badge variant="error" size="sm">Team B Win</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
75
src/lib/components/player/PlayerCard.svelte
Normal file
75
src/lib/components/player/PlayerCard.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { User, TrendingUp, Target } from 'lucide-svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { PlayerMeta } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
player: PlayerMeta;
|
||||
showStats?: boolean;
|
||||
}
|
||||
|
||||
let { player, showStats = true }: Props = $props();
|
||||
|
||||
const kd = player.deaths > 0 ? (player.kills / player.deaths).toFixed(2) : player.kills.toFixed(2);
|
||||
const winRate = player.wins + player.losses > 0
|
||||
? ((player.wins / (player.wins + player.losses)) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="block overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-all hover:scale-[1.02] hover:shadow-xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-primary/20 to-secondary/20 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-base-100">
|
||||
<User class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="truncate text-lg font-bold text-base-content">{player.name}</h3>
|
||||
<p class="text-sm text-base-content/60">ID: {player.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showStats}
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-4 p-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<Target class="mr-1 h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{kd}</div>
|
||||
<div class="text-xs text-base-content/60">K/D</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<TrendingUp class="mr-1 h-4 w-4 text-success" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{winRate}%</div>
|
||||
<div class="text-xs text-base-content/60">Win Rate</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mb-1 flex items-center justify-center">
|
||||
<User class="mr-1 h-4 w-4 text-info" />
|
||||
</div>
|
||||
<div class="text-xl font-bold text-base-content">{player.wins + player.losses}</div>
|
||||
<div class="text-xs text-base-content/60">Matches</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-base-300 bg-base-200 px-4 py-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">Record:</span>
|
||||
<div class="flex gap-2">
|
||||
<Badge variant="success" size="sm">{player.wins}W</Badge>
|
||||
<Badge variant="error" size="sm">{player.losses}L</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
87
src/lib/components/ui/Modal.svelte
Normal file
87
src/lib/components/ui/Modal.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
onClose?: () => void;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), title, size = 'md', onClose, children }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
xl: 'max-w-6xl'
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
open = false;
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative w-full {sizeClasses[size]} rounded-lg bg-base-100 shadow-xl"
|
||||
transition:fly={{ y: -20, duration: 300 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between border-b border-base-300 p-6">
|
||||
<h2 id="modal-title" class="text-2xl font-bold text-base-content">{title}</h2>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm absolute right-4 top-4 z-10"
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
26
src/lib/components/ui/Skeleton.svelte
Normal file
26
src/lib/components/ui/Skeleton.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
width?: string;
|
||||
height?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { variant = 'rectangular', width, height, class: className = '' }: Props = $props();
|
||||
|
||||
const baseClasses = 'animate-pulse bg-base-300';
|
||||
|
||||
const variantClasses = {
|
||||
text: 'rounded h-4',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded'
|
||||
};
|
||||
|
||||
const style = [width ? `width: ${width};` : '', height ? `height: ${height};` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
</script>
|
||||
|
||||
<div class="{baseClasses} {variantClasses[variant]} {className}" {style} role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
77
src/lib/components/ui/Tabs.svelte
Normal file
77
src/lib/components/ui/Tabs.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
href?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (value: string) => void;
|
||||
variant?: 'boxed' | 'bordered' | 'lifted';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
tabs,
|
||||
activeTab = $bindable(),
|
||||
onTabChange,
|
||||
variant = 'bordered',
|
||||
size = 'md',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// If using href-based tabs, derive active from current route
|
||||
const isActive = (tab: Tab): boolean => {
|
||||
if (tab.href) {
|
||||
return $page.url.pathname === tab.href || $page.url.pathname.startsWith(tab.href + '/');
|
||||
}
|
||||
return activeTab === tab.value;
|
||||
};
|
||||
|
||||
const handleTabClick = (tab: Tab) => {
|
||||
if (tab.disabled) return;
|
||||
|
||||
if (tab.value && !tab.href) {
|
||||
activeTab = tab.value;
|
||||
onTabChange?.(tab.value);
|
||||
}
|
||||
};
|
||||
|
||||
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}">
|
||||
{#each tabs as tab}
|
||||
{#if tab.href}
|
||||
<a
|
||||
href={tab.href}
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
aria-disabled={tab.disabled}
|
||||
data-sveltekit-preload-data="hover"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
disabled={tab.disabled}
|
||||
onclick={() => handleTabClick(tab)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
20
src/lib/components/ui/Tooltip.svelte
Normal file
20
src/lib/components/ui/Tooltip.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { text, position = 'top', children }: Props = $props();
|
||||
|
||||
const positionClass = {
|
||||
top: 'tooltip-top',
|
||||
bottom: 'tooltip-bottom',
|
||||
left: 'tooltip-left',
|
||||
right: 'tooltip-right'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="tooltip {positionClass[position]}" data-tip={text}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
Reference in New Issue
Block a user