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