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:
17
src/app.css
17
src/app.css
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
src/lib/components/match/MatchCardSkeleton.svelte
Normal file
35
src/lib/components/match/MatchCardSkeleton.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user