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:
2025-11-04 21:17:32 +01:00
parent 24b990ac62
commit 523136ffbc
30 changed files with 11721 additions and 9195 deletions

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

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

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

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

View File

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

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

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

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

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

View 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}

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

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

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