feat: Redesign matches page with neon styling and UX improvements

- Convert matches page from DaisyUI to neon esports design system
- Add colored left borders to cards for instant win/loss/tie scanning
- Add player count badges and demo status icons to match cards
- Implement filter state preservation across navigation
- Add staggered card animations and skeleton loading states
- Add slide transition for filter panel
- Make cards compact with horizontal layout for better density
- Update grid to 4 columns on xl screens
- Style DataTable, ShareCodeInput with neon theme
- Add external link support to NeonButton

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 17:11:19 +01:00
parent cdc70403f9
commit 6dc12f0c35
9 changed files with 824 additions and 488 deletions

View File

@@ -129,6 +129,23 @@
}
}
/* Card fade-in animation with stagger support */
.animate-card-in {
animation: cardFadeIn 0.4s ease-out forwards;
opacity: 0;
}
@keyframes cardFadeIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Neon Text Glow Effects */
.text-glow-sm {
text-shadow: 0 0 10px currentColor;

View File

@@ -10,7 +10,7 @@
render?: (value: T[keyof T], row: T) => unknown;
align?: 'left' | 'center' | 'right';
class?: string;
width?: string; // e.g., '200px', '30%', 'auto'
width?: string;
}
interface Props {
@@ -20,7 +20,7 @@
striped?: boolean;
hoverable?: boolean;
compact?: boolean;
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths
fixedLayout?: boolean;
}
let {
@@ -71,19 +71,18 @@
</script>
<div class="overflow-x-auto {className}">
<table
class="table"
class:table-zebra={striped}
class:table-xs={compact}
style={fixedLayout ? 'table-layout: fixed;' : ''}
>
<table class="w-full border-collapse" style={fixedLayout ? 'table-layout: fixed;' : ''}>
<thead>
<tr>
<tr class="border-b border-white/10 bg-void">
{#each columns as column}
<th
class:cursor-pointer={column.sortable}
class:hover:bg-base-200={column.sortable}
class="text-{column.align || 'left'} {column.class || ''}"
class="px-4 text-left text-xs font-semibold uppercase tracking-wider text-white/60 {compact
? 'py-2'
: 'py-3'} {column.sortable
? 'cursor-pointer transition-colors hover:bg-neon-blue/10 hover:text-neon-blue'
: ''} {column.class || ''}"
class:text-center={column.align === 'center'}
class:text-right={column.align === 'right'}
style={column.width ? `width: ${column.width}` : ''}
onclick={() => handleSort(column)}
>
@@ -94,16 +93,16 @@
>
<span>{column.label}</span>
{#if column.sortable}
<div class="flex flex-col opacity-40">
<div class="flex flex-col">
<ArrowUp
class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc'
? 'text-primary opacity-100'
: ''}"
? 'text-neon-blue'
: 'text-white/30'}"
/>
<ArrowDown
class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc'
? 'text-primary opacity-100'
: ''}"
? 'text-neon-blue'
: 'text-white/30'}"
/>
</div>
{/if}
@@ -113,10 +112,18 @@
</tr>
</thead>
<tbody>
{#each sortedData as row}
<tr class:hover={hoverable}>
{#each sortedData as row, index}
<tr
class="border-b border-white/5 transition-colors {hoverable
? 'hover:bg-neon-blue/5'
: ''} {striped && index % 2 === 1 ? 'bg-white/[0.02]' : ''}"
>
{#each columns as column}
<td class="text-{column.align || 'left'} {column.class || ''}">
<td
class="px-4 text-white/80 {compact ? 'py-2' : 'py-3'} {column.class || ''}"
class:text-center={column.align === 'center'}
class:text-right={column.align === 'right'}
>
{#if column.render}
{@html column.render(row[column.key], row)}
{:else}
@@ -129,3 +136,50 @@
</tbody>
</table>
</div>
<style>
/* Style links and buttons within table cells */
:global(td a) {
color: rgb(0, 212, 255);
transition: color 0.2s;
}
:global(td a:hover) {
color: rgb(0, 170, 204);
}
:global(td .btn-primary) {
background-color: rgb(0, 212, 255);
color: rgb(10, 10, 15);
border: none;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s;
}
:global(td .btn-primary:hover) {
box-shadow: 0 0 15px rgba(0, 212, 255, 0.4);
transform: scale(1.02);
}
/* Neon badge styling for result badges */
:global(td .badge-success) {
background-color: rgba(0, 255, 136, 0.1);
color: rgb(0, 255, 136);
border: 1px solid rgba(0, 255, 136, 0.3);
}
:global(td .badge-error) {
background-color: rgba(255, 51, 102, 0.1);
color: rgb(255, 51, 102);
border: 1px solid rgba(255, 51, 102, 0.3);
}
:global(td .badge-warning) {
background-color: rgba(255, 215, 0, 0.1);
color: rgb(255, 215, 0);
border: 1px solid rgba(255, 215, 0, 0.3);
}
:global(td .badge) {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
</style>

View File

@@ -47,7 +47,7 @@
Browse Matches
</NeonButton>
<NeonButton href="https://somegit.dev/CSGOWTF/csgowtf" variant="gold" size="lg">
<NeonButton href="https://somegit.dev/CSGOWTF/csgowtf" variant="gold" size="lg" external>
<Github class="mr-2 h-5 w-5" aria-hidden="true" />
View on GitHub
</NeonButton>

View File

@@ -1,15 +1,17 @@
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import { CheckCircle2, Clock } from 'lucide-svelte';
import type { MatchListItem } from '$lib/types';
import { storeMatchesState } from '$lib/utils/navigation';
import { storeMatchesState, type FilterState } from '$lib/utils/navigation';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
interface Props {
match: MatchListItem;
loadedCount?: number;
filters?: FilterState;
index?: number;
}
let { match, loadedCount = 0 }: Props = $props();
let { match, loadedCount = 0, filters, index = 0 }: Props = $props();
const formattedDate = new Date(match.date).toLocaleString('en-US', {
month: 'short',
@@ -21,9 +23,28 @@
const mapName = formatMapName(match.map);
const mapBg = getMapBackground(match.map);
// Derive match result for colored border
const matchResult = $derived.by(() => {
if (match.score_team_a > match.score_team_b) return 'win';
if (match.score_team_a < match.score_team_b) return 'loss';
return 'tie';
});
// Border color class based on result
const resultBorderColor = $derived.by(() => {
const colors = {
win: 'border-l-neon-green',
loss: 'border-l-neon-red',
tie: 'border-l-neon-gold'
};
return colors[matchResult];
});
// Stagger delay for animation (cap at 20 items per batch)
const staggerDelay = $derived(`${Math.min(index % 20, 19) * 50}ms`);
function handleClick() {
// Store navigation state before navigating
storeMatchesState(match.match_id, loadedCount);
storeMatchesState(match.match_id, loadedCount, filters);
}
function handleImageError(event: Event) {
@@ -34,66 +55,86 @@
<a
href={`/match/${match.match_id}`}
class="block transition-transform hover:scale-[1.02]"
class="animate-card-in group block transition-transform duration-300 hover:scale-[1.01] motion-reduce:animate-none motion-reduce:hover:scale-100"
style="animation-delay: {staggerDelay};"
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"
class="overflow-hidden rounded-lg border border-l-4 border-white/10 {resultBorderColor} bg-void-light transition-all duration-300 group-hover:border-l-4 group-hover:border-neon-blue/50 group-hover:{resultBorderColor} group-hover:shadow-[0_0_20px_rgba(0,212,255,0.1)]"
>
<!-- Map Header with Background Image -->
<div class="relative h-32 overflow-hidden">
<div class="relative h-20 overflow-hidden">
<!-- Background Image -->
<img
src={mapBg}
alt={mapName}
class="absolute inset-0 h-full w-full object-cover"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
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>
<div class="absolute inset-0 bg-gradient-to-t from-void via-void/70 to-transparent"></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 class="relative flex h-full items-end justify-between p-2">
<div class="flex items-center gap-2">
<span
class="text-sm font-bold text-white drop-shadow-lg"
style="text-shadow: 0 0 8px rgba(0, 212, 255, 0.3);"
>
{mapName}
</span>
</div>
{#if match.demo_parsed}
<Badge variant="success" size="sm">Parsed</Badge>
<!-- Status badges - horizontal layout -->
<div class="flex items-center gap-1">
{#if match.player_count}
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/20 px-1.5 py-0.5 text-[10px] text-neon-blue backdrop-blur-sm"
title="{match.player_count} players"
>
{match.player_count >= 10 ? '5v5' : `${match.player_count}p`}
</span>
{/if}
<div
class="flex items-center gap-0.5 rounded-full border border-white/10 bg-void/50 px-1.5 py-0.5 backdrop-blur-sm"
title={match.demo_parsed ? 'Demo fully parsed' : 'Demo processing'}
>
{#if match.demo_parsed}
<CheckCircle2 class="h-2.5 w-2.5 text-neon-green" aria-hidden="true" />
{:else}
<Clock class="h-2.5 w-2.5 animate-pulse text-neon-gold" aria-hidden="true" />
{/if}
</div>
</div>
</div>
</div>
<!-- Match Info -->
<div class="p-4">
<!-- Match Info - Compact -->
<div class="flex items-center justify-between px-3 py-2">
<!-- 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 class="flex items-center gap-2">
<span
class="font-mono text-lg font-bold text-terrorist"
style="text-shadow: 0 0 8px rgba(212, 167, 74, 0.4);"
>
{match.score_team_a}
</span>
<span class="text-xs text-white/30">-</span>
<span
class="font-mono text-lg font-bold text-ct"
style="text-shadow: 0 0 8px rgba(94, 152, 217, 0.4);"
>
{match.score_team_b}
</span>
</div>
<!-- Meta -->
<div class="flex items-center justify-between text-sm text-base-content/60">
<div class="flex items-center gap-3 text-xs text-white/50">
<span>{formattedDate}</span>
{#if match.duration}
<span>{Math.floor(match.duration / 60)}m</span>
{/if}
</div>
<!-- Result Badge (inferred from score) -->
<div class="mt-3 flex justify-center">
{#if match.score_team_a === match.score_team_b}
<Badge variant="warning" size="sm">Tie</Badge>
{:else if match.score_team_a > match.score_team_b}
<Badge variant="success" size="sm">Team A Win</Badge>
{:else}
<Badge variant="error" size="sm">Team B Win</Badge>
{/if}
</div>
</div>
</div>
</a>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
// Compact skeleton loading component for MatchCard
</script>
<div class="block">
<div
class="overflow-hidden rounded-lg border border-l-4 border-white/10 border-l-white/20 bg-void-light"
>
<!-- Map Header Skeleton - Compact -->
<div class="relative h-20 overflow-hidden bg-void-light">
<div class="absolute inset-0 animate-pulse bg-white/5"></div>
<div class="absolute inset-0 bg-gradient-to-t from-void via-void/70 to-transparent"></div>
<div class="relative flex h-full items-end justify-between p-2">
<div class="h-4 w-20 animate-pulse rounded bg-white/15"></div>
<div class="flex items-center gap-1">
<div class="h-4 w-8 animate-pulse rounded-full bg-white/10"></div>
<div class="h-4 w-4 animate-pulse rounded-full bg-white/10"></div>
</div>
</div>
</div>
<!-- Match Info Skeleton - Compact -->
<div class="flex items-center justify-between px-3 py-2">
<div class="flex items-center gap-2">
<div class="h-5 w-6 animate-pulse rounded bg-white/10"></div>
<span class="text-white/20">-</span>
<div class="h-5 w-6 animate-pulse rounded bg-white/10"></div>
</div>
<div class="flex items-center gap-3">
<div class="h-3 w-16 animate-pulse rounded bg-white/10"></div>
<div class="h-3 w-8 animate-pulse rounded bg-white/10"></div>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
import { Upload, Check, AlertCircle, Loader2, ChevronDown } from 'lucide-svelte';
import { matchesAPI } from '$lib/api/matches';
import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
@@ -9,10 +9,10 @@
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
let statusMessage = $state('');
let parsedMatchId = $state('');
let showHelp = $state(false);
// 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());
}
@@ -47,7 +47,6 @@
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
toast.success('Match submitted for parsing!');
// Wait a moment then redirect to the match page
setTimeout(() => {
goto(`/match/${response.match_id}`);
}, 2000);
@@ -75,22 +74,22 @@
<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>
<div>
<label class="mb-2 block text-sm font-medium text-white" for="shareCode">
Submit Match Share Code
</label>
<div class="flex gap-2">
<div class="flex gap-3">
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered flex-1"
class="flex-1 rounded-lg border border-neon-blue/30 bg-void px-4 py-3 font-mono text-white transition-colors placeholder:text-white/40 focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue disabled:cursor-not-allowed disabled:opacity-50"
bind:value={shareCode}
disabled={isLoading}
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
/>
<button
class="btn btn-primary"
class="flex items-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-semibold text-void transition-all hover:scale-105 hover:shadow-[0_0_20px_rgba(0,212,255,0.4)] focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue focus-visible:ring-offset-2 focus-visible:ring-offset-void disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
onclick={handleSubmit}
disabled={isLoading || !shareCode.trim()}
>
@@ -102,54 +101,78 @@
Parse
</button>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
<p class="mt-2 text-sm text-white/50">
Submit a CS2 match share code to add it to the database
</span>
</div>
</p>
</div>
<!-- Status Messages -->
{#if parseStatus !== 'idle'}
<div
class="alert {parseStatus === 'success'
? 'alert-success'
class="flex items-start gap-3 rounded-lg border p-4 {parseStatus === 'success'
? 'border-neon-green/30 bg-neon-green/10'
: parseStatus === 'error'
? 'alert-error'
: 'alert-info'}"
? 'border-neon-red/30 bg-neon-red/10'
: 'border-neon-blue/30 bg-neon-blue/10'}"
>
{#if parseStatus === 'parsing'}
<Loader2 class="h-6 w-6 shrink-0 animate-spin stroke-current" />
<Loader2 class="h-5 w-5 shrink-0 animate-spin text-neon-blue" />
{:else if parseStatus === 'success'}
<Check class="h-6 w-6 shrink-0 stroke-current" />
<Check class="h-5 w-5 shrink-0 text-neon-green" />
{:else}
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
<AlertCircle class="h-5 w-5 shrink-0 text-neon-red" />
{/if}
<div class="flex-1">
<p>{statusMessage}</p>
<p
class={parseStatus === 'success'
? 'text-neon-green'
: parseStatus === 'error'
? 'text-neon-red'
: 'text-neon-blue'}
>
{statusMessage}
</p>
{#if parseStatus === 'success' && parsedMatchId}
<p class="mt-1 text-sm">Redirecting to match page...</p>
<p class="mt-1 text-sm text-white/50">Redirecting to match page...</p>
{/if}
</div>
{#if parseStatus !== 'parsing'}
<button class="btn btn-ghost btn-sm" onclick={resetForm}>Dismiss</button>
<button
class="rounded px-2 py-1 text-sm text-white/50 transition-colors hover:bg-white/10 hover:text-white"
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">
<!-- Help Text (Collapsible) -->
<div class="rounded-lg border border-white/10 bg-void">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-white/70 transition-colors hover:text-white"
onclick={() => (showHelp = !showHelp)}
>
<span>How to get your match share code</span>
<ChevronDown
class="h-4 w-4 transition-transform duration-200 {showHelp ? 'rotate-180' : ''}"
/>
</button>
{#if showHelp}
<div class="border-t border-white/10 px-4 py-3">
<ol class="list-inside list-decimal space-y-2 text-sm text-white/60">
<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">
<p class="mt-3 text-xs text-white/40">
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>
{/if}
</div>
</div>

View File

@@ -8,6 +8,7 @@
children: Snippet;
onclick?: () => void;
class?: string;
external?: boolean;
}
let {
@@ -16,7 +17,8 @@
size = 'md',
children,
onclick,
class: className = ''
class: className = '',
external = false
}: Props = $props();
const variantClasses = {
@@ -60,6 +62,7 @@
<a
{href}
class="inline-flex items-center justify-center rounded-lg font-semibold transition-all duration-300 hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-void motion-reduce:transition-none motion-reduce:hover:scale-100 {classes.bg} {classes.text} {classes.glow} {sizeClass} {className}"
{...external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
>
{@render children()}
</a>

View File

@@ -5,24 +5,41 @@
const STORAGE_KEY = 'matches-navigation-state';
/**
* Filter state for matches page - used for preserving filters across navigation
*/
export interface FilterState {
resultFilter: 'all' | 'win' | 'loss' | 'tie';
sortBy: 'date' | 'duration' | 'score';
sortOrder: 'desc' | 'asc';
fromDate: string;
toDate: string;
}
interface NavigationState {
matchId: string;
scrollY: number;
timestamp: number;
loadedCount: number; // Number of matches loaded (for pagination)
filters?: FilterState; // Optional filter state
}
/**
* Store navigation state when leaving the matches page
*/
export function storeMatchesState(matchId: string, loadedCount: number): void {
export function storeMatchesState(
matchId: string,
loadedCount: number,
filters?: FilterState
): void {
if (typeof window === 'undefined') return;
const state: NavigationState = {
matchId,
scrollY: window.scrollY,
timestamp: Date.now(),
loadedCount
loadedCount,
filters
};
try {

View File

@@ -10,13 +10,12 @@
LayoutGrid,
Table as TableIcon
} from 'lucide-svelte';
import { slide } from 'svelte/transition';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import MatchCardSkeleton from '$lib/components/match/MatchCardSkeleton.svelte';
import ShareCodeInput from '$lib/components/match/ShareCodeInput.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import type { PageData } from './$types';
@@ -26,7 +25,8 @@
getMatchesState,
scrollToMatch,
clearMatchesState,
storeMatchesState
storeMatchesState,
type FilterState
} from '$lib/utils/navigation';
let { data }: { data: PageData } = $props();
@@ -87,6 +87,25 @@
// Future filters (disabled until API supports them)
let rankTier = $state<string>('all');
// Derived filter state for navigation preservation
const currentFilters = $derived<FilterState>({
resultFilter,
sortBy,
sortOrder,
fromDate,
toDate
});
// Initial loading state for skeleton display
let isInitialLoading = $state(true);
// Set loading to false once data is available
$effect(() => {
if (matchesState.items.length > 0 || data.matches.length === 0) {
isInitialLoading = false;
}
});
// Infinite scroll setup
let loadMoreTriggerRef = $state<HTMLDivElement | null>(null);
let loadMoreTimeout = $state<number | null>(null);
@@ -147,6 +166,15 @@
if (navState) {
navStateProcessed = true;
// Restore filter state if available
if (navState.filters) {
resultFilter = navState.filters.resultFilter;
sortBy = navState.filters.sortBy;
sortOrder = navState.filters.sortOrder;
fromDate = navState.filters.fromDate;
toDate = navState.filters.toDate;
}
// Check if we need to load more matches to find the target match
const targetMatch = matchesState.items.find((m) => m.match_id === navState.matchId);
@@ -427,7 +455,7 @@
if (link) {
const matchId = link.getAttribute('data-match-id');
if (matchId) {
storeMatchesState(matchId, matchesState.items.length);
storeMatchesState(matchId, matchesState.items.length, currentFilters);
}
}
}
@@ -439,29 +467,48 @@
<!-- Export Toast Notification -->
{#if exportMessage}
<div class="toast toast-center toast-top z-50">
<div class="alert alert-success shadow-lg">
<div>
<Download class="h-5 w-5" />
<span>{exportMessage}</span>
</div>
<div class="fixed left-1/2 top-4 z-50 -translate-x-1/2">
<div
class="flex items-center gap-3 rounded-lg border border-neon-green/30 bg-void-light px-4 py-3 shadow-lg"
style="box-shadow: 0 0 20px rgba(0, 255, 136, 0.2);"
>
<Download class="h-5 w-5 text-neon-green" />
<span class="text-white">{exportMessage}</span>
</div>
</div>
{/if}
<div class="container mx-auto px-4 py-8">
<div class="relative min-h-screen bg-void">
<!-- Decorative background elements -->
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="absolute -left-40 top-20 h-80 w-80 rounded-full bg-neon-blue/10 blur-[100px]"></div>
<div
class="absolute -right-40 top-60 h-80 w-80 rounded-full bg-neon-purple/10 blur-[100px]"
></div>
<div
class="absolute inset-0 opacity-20"
style="background-image: linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); background-size: 50px 50px;"
></div>
</div>
<div class="container relative mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="mb-2 text-4xl font-bold">Matches</h1>
<p class="text-base-content/60">Browse and search through CS2 competitive matches</p>
<h1
class="mb-2 text-4xl font-bold text-white"
style="text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);"
>
Matches
</h1>
<p class="text-white/60">Browse and search through CS2 competitive matches</p>
</div>
<!-- Share Code Input -->
<Card padding="lg" class="mb-8">
<div class="mb-8 rounded-xl border border-white/10 bg-void-light p-6">
<ShareCodeInput />
</Card>
</div>
<!-- Search & Filters -->
<Card padding="lg" class="mb-8">
<div class="mb-8 rounded-xl border border-white/10 bg-void-light p-6">
<form
onsubmit={(e) => {
e.preventDefault();
@@ -472,99 +519,116 @@
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-white/40" />
<input
bind:value={searchQuery}
type="text"
placeholder="Search by match ID or share code..."
class="input input-bordered w-full pl-10"
class="w-full rounded-lg border border-neon-blue/30 bg-void py-3 pl-10 pr-4 text-white transition-colors placeholder:text-white/40 focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue"
title="Player name search coming soon when API supports it"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2">
<div
class="tooltip tooltip-left"
data-tip="Player name search will be available when the API supports it"
<span
class="rounded-full border border-neon-gold/30 bg-neon-gold/10 px-2 py-1 text-xs text-neon-gold"
title="Player name search will be available when the API supports it"
>
<Badge variant="warning" size="sm">Player Search Coming Soon</Badge>
Player Search Coming Soon
</span>
</div>
</div>
</div>
</div>
<Button type="submit" variant="primary">
<Search class="mr-2 h-5 w-5" />
<button
type="submit"
class="flex items-center justify-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-semibold text-void transition-all hover:scale-105 hover:shadow-[0_0_20px_rgba(0,212,255,0.4)] focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue focus-visible:ring-offset-2 focus-visible:ring-offset-void"
>
<Search class="h-5 w-5" />
Search
</Button>
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
<Filter class="mr-2 h-5 w-5" />
</button>
<button
type="button"
class="flex items-center justify-center gap-2 rounded-lg border border-neon-blue/30 px-4 py-3 text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
onclick={() => (showFilters = !showFilters)}
>
<Filter class="h-5 w-5" />
Filters
{#if activeFilterCount > 0}
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount}</Badge>
<span class="rounded-full bg-neon-blue px-2 py-0.5 text-xs font-medium text-void"
>{activeFilterCount}</span
>
{/if}
</Button>
</button>
<!-- Export Dropdown -->
<div class="dropdown dropdown-end">
<Button
<div class="relative">
<button
type="button"
variant="ghost"
class="flex items-center justify-center gap-2 rounded-lg border border-neon-blue/30 px-4 py-3 text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue disabled:cursor-not-allowed disabled:opacity-50"
disabled={matchesState.items.length === 0}
onclick={() => (exportDropdownOpen = !exportDropdownOpen)}
>
<Download class="mr-2 h-5 w-5" />
<Download class="h-5 w-5" />
Export
</Button>
</button>
{#if exportDropdownOpen}
<ul class="menu dropdown-content z-[1] mt-2 w-52 rounded-box bg-base-100 p-2 shadow-lg">
<li>
<button type="button" onclick={handleExportCSV}>
<div
class="absolute right-0 z-10 mt-2 w-48 rounded-lg border border-white/10 bg-void-light py-2 shadow-xl"
>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-2 text-left text-white/70 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={handleExportCSV}
>
<FileDown class="h-4 w-4" />
Export as CSV
</button>
</li>
<li>
<button type="button" onclick={handleExportJSON}>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-2 text-left text-white/70 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={handleExportJSON}
>
<FileJson class="h-4 w-4" />
Export as JSON
</button>
</li>
</ul>
</div>
{/if}
</div>
</div>
<!-- Filter Panel (Collapsible) -->
{#if showFilters}
<div class="space-y-4 border-t border-base-300 pt-4">
<div class="space-y-6 border-t border-white/10 pt-4" transition:slide={{ duration: 300 }}>
<!-- Date Range Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Date Range</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">
Date Range
</h3>
<div class="flex flex-col gap-3">
<!-- Preset Buttons -->
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn btn-outline btn-sm"
class="rounded-lg border border-neon-blue/30 px-3 py-1.5 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={() => setDatePreset('today')}
>
Today
</button>
<button
type="button"
class="btn btn-outline btn-sm"
class="rounded-lg border border-neon-blue/30 px-3 py-1.5 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={() => setDatePreset('week')}
>
This Week
</button>
<button
type="button"
class="btn btn-outline btn-sm"
class="rounded-lg border border-neon-blue/30 px-3 py-1.5 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={() => setDatePreset('month')}
>
This Month
</button>
<button
type="button"
class="btn btn-outline btn-sm"
class="rounded-lg border border-neon-blue/30 px-3 py-1.5 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={() => setDatePreset('all')}
>
All Time
@@ -573,21 +637,21 @@
<!-- Date Inputs -->
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex flex-1 items-center gap-2">
<label for="from-date" class="text-sm font-medium">From:</label>
<label for="from-date" class="text-sm font-medium text-white/60">From:</label>
<input
id="from-date"
type="date"
bind:value={fromDate}
class="input input-sm input-bordered flex-1"
class="flex-1 rounded-lg border border-neon-blue/30 bg-void px-3 py-2 text-sm text-white transition-colors focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue"
/>
</div>
<div class="flex flex-1 items-center gap-2">
<label for="to-date" class="text-sm font-medium">To:</label>
<label for="to-date" class="text-sm font-medium text-white/60">To:</label>
<input
id="to-date"
type="date"
bind:value={toDate}
class="input input-sm input-bordered flex-1"
class="flex-1 rounded-lg border border-neon-blue/30 bg-void px-3 py-2 text-sm text-white transition-colors focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue"
/>
</div>
</div>
@@ -596,13 +660,15 @@
<!-- Map Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">Map</h3>
<div class="flex flex-wrap gap-2">
{#each commonMaps as mapName}
<a
href={`/matches?map=${mapName}`}
class="badge badge-outline badge-lg hover:badge-primary"
class:badge-primary={currentMap === mapName}
class="rounded-full border px-3 py-1.5 text-sm transition-colors {currentMap ===
mapName
? 'border-neon-blue bg-neon-blue/20 text-neon-blue'
: 'border-white/20 text-white/60 hover:border-neon-blue hover:text-neon-blue'}"
>
{mapName}
</a>
@@ -613,17 +679,19 @@
<!-- Rank Tier Filter (Coming Soon) -->
<div>
<div class="mb-3 flex items-center gap-2">
<h3 class="font-semibold text-base-content">Filter by Rank Tier</h3>
<div
class="tooltip"
data-tip="This filter will be available when the API supports rank data"
<h3 class="text-sm font-semibold uppercase tracking-wider text-white/70">
Rank Tier
</h3>
<span
class="rounded-full border border-neon-gold/30 bg-neon-gold/10 px-2 py-0.5 text-xs text-neon-gold"
title="This filter will be available when the API supports rank data"
>
<Badge variant="warning" size="sm">Coming Soon</Badge>
</div>
Coming Soon
</span>
</div>
<select
bind:value={rankTier}
class="select select-bordered select-sm w-full max-w-xs"
class="w-full max-w-xs cursor-not-allowed rounded-lg border border-white/10 bg-void px-3 py-2 text-sm text-white/40 opacity-50"
disabled
>
<option value="all">All Ranks</option>
@@ -639,58 +707,90 @@
<!-- Game Mode Filter (Coming Soon) -->
<div>
<div class="mb-3 flex items-center gap-2">
<h3 class="font-semibold text-base-content">Filter by Game Mode</h3>
<div
class="tooltip"
data-tip="This filter will be available when the API supports game mode data"
<h3 class="text-sm font-semibold uppercase tracking-wider text-white/70">
Game Mode
</h3>
<span
class="rounded-full border border-neon-gold/30 bg-neon-gold/10 px-2 py-0.5 text-xs text-neon-gold"
title="This filter will be available when the API supports game mode data"
>
<Badge variant="warning" size="sm">Coming Soon</Badge>
</div>
Coming Soon
</span>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-sm" disabled>All Modes</button>
<button type="button" class="btn btn-outline btn-sm" disabled>Premier</button>
<button type="button" class="btn btn-outline btn-sm" disabled>Competitive</button>
<button type="button" class="btn btn-outline btn-sm" disabled>Wingman</button>
<button
type="button"
class="cursor-not-allowed rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/40"
disabled
>
All Modes
</button>
<button
type="button"
class="cursor-not-allowed rounded-lg border border-white/10 px-3 py-1.5 text-sm text-white/40"
disabled
>
Premier
</button>
<button
type="button"
class="cursor-not-allowed rounded-lg border border-white/10 px-3 py-1.5 text-sm text-white/40"
disabled
>
Competitive
</button>
<button
type="button"
class="cursor-not-allowed rounded-lg border border-white/10 px-3 py-1.5 text-sm text-white/40"
disabled
>
Wingman
</button>
</div>
</div>
<!-- Result Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Result</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">
Result
</h3>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="badge badge-lg"
class:badge-primary={resultFilter === 'all'}
class:badge-outline={resultFilter !== 'all'}
class="rounded-full border px-3 py-1.5 text-sm transition-colors {resultFilter ===
'all'
? 'border-neon-blue bg-neon-blue/20 text-neon-blue'
: 'border-white/20 text-white/60 hover:border-neon-blue hover:text-neon-blue'}"
onclick={() => (resultFilter = 'all')}
>
All Matches
</button>
<button
type="button"
class="badge badge-lg"
class:badge-success={resultFilter === 'win'}
class:badge-outline={resultFilter !== 'win'}
class="rounded-full border px-3 py-1.5 text-sm transition-colors {resultFilter ===
'win'
? 'border-neon-green bg-neon-green/20 text-neon-green'
: 'border-white/20 text-white/60 hover:border-neon-green hover:text-neon-green'}"
onclick={() => (resultFilter = 'win')}
>
Wins
</button>
<button
type="button"
class="badge badge-lg"
class:badge-error={resultFilter === 'loss'}
class:badge-outline={resultFilter !== 'loss'}
class="rounded-full border px-3 py-1.5 text-sm transition-colors {resultFilter ===
'loss'
? 'border-neon-red bg-neon-red/20 text-neon-red'
: 'border-white/20 text-white/60 hover:border-neon-red hover:text-neon-red'}"
onclick={() => (resultFilter = 'loss')}
>
Losses
</button>
<button
type="button"
class="badge badge-lg"
class:badge-warning={resultFilter === 'tie'}
class:badge-outline={resultFilter !== 'tie'}
class="rounded-full border px-3 py-1.5 text-sm transition-colors {resultFilter ===
'tie'
? 'border-neon-gold bg-neon-gold/20 text-neon-gold'
: 'border-white/20 text-white/60 hover:border-neon-gold hover:text-neon-gold'}"
onclick={() => (resultFilter = 'tie')}
>
Ties
@@ -700,16 +800,21 @@
<!-- Sort Controls -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Sort By</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/70">
Sort By
</h3>
<div class="flex flex-wrap gap-2">
<select bind:value={sortBy} class="select select-bordered select-sm">
<select
bind:value={sortBy}
class="rounded-lg border border-neon-blue/30 bg-void px-3 py-2 text-sm text-white transition-colors focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue"
>
<option value="date">Date</option>
<option value="duration">Duration</option>
<option value="score">Score Difference</option>
</select>
<button
type="button"
class="btn btn-sm"
class="rounded-lg border border-neon-blue/30 px-3 py-2 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue"
onclick={() => (sortOrder = sortOrder === 'desc' ? 'asc' : 'desc')}
>
{sortOrder === 'desc' ? '↓ Descending' : '↑ Ascending'}
@@ -718,8 +823,12 @@
</div>
<!-- Clear All Filters Button -->
<div class="border-t border-base-300 pt-3">
<button type="button" class="btn btn-ghost btn-sm w-full" onclick={clearAllFilters}>
<div class="border-t border-white/10 pt-4">
<button
type="button"
class="w-full rounded-lg border border-neon-red/30 px-4 py-2 text-sm text-neon-red/70 transition-colors hover:border-neon-red hover:bg-neon-red/10 hover:text-neon-red"
onclick={clearAllFilters}
>
Clear All Filters
</button>
</div>
@@ -729,88 +838,118 @@
<!-- Active Filters -->
{#if currentMap || currentPlayerId || currentSearch || fromDate || toDate}
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-base-300 pt-4">
<span class="text-sm font-medium text-base-content/70">Active Filters:</span>
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-4">
<span class="text-sm font-medium text-white/50">Active Filters:</span>
{#if currentSearch}
<Badge variant="info">Match/Share Code: {currentSearch}</Badge>
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-3 py-1 text-xs text-neon-blue"
>Match/Share Code: {currentSearch}</span
>
{/if}
{#if currentMap}
<Badge variant="info">Map: {currentMap}</Badge>
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-3 py-1 text-xs text-neon-blue"
>Map: {currentMap}</span
>
{/if}
{#if currentPlayerId}
<Badge variant="info">Player ID: {currentPlayerId}</Badge>
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-3 py-1 text-xs text-neon-blue"
>Player ID: {currentPlayerId}</span
>
{/if}
{#if fromDate}
<Badge variant="info">From: {fromDate}</Badge>
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-3 py-1 text-xs text-neon-blue"
>From: {fromDate}</span
>
{/if}
{#if toDate}
<Badge variant="info">To: {toDate}</Badge>
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-3 py-1 text-xs text-neon-blue"
>To: {toDate}</span
>
{/if}
<Button
variant="ghost"
size="sm"
<button
class="rounded-lg px-3 py-1 text-xs text-white/50 transition-colors hover:bg-neon-red/10 hover:text-neon-red"
onclick={() => {
clearAllFilters();
goto('/matches');
}}>Clear All</Button
}}>Clear All</button
>
</div>
{/if}
</Card>
</div>
<!-- View Mode Toggle & Results Summary -->
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<!-- View Mode Toggle -->
<div class="join">
<div class="flex overflow-hidden rounded-lg border border-white/10">
<button
type="button"
class="btn join-item"
class:btn-active={viewMode === 'grid'}
class="flex items-center gap-2 px-4 py-2 text-sm transition-colors {viewMode === 'grid'
? 'bg-neon-blue text-void'
: 'text-white/60 hover:bg-white/5 hover:text-white'}"
onclick={() => setViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid class="h-5 w-5" />
<span class="ml-2 hidden sm:inline">Grid</span>
<LayoutGrid class="h-4 w-4" />
<span class="hidden sm:inline">Grid</span>
</button>
<button
type="button"
class="btn join-item"
class:btn-active={viewMode === 'table'}
class="flex items-center gap-2 border-l border-white/10 px-4 py-2 text-sm transition-colors {viewMode ===
'table'
? 'bg-neon-blue text-void'
: 'text-white/60 hover:bg-white/5 hover:text-white'}"
onclick={() => setViewMode('table')}
aria-label="Table view"
>
<TableIcon class="h-5 w-5" />
<span class="ml-2 hidden sm:inline">Table</span>
<TableIcon class="h-4 w-4" />
<span class="hidden sm:inline">Table</span>
</button>
</div>
<!-- Results Summary -->
{#if matchesState.items.length > 0}
<Badge variant="info">
<span
class="rounded-full border border-neon-blue/30 bg-neon-blue/10 px-3 py-1 text-sm text-neon-blue"
>
Showing {matchesState.items.length} matches
</Badge>
</span>
{/if}
</div>
<!-- Matches Display (Grid or Table) -->
{#if matchesState.items.length > 0}
{#if isInitialLoading}
<!-- Skeleton Loading State -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(8) as _}
<MatchCardSkeleton />
{/each}
</div>
{:else if matchesState.items.length > 0}
{#if viewMode === 'grid'}
<!-- Grid View -->
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each matchesState.items as match (match.match_id)}
<MatchCard {match} loadedCount={matchesState.items.length} />
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each matchesState.items as match, index (match.match_id)}
<MatchCard
{match}
{index}
loadedCount={matchesState.items.length}
filters={currentFilters}
/>
{/each}
</div>
{:else}
<!-- Table View -->
<div
class="rounded-lg border border-base-300 bg-base-100"
class="overflow-hidden rounded-xl border border-white/10 bg-void-light"
onclick={handleTableLinkClick}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
// Create a mock MouseEvent to match the expected type
const mockEvent = {
target: e.target,
currentTarget: e.currentTarget
@@ -838,45 +977,52 @@
<div bind:this={loadMoreTriggerRef} class="h-1 w-full"></div>
<!-- Visible load more button for manual loading -->
<Button
variant="primary"
size="lg"
<button
class="inline-flex items-center gap-2 rounded-lg border border-neon-blue/30 px-6 py-3 font-semibold text-neon-blue transition-all hover:border-neon-blue hover:bg-neon-blue/10 hover:shadow-[0_0_20px_rgba(0,212,255,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue disabled:cursor-not-allowed disabled:opacity-50"
onclick={loadMore}
disabled={matchesState.isLoadingMore}
>
{#if matchesState.isLoadingMore}
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
<Loader2 class="h-5 w-5 animate-spin" />
Loading...
{:else}
Load More Matches
{/if}
</Button>
</button>
{#if matchesState.isLoadingMore}
<p class="mt-2 text-sm text-base-content/60">Loading more matches...</p>
<p class="mt-2 text-sm text-white/50">Loading more matches...</p>
{/if}
<p class="mt-2 text-sm text-base-content/60">
<p class="mt-2 text-sm text-white/50">
Showing {matchesState.items.length} matches (more available)
</p>
</div>
{:else if matchesState.items.length > 0}
<div class="mt-8 text-center">
<Badge variant="default">
<span
class="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/50"
>
All matches loaded ({matchesState.items.length} total)
</Badge>
</span>
</div>
{/if}
{:else}
<Card padding="lg">
<div class="rounded-xl border border-white/10 bg-void-light p-12">
<div class="text-center">
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
<h2 class="mb-2 text-xl font-semibold text-base-content">No Matches Found</h2>
<p class="text-base-content/60">Try adjusting your filters or search query.</p>
<Calendar class="mx-auto mb-4 h-16 w-16 text-white/30" />
<h2 class="mb-2 text-xl font-semibold text-white">No Matches Found</h2>
<p class="text-white/50">Try adjusting your filters or search query.</p>
{#if currentMap || currentPlayerId || currentSearch}
<div class="mt-4">
<Button variant="primary" href="/matches">View All Matches</Button>
<div class="mt-6">
<a
href="/matches"
class="inline-flex items-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-semibold text-void transition-all hover:scale-105 hover:shadow-[0_0_20px_rgba(0,212,255,0.4)]"
>
View All Matches
</a>
</div>
{/if}
</div>
</Card>
</div>
{/if}
</div>
</div>