forked from CSGOWTF/csgowtf
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:
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