fix: Fix Svelte 5 reactivity issues in matches page and update API handling
- Fix toast notification imports: change from showToast to toast.success/error - Remove hover preloading from app.html and Tabs component - Fix match rounds API handling with safe parsing for incomplete data - Fix pagination timestamp calculation (API returns Unix timestamp, not ISO string) - Refactor matches page state management to fix reactivity issues: - Replace separate state variables with single matchesState object - Completely replace state object on updates to trigger reactivity - Fix infinite loop in intersection observer effect - Add keyed each blocks for proper list rendering - Remove client-side filtering (temporarily) to isolate reactivity issues - Add error state handling with nextPageTime in matches loader 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
<meta name="description" content="Statistics for CS2 matchmaking matches" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client';
|
||||
import {
|
||||
parseMatchRounds,
|
||||
parseMatchRoundsSafe,
|
||||
parseMatchWeapons,
|
||||
parseMatchChat,
|
||||
parseMatchParseResponse
|
||||
@@ -69,13 +69,22 @@ export const matchesAPI = {
|
||||
* Get match round-by-round statistics
|
||||
* @param matchId - Match ID
|
||||
* @returns Round statistics and economy data
|
||||
* @throws Error if data is invalid or demo not parsed yet
|
||||
*/
|
||||
async getMatchRounds(matchId: string | number): Promise<MatchRoundsResponse> {
|
||||
const url = `/match/${matchId}/rounds`;
|
||||
const data = await apiClient.get<MatchRoundsResponse>(url);
|
||||
const data = await apiClient.get<unknown>(url);
|
||||
|
||||
// Validate with Zod schema
|
||||
return parseMatchRounds(data);
|
||||
// Validate with Zod schema using safe parse
|
||||
// This handles cases where the demo hasn't been parsed yet
|
||||
const result = parseMatchRoundsSafe(data);
|
||||
|
||||
if (!result.success) {
|
||||
// If validation fails, it's likely the demo hasn't been parsed yet
|
||||
throw new Error('Demo not parsed yet or invalid response format');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -117,30 +126,33 @@ export const matchesAPI = {
|
||||
const limit = params?.limit || 50;
|
||||
|
||||
// CRITICAL: API returns a plain array, not a wrapped object
|
||||
// We request limit + 1 to detect if there are more pages
|
||||
// NOTE: Backend has a hard limit of 20 matches per request
|
||||
// We assume hasMore = true if we get exactly the limit we requested
|
||||
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
|
||||
params: {
|
||||
limit: limit + 1, // Request one extra to check if there are more
|
||||
limit: limit,
|
||||
map: params?.map,
|
||||
player_id: params?.player_id
|
||||
}
|
||||
});
|
||||
|
||||
// Check if there are more matches (if we got the extra one)
|
||||
const hasMore = data.length > limit;
|
||||
// Handle null or empty response
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.warn('[API] getMatches received null or invalid data');
|
||||
return transformMatchesListResponse([], false, undefined);
|
||||
}
|
||||
|
||||
// Remove the extra match if we have more
|
||||
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
|
||||
// If we got exactly the limit, assume there might be more
|
||||
// If we got less, we've reached the end
|
||||
const hasMore = data.length === limit;
|
||||
|
||||
// If there are more matches, use the timestamp of the last match for pagination
|
||||
// This timestamp is used in the next request: /matches/next/{timestamp}
|
||||
const lastMatch =
|
||||
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
|
||||
const nextPageTime =
|
||||
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
|
||||
// Get the timestamp from the LAST match BEFORE transformation
|
||||
// The legacy API format has `date` as a Unix timestamp (number)
|
||||
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
|
||||
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
|
||||
return transformMatchesListResponse(data, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -153,28 +165,25 @@ export const matchesAPI = {
|
||||
const limit = params?.limit || 20;
|
||||
|
||||
// API returns a plain array, not a wrapped object
|
||||
// Backend has a hard limit of 20 matches per request
|
||||
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
|
||||
params: {
|
||||
limit: limit + 1, // Request one extra to check if there are more
|
||||
limit: limit,
|
||||
map: params?.map,
|
||||
player_id: params?.player_id
|
||||
}
|
||||
});
|
||||
|
||||
// Check if there are more matches (if we got the extra one)
|
||||
const hasMore = data.length > limit;
|
||||
// If we got exactly the limit, assume there might be more
|
||||
const hasMore = data.length === limit;
|
||||
|
||||
// Remove the extra match if we have more
|
||||
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
|
||||
|
||||
// If there are more matches, use the timestamp of the last match for pagination
|
||||
const lastMatch =
|
||||
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
|
||||
const nextPageTime =
|
||||
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
|
||||
// Get the timestamp from the LAST match BEFORE transformation
|
||||
// The legacy API format has `date` as a Unix timestamp (number)
|
||||
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
|
||||
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
|
||||
return transformMatchesListResponse(data, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { matchesAPI } from '$lib/api/matches';
|
||||
import { showToast } from '$lib/stores/toast';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let shareCode = $state('');
|
||||
@@ -21,12 +21,12 @@
|
||||
const trimmedCode = shareCode.trim().toUpperCase();
|
||||
|
||||
if (!trimmedCode) {
|
||||
showToast('Please enter a share code', 'error');
|
||||
toast.error('Please enter a share code');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidShareCode(trimmedCode)) {
|
||||
showToast('Invalid share code format', 'error');
|
||||
toast.error('Invalid share code format');
|
||||
parseStatus = 'error';
|
||||
statusMessage = 'Share code must be in format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX';
|
||||
return;
|
||||
@@ -45,7 +45,7 @@
|
||||
statusMessage =
|
||||
response.message ||
|
||||
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
|
||||
showToast('Match submitted for parsing!', 'success');
|
||||
toast.success('Match submitted for parsing!');
|
||||
|
||||
// Wait a moment then redirect to the match page
|
||||
setTimeout(() => {
|
||||
@@ -54,12 +54,12 @@
|
||||
} else {
|
||||
parseStatus = 'error';
|
||||
statusMessage = response.message || 'Failed to parse share code';
|
||||
showToast(statusMessage, 'error');
|
||||
toast.error(statusMessage);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
parseStatus = 'error';
|
||||
statusMessage = error instanceof Error ? error.message : 'Failed to parse share code';
|
||||
showToast(statusMessage, 'error');
|
||||
toast.error(statusMessage);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import { playersAPI } from '$lib/api/players';
|
||||
import { showToast } from '$lib/stores/toast';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
interface Props {
|
||||
playerId: string;
|
||||
@@ -31,12 +31,12 @@
|
||||
|
||||
try {
|
||||
await playersAPI.trackPlayer(playerId, authCode, shareCode || undefined);
|
||||
showToast('Player tracking activated successfully!', 'success');
|
||||
toast.success('Player tracking activated successfully!');
|
||||
isOpen = false;
|
||||
dispatch('tracked');
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to track player';
|
||||
showToast(error, 'error');
|
||||
toast.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
@@ -53,12 +53,12 @@
|
||||
|
||||
try {
|
||||
await playersAPI.untrackPlayer(playerId, authCode);
|
||||
showToast('Player tracking removed successfully', 'success');
|
||||
toast.success('Player tracking removed successfully');
|
||||
isOpen = false;
|
||||
dispatch('untracked');
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to untrack player';
|
||||
showToast(error, 'error');
|
||||
toast.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
class:tab-active={isActive(tab)}
|
||||
class:tab-disabled={tab.disabled}
|
||||
aria-disabled={tab.disabled}
|
||||
data-sveltekit-preload-data="hover"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
|
||||
@@ -44,9 +44,11 @@
|
||||
// View mode state with localStorage persistence
|
||||
let viewMode = $state<'grid' | 'table'>('grid');
|
||||
|
||||
// Initialize view mode from localStorage on client side
|
||||
// Initialize view mode from localStorage on client side (run once)
|
||||
let viewModeInitialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!import.meta.env.SSR && typeof window !== 'undefined') {
|
||||
if (!viewModeInitialized && !import.meta.env.SSR && typeof window !== 'undefined') {
|
||||
viewModeInitialized = true;
|
||||
const savedViewMode = localStorage.getItem('matches-view-mode');
|
||||
if (savedViewMode === 'grid' || savedViewMode === 'table') {
|
||||
viewMode = savedViewMode;
|
||||
@@ -62,11 +64,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Pagination state
|
||||
let matches = $state<MatchListItem[]>(data.matches);
|
||||
let hasMore = $state(data.hasMore);
|
||||
let nextPageTime = $state(data.nextPageTime);
|
||||
let isLoadingMore = $state(false);
|
||||
// Pagination state - initialize from data prop
|
||||
// When filters change, the page reloads with new data automatically
|
||||
// Use a container object to ensure Svelte tracks the reference properly
|
||||
let matchesState = $state({
|
||||
items: [...data.matches],
|
||||
hasMore: data.hasMore,
|
||||
nextPageTime: data.nextPageTime,
|
||||
isLoadingMore: false,
|
||||
version: 0
|
||||
});
|
||||
|
||||
// Sorting and filtering state
|
||||
let sortBy = $state<'date' | 'duration' | 'score'>('date');
|
||||
@@ -80,31 +87,23 @@
|
||||
// Future filters (disabled until API supports them)
|
||||
let rankTier = $state<string>('all');
|
||||
|
||||
// Reset pagination when data changes (new filters applied)
|
||||
$effect(() => {
|
||||
matches = data.matches;
|
||||
hasMore = data.hasMore;
|
||||
nextPageTime = data.nextPageTime;
|
||||
});
|
||||
|
||||
// Infinite scroll setup
|
||||
let loadMoreTriggerRef = $state<HTMLDivElement | null>(null);
|
||||
let observer = $state<IntersectionObserver | null>(null);
|
||||
let loadMoreTimeout = $state<number | null>(null);
|
||||
|
||||
// Set up intersection observer for infinite scroll
|
||||
// Set up intersection observer for infinite scroll - run only when element ref changes
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && loadMoreTriggerRef && hasMore && !isLoadingMore) {
|
||||
// Clean up existing observer
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
const element = loadMoreTriggerRef;
|
||||
|
||||
if (typeof window !== 'undefined' && element) {
|
||||
// Create new observer
|
||||
observer = new IntersectionObserver(
|
||||
const newObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && hasMore && !isLoadingMore) {
|
||||
if (entry.isIntersecting) {
|
||||
// Check state without creating dependencies
|
||||
const shouldLoad = matchesState.hasMore && !matchesState.isLoadingMore;
|
||||
if (shouldLoad) {
|
||||
// Debounce the load more call to prevent too frequent requests
|
||||
if (loadMoreTimeout) {
|
||||
clearTimeout(loadMoreTimeout);
|
||||
@@ -113,6 +112,7 @@
|
||||
loadMore();
|
||||
}, 300); // 300ms debounce
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
@@ -122,13 +122,11 @@
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(loadMoreTriggerRef);
|
||||
newObserver.observe(element);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
newObserver.disconnect();
|
||||
if (loadMoreTimeout) {
|
||||
clearTimeout(loadMoreTimeout);
|
||||
}
|
||||
@@ -137,23 +135,30 @@
|
||||
return () => {}; // Return empty cleanup function for server-side rendering
|
||||
});
|
||||
|
||||
// Track window width for responsive slides
|
||||
// Track if we've processed the navigation state to prevent infinite loops
|
||||
let navStateProcessed = $state(false);
|
||||
|
||||
// Scroll restoration when returning from a match detail page
|
||||
$effect(() => {
|
||||
// Only run once per page load
|
||||
if (navStateProcessed) return;
|
||||
|
||||
const navState = getMatchesState();
|
||||
if (navState) {
|
||||
navStateProcessed = true;
|
||||
|
||||
// Check if we need to load more matches to find the target match
|
||||
const targetMatch = matches.find((m) => m.match_id === navState.matchId);
|
||||
const targetMatch = matchesState.items.find((m) => m.match_id === navState.matchId);
|
||||
|
||||
if (targetMatch) {
|
||||
// Match found, scroll to it
|
||||
scrollToMatch(navState.matchId, navState.scrollY);
|
||||
clearMatchesState();
|
||||
} else if (hasMore && matches.length < navState.loadedCount) {
|
||||
} else if (matchesState.hasMore && matchesState.items.length < navState.loadedCount) {
|
||||
// Match not found but we had more matches loaded before, try loading more
|
||||
loadMore().then(() => {
|
||||
// After loading, check again
|
||||
const found = matches.find((m) => m.match_id === navState.matchId);
|
||||
const found = matchesState.items.find((m) => m.match_id === navState.matchId);
|
||||
if (found) {
|
||||
scrollToMatch(navState.matchId, navState.scrollY);
|
||||
} else {
|
||||
@@ -170,54 +175,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Computed filtered and sorted matches
|
||||
const displayMatches = $derived.by(() => {
|
||||
let filtered = [...matches];
|
||||
|
||||
// Apply date range filter
|
||||
if (fromDate || toDate) {
|
||||
filtered = filtered.filter((match) => {
|
||||
const matchDate = new Date(match.date);
|
||||
if (fromDate && matchDate < new Date(fromDate + 'T00:00:00')) return false;
|
||||
if (toDate && matchDate > new Date(toDate + 'T23:59:59')) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply result filter
|
||||
if (resultFilter !== 'all') {
|
||||
filtered = filtered.filter((match) => {
|
||||
const teamAWon = match.score_team_a > match.score_team_b;
|
||||
const teamBWon = match.score_team_b > match.score_team_a;
|
||||
const tie = match.score_team_a === match.score_team_b;
|
||||
|
||||
if (resultFilter === 'win') return teamAWon;
|
||||
if (resultFilter === 'loss') return teamBWon;
|
||||
if (resultFilter === 'tie') return tie;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'date') {
|
||||
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
} else if (sortBy === 'duration') {
|
||||
comparison = a.duration - b.duration;
|
||||
} else if (sortBy === 'score') {
|
||||
const aScoreDiff = Math.abs(a.score_team_a - a.score_team_b);
|
||||
const bScoreDiff = Math.abs(b.score_team_a - b.score_team_b);
|
||||
comparison = aScoreDiff - bScoreDiff;
|
||||
}
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
@@ -260,7 +217,7 @@
|
||||
};
|
||||
|
||||
// Count active client-side filters
|
||||
const activeFilterCount = $derived(() => {
|
||||
const activeFilterCount = $derived.by(() => {
|
||||
let count = 0;
|
||||
if (fromDate) count++;
|
||||
if (toDate) count++;
|
||||
@@ -272,7 +229,7 @@
|
||||
|
||||
const loadMore = async () => {
|
||||
// Prevent multiple simultaneous requests
|
||||
if (!hasMore || isLoadingMore || matches.length === 0) {
|
||||
if (!matchesState.hasMore || matchesState.isLoadingMore || matchesState.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,35 +239,38 @@
|
||||
loadMoreTimeout = null;
|
||||
}
|
||||
|
||||
// Get the date of the last match for pagination
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
// Use nextPageTime if available, otherwise calculate from last match
|
||||
let beforeTime: number;
|
||||
if (matchesState.nextPageTime) {
|
||||
beforeTime = matchesState.nextPageTime;
|
||||
} else {
|
||||
const lastMatch = matchesState.items[matchesState.items.length - 1];
|
||||
if (!lastMatch) {
|
||||
isLoadingMore = false;
|
||||
return;
|
||||
}
|
||||
beforeTime = Math.floor(new Date(lastMatch.date).getTime() / 1000);
|
||||
}
|
||||
|
||||
const lastMatchDate = lastMatch.date;
|
||||
const lastMatchTimestamp = Math.floor(new Date(lastMatchDate).getTime() / 1000);
|
||||
|
||||
isLoadingMore = true;
|
||||
matchesState.isLoadingMore = true;
|
||||
try {
|
||||
const matchesData = await api.matches.getMatches({
|
||||
limit: 20,
|
||||
map: data.filters.map,
|
||||
player_id: data.filters.playerId ? String(data.filters.playerId) : undefined,
|
||||
before_time: lastMatchTimestamp
|
||||
before_time: beforeTime
|
||||
});
|
||||
|
||||
// Append new matches to existing list
|
||||
matches = [...matches, ...matchesData.matches];
|
||||
hasMore = matchesData.has_more;
|
||||
nextPageTime = matchesData.next_page_time;
|
||||
console.log('Updated state:', { matchesLength: matches.length, hasMore, nextPageTime });
|
||||
// Update state - completely replace the state object
|
||||
matchesState = {
|
||||
items: [...matchesState.items, ...matchesData.matches],
|
||||
hasMore: matchesData.has_more,
|
||||
nextPageTime: matchesData.next_page_time,
|
||||
isLoadingMore: false,
|
||||
version: matchesState.version + 1
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load more matches:', error);
|
||||
// Show error toast or message here
|
||||
} finally {
|
||||
isLoadingMore = false;
|
||||
matchesState.isLoadingMore = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,8 +287,8 @@
|
||||
// Export handlers
|
||||
const handleExportCSV = () => {
|
||||
try {
|
||||
exportMatchesToCSV(displayMatches);
|
||||
exportMessage = `Successfully exported ${displayMatches.length} matches to CSV`;
|
||||
exportMatchesToCSV(matchesState.items);
|
||||
exportMessage = `Successfully exported ${matchesState.items.length} matches to CSV`;
|
||||
exportDropdownOpen = false;
|
||||
setTimeout(() => {
|
||||
exportMessage = null;
|
||||
@@ -343,8 +303,8 @@
|
||||
|
||||
const handleExportJSON = () => {
|
||||
try {
|
||||
exportMatchesToJSON(displayMatches);
|
||||
exportMessage = `Successfully exported ${displayMatches.length} matches to JSON`;
|
||||
exportMatchesToJSON(matchesState.items);
|
||||
exportMessage = `Successfully exported ${matchesState.items.length} matches to JSON`;
|
||||
exportDropdownOpen = false;
|
||||
setTimeout(() => {
|
||||
exportMessage = null;
|
||||
@@ -467,7 +427,7 @@
|
||||
if (link) {
|
||||
const matchId = link.getAttribute('data-match-id');
|
||||
if (matchId) {
|
||||
storeMatchesState(matchId, matches.length);
|
||||
storeMatchesState(matchId, matchesState.items.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,8 +488,8 @@
|
||||
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
|
||||
<Filter class="mr-2 h-5 w-5" />
|
||||
Filters
|
||||
{#if activeFilterCount() > 0}
|
||||
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount()}</Badge>
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -538,7 +498,7 @@
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={displayMatches.length === 0}
|
||||
disabled={matchesState.items.length === 0}
|
||||
onclick={() => (exportDropdownOpen = !exportDropdownOpen)}
|
||||
>
|
||||
<Download class="mr-2 h-5 w-5" />
|
||||
@@ -816,20 +776,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
{#if matches.length > 0 && resultFilter !== 'all'}
|
||||
{#if matchesState.items.length > 0}
|
||||
<Badge variant="info">
|
||||
Showing {displayMatches.length} of {matches.length} matches
|
||||
Showing {matchesState.items.length} matches
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Matches Display (Grid or Table) -->
|
||||
{#if displayMatches.length > 0}
|
||||
{#if matchesState.items.length > 0}
|
||||
{#if viewMode === 'grid'}
|
||||
<!-- Grid View -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each displayMatches as match}
|
||||
<MatchCard {match} loadedCount={matches.length} />
|
||||
{#each matchesState.items as match (match.match_id)}
|
||||
<MatchCard {match} loadedCount={matchesState.items.length} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -852,7 +812,7 @@
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
data={displayMatches}
|
||||
data={matchesState.items}
|
||||
columns={tableColumns}
|
||||
striped={true}
|
||||
hoverable={true}
|
||||
@@ -863,65 +823,39 @@
|
||||
{/if}
|
||||
|
||||
<!-- Load More Trigger (for infinite scroll) -->
|
||||
{#if hasMore}
|
||||
{#if matchesState.hasMore}
|
||||
<div class="mt-8 text-center">
|
||||
<!-- Hidden trigger element for intersection observer -->
|
||||
<div bind:this={loadMoreTriggerRef} class="h-1 w-full"></div>
|
||||
|
||||
<!-- Visible load more button for manual loading -->
|
||||
<Button variant="primary" size="lg" onclick={loadMore} disabled={isLoadingMore}>
|
||||
{#if isLoadingMore}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onclick={loadMore}
|
||||
disabled={matchesState.isLoadingMore}
|
||||
>
|
||||
{#if matchesState.isLoadingMore}
|
||||
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading...
|
||||
{:else}
|
||||
Load More Matches
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isLoadingMore}
|
||||
{#if matchesState.isLoadingMore}
|
||||
<p class="mt-2 text-sm text-base-content/60">Loading more matches...</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
Showing {matches.length} matches {hasMore ? '(more available)' : '(all loaded)'}
|
||||
Showing {matchesState.items.length} matches (more available)
|
||||
</p>
|
||||
</div>
|
||||
{:else if matches.length > 0}
|
||||
{:else if matchesState.items.length > 0}
|
||||
<div class="mt-8 text-center">
|
||||
<Badge variant="default">
|
||||
All matches loaded ({matches.length} total{resultFilter !== 'all'
|
||||
? `, ${displayMatches.length} shown`
|
||||
: ''})
|
||||
All matches loaded ({matchesState.items.length} total)
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if matches.length > 0 && displayMatches.length === 0}
|
||||
<Card padding="lg">
|
||||
<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">
|
||||
No matches match your current filters. Try adjusting your filter settings.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{#if resultFilter !== 'all'}
|
||||
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
|
||||
Clear Result Filter
|
||||
</Button>
|
||||
{/if}
|
||||
{#if fromDate || toDate}
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
}}
|
||||
>
|
||||
Clear Date Filter
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" onclick={clearAllFilters}>Clear All Filters</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -41,6 +41,7 @@ export const load: PageLoad = async ({ url }) => {
|
||||
return {
|
||||
matches: [],
|
||||
hasMore: false,
|
||||
nextPageTime: undefined,
|
||||
filters: { map, playerId },
|
||||
meta: {
|
||||
title: 'Browse Matches - CS2.WTF',
|
||||
|
||||
Reference in New Issue
Block a user