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 */ /* Neon Text Glow Effects */
.text-glow-sm { .text-glow-sm {
text-shadow: 0 0 10px currentColor; text-shadow: 0 0 10px currentColor;

View File

@@ -10,7 +10,7 @@
render?: (value: T[keyof T], row: T) => unknown; render?: (value: T[keyof T], row: T) => unknown;
align?: 'left' | 'center' | 'right'; align?: 'left' | 'center' | 'right';
class?: string; class?: string;
width?: string; // e.g., '200px', '30%', 'auto' width?: string;
} }
interface Props { interface Props {
@@ -20,7 +20,7 @@
striped?: boolean; striped?: boolean;
hoverable?: boolean; hoverable?: boolean;
compact?: boolean; compact?: boolean;
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths fixedLayout?: boolean;
} }
let { let {
@@ -71,19 +71,18 @@
</script> </script>
<div class="overflow-x-auto {className}"> <div class="overflow-x-auto {className}">
<table <table class="w-full border-collapse" style={fixedLayout ? 'table-layout: fixed;' : ''}>
class="table"
class:table-zebra={striped}
class:table-xs={compact}
style={fixedLayout ? 'table-layout: fixed;' : ''}
>
<thead> <thead>
<tr> <tr class="border-b border-white/10 bg-void">
{#each columns as column} {#each columns as column}
<th <th
class:cursor-pointer={column.sortable} class="px-4 text-left text-xs font-semibold uppercase tracking-wider text-white/60 {compact
class:hover:bg-base-200={column.sortable} ? 'py-2'
class="text-{column.align || 'left'} {column.class || ''}" : '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}` : ''} style={column.width ? `width: ${column.width}` : ''}
onclick={() => handleSort(column)} onclick={() => handleSort(column)}
> >
@@ -94,16 +93,16 @@
> >
<span>{column.label}</span> <span>{column.label}</span>
{#if column.sortable} {#if column.sortable}
<div class="flex flex-col opacity-40"> <div class="flex flex-col">
<ArrowUp <ArrowUp
class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc' class="h-3 w-3 {sortKey === column.key && sortDirection === 'asc'
? 'text-primary opacity-100' ? 'text-neon-blue'
: ''}" : 'text-white/30'}"
/> />
<ArrowDown <ArrowDown
class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc' class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc'
? 'text-primary opacity-100' ? 'text-neon-blue'
: ''}" : 'text-white/30'}"
/> />
</div> </div>
{/if} {/if}
@@ -113,10 +112,18 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedData as row} {#each sortedData as row, index}
<tr class:hover={hoverable}> <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} {#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} {#if column.render}
{@html column.render(row[column.key], row)} {@html column.render(row[column.key], row)}
{:else} {:else}
@@ -129,3 +136,50 @@
</tbody> </tbody>
</table> </table>
</div> </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 Browse Matches
</NeonButton> </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" /> <Github class="mr-2 h-5 w-5" aria-hidden="true" />
View on GitHub View on GitHub
</NeonButton> </NeonButton>

View File

@@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte'; import { CheckCircle2, Clock } from 'lucide-svelte';
import type { MatchListItem } from '$lib/types'; 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'; import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
interface Props { interface Props {
match: MatchListItem; match: MatchListItem;
loadedCount?: number; 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', { const formattedDate = new Date(match.date).toLocaleString('en-US', {
month: 'short', month: 'short',
@@ -21,9 +23,28 @@
const mapName = formatMapName(match.map); const mapName = formatMapName(match.map);
const mapBg = getMapBackground(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() { function handleClick() {
// Store navigation state before navigating storeMatchesState(match.match_id, loadedCount, filters);
storeMatchesState(match.match_id, loadedCount);
} }
function handleImageError(event: Event) { function handleImageError(event: Event) {
@@ -34,66 +55,86 @@
<a <a
href={`/match/${match.match_id}`} 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} data-match-id={match.match_id}
onclick={handleClick} onclick={handleClick}
> >
<div <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 --> <!-- Map Header with Background Image -->
<div class="relative h-32 overflow-hidden"> <div class="relative h-20 overflow-hidden">
<!-- Background Image --> <!-- Background Image -->
<img <img
src={mapBg} src={mapBg}
alt={mapName} 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" loading="lazy"
onerror={handleImageError} onerror={handleImageError}
/> />
<!-- Overlay for better text contrast --> <!-- 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 --> <!-- Content -->
<div class="relative flex h-full items-end justify-between p-3"> <div class="relative flex h-full items-end justify-between p-2">
<div class="flex flex-col gap-1"> <div class="flex items-center gap-2">
{#if match.map} <span
<Badge variant="default">{match.map}</Badge> class="text-sm font-bold text-white drop-shadow-lg"
{/if} style="text-shadow: 0 0 8px rgba(0, 212, 255, 0.3);"
<span class="text-lg font-bold text-white drop-shadow-lg">{mapName}</span> >
{mapName}
</span>
</div> </div>
{#if match.demo_parsed} <!-- Status badges - horizontal layout -->
<Badge variant="success" size="sm">Parsed</Badge> <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} {/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>
</div> </div>
<!-- Match Info --> <!-- Match Info - Compact -->
<div class="p-4"> <div class="flex items-center justify-between px-3 py-2">
<!-- Score --> <!-- Score -->
<div class="mb-3 flex items-center justify-center gap-3"> <div class="flex items-center gap-2">
<span class="font-mono text-2xl font-bold text-terrorist">{match.score_team_a}</span> <span
<span class="text-base-content/40">-</span> class="font-mono text-lg font-bold text-terrorist"
<span class="font-mono text-2xl font-bold text-ct">{match.score_team_b}</span> 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> </div>
<!-- Meta --> <!-- 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> <span>{formattedDate}</span>
{#if match.duration} {#if match.duration}
<span>{Math.floor(match.duration / 60)}m</span> <span>{Math.floor(match.duration / 60)}m</span>
{/if} {/if}
</div> </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>
</div> </div>
</a> </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"> <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 { matchesAPI } from '$lib/api/matches';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -9,10 +9,10 @@
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle'); let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
let statusMessage = $state(''); let statusMessage = $state('');
let parsedMatchId = $state(''); let parsedMatchId = $state('');
let showHelp = $state(false);
// Validate share code format // Validate share code format
function isValidShareCode(code: string): boolean { 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}$/; 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()); 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.'; 'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
toast.success('Match submitted for parsing!'); toast.success('Match submitted for parsing!');
// Wait a moment then redirect to the match page
setTimeout(() => { setTimeout(() => {
goto(`/match/${response.match_id}`); goto(`/match/${response.match_id}`);
}, 2000); }, 2000);
@@ -75,22 +74,22 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Input Section --> <!-- Input Section -->
<div class="form-control"> <div>
<label class="label" for="shareCode"> <label class="mb-2 block text-sm font-medium text-white" for="shareCode">
<span class="label-text font-medium">Submit Match Share Code</span> Submit Match Share Code
</label> </label>
<div class="flex gap-2"> <div class="flex gap-3">
<input <input
id="shareCode" id="shareCode"
type="text" type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" 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} bind:value={shareCode}
disabled={isLoading} disabled={isLoading}
onkeydown={(e) => e.key === 'Enter' && handleSubmit()} onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
/> />
<button <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} onclick={handleSubmit}
disabled={isLoading || !shareCode.trim()} disabled={isLoading || !shareCode.trim()}
> >
@@ -102,54 +101,78 @@
Parse Parse
</button> </button>
</div> </div>
<div class="label"> <p class="mt-2 text-sm text-white/50">
<span class="label-text-alt text-base-content/60">
Submit a CS2 match share code to add it to the database Submit a CS2 match share code to add it to the database
</span> </p>
</div>
</div> </div>
<!-- Status Messages --> <!-- Status Messages -->
{#if parseStatus !== 'idle'} {#if parseStatus !== 'idle'}
<div <div
class="alert {parseStatus === 'success' class="flex items-start gap-3 rounded-lg border p-4 {parseStatus === 'success'
? 'alert-success' ? 'border-neon-green/30 bg-neon-green/10'
: parseStatus === 'error' : parseStatus === 'error'
? 'alert-error' ? 'border-neon-red/30 bg-neon-red/10'
: 'alert-info'}" : 'border-neon-blue/30 bg-neon-blue/10'}"
> >
{#if parseStatus === 'parsing'} {#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'} {: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} {:else}
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" /> <AlertCircle class="h-5 w-5 shrink-0 text-neon-red" />
{/if} {/if}
<div class="flex-1"> <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} {#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} {/if}
</div> </div>
{#if parseStatus !== 'parsing'} {#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} {/if}
</div> </div>
{/if} {/if}
<!-- Help Text --> <!-- Help Text (Collapsible) -->
<div class="text-sm text-base-content/70"> <div class="rounded-lg border border-white/10 bg-void">
<p class="mb-2 font-medium">How to get your match share code:</p> <button
<ol class="list-inside list-decimal space-y-1"> 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>Open CS2 and navigate to your Matches tab</li>
<li>Click on a match you want to analyze</li> <li>Click on a match you want to analyze</li>
<li>Click the "Copy Share Link" button</li> <li>Click the "Copy Share Link" button</li>
<li>Paste the share code here</li> <li>Paste the share code here</li>
</ol> </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 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 basic match info immediately, but detailed statistics will be available after parsing
completes. completes.
</p> </p>
</div> </div>
{/if}
</div>
</div> </div>

View File

@@ -8,6 +8,7 @@
children: Snippet; children: Snippet;
onclick?: () => void; onclick?: () => void;
class?: string; class?: string;
external?: boolean;
} }
let { let {
@@ -16,7 +17,8 @@
size = 'md', size = 'md',
children, children,
onclick, onclick,
class: className = '' class: className = '',
external = false
}: Props = $props(); }: Props = $props();
const variantClasses = { const variantClasses = {
@@ -60,6 +62,7 @@
<a <a
{href} {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}" 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()} {@render children()}
</a> </a>

View File

@@ -5,24 +5,41 @@
const STORAGE_KEY = 'matches-navigation-state'; 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 { interface NavigationState {
matchId: string; matchId: string;
scrollY: number; scrollY: number;
timestamp: number; timestamp: number;
loadedCount: number; // Number of matches loaded (for pagination) loadedCount: number; // Number of matches loaded (for pagination)
filters?: FilterState; // Optional filter state
} }
/** /**
* Store navigation state when leaving the matches page * 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; if (typeof window === 'undefined') return;
const state: NavigationState = { const state: NavigationState = {
matchId, matchId,
scrollY: window.scrollY, scrollY: window.scrollY,
timestamp: Date.now(), timestamp: Date.now(),
loadedCount loadedCount,
filters
}; };
try { try {

View File

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