feat: Redesign matches page with neon styling and UX improvements

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
import { Upload, Check, AlertCircle, Loader2, ChevronDown } from 'lucide-svelte';
import { matchesAPI } from '$lib/api/matches';
import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
@@ -9,10 +9,10 @@
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
let statusMessage = $state('');
let parsedMatchId = $state('');
let showHelp = $state(false);
// Validate share code format
function isValidShareCode(code: string): boolean {
// Format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
const pattern = /^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/;
return pattern.test(code.toUpperCase());
}
@@ -47,7 +47,6 @@
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
toast.success('Match submitted for parsing!');
// Wait a moment then redirect to the match page
setTimeout(() => {
goto(`/match/${response.match_id}`);
}, 2000);
@@ -75,22 +74,22 @@
<div class="space-y-4">
<!-- Input Section -->
<div class="form-control">
<label class="label" for="shareCode">
<span class="label-text font-medium">Submit Match Share Code</span>
<div>
<label class="mb-2 block text-sm font-medium text-white" for="shareCode">
Submit Match Share Code
</label>
<div class="flex gap-2">
<div class="flex gap-3">
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered flex-1"
class="flex-1 rounded-lg border border-neon-blue/30 bg-void px-4 py-3 font-mono text-white transition-colors placeholder:text-white/40 focus:border-neon-blue focus:outline-none focus:ring-1 focus:ring-neon-blue disabled:cursor-not-allowed disabled:opacity-50"
bind:value={shareCode}
disabled={isLoading}
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
/>
<button
class="btn btn-primary"
class="flex items-center gap-2 rounded-lg bg-neon-blue px-6 py-3 font-semibold text-void transition-all hover:scale-105 hover:shadow-[0_0_20px_rgba(0,212,255,0.4)] focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue focus-visible:ring-offset-2 focus-visible:ring-offset-void disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
onclick={handleSubmit}
disabled={isLoading || !shareCode.trim()}
>
@@ -102,54 +101,78 @@
Parse
</button>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
Submit a CS2 match share code to add it to the database
</span>
</div>
<p class="mt-2 text-sm text-white/50">
Submit a CS2 match share code to add it to the database
</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">
<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">
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>
<!-- 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-3 text-xs text-white/40">
Note: Demo parsing can take 1-5 minutes depending on match length. You'll be able to view
basic match info immediately, but detailed statistics will be available after parsing
completes.
</p>
</div>
{/if}
</div>
</div>

View File

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

View File

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

File diff suppressed because it is too large Load Diff