diff --git a/frontend/src/app/[locale]/leaderboard/page.tsx b/frontend/src/app/[locale]/leaderboard/page.tsx index a9cd55da..36a8c644 100644 --- a/frontend/src/app/[locale]/leaderboard/page.tsx +++ b/frontend/src/app/[locale]/leaderboard/page.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import Image from "next/image"; +import React, { useState, useEffect, useCallback, useMemo, Suspense } from "react"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; @@ -13,8 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/Select"; -import { Image as ImageIcon, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Trophy } from "lucide-react"; -import type { LeaderboardPlayer } from "@/types/leaderboard"; +import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Trophy, Loader2 } from "lucide-react"; import type { LeaderboardCategory } from "@/types/leaderboard"; import { useLeaderboard } from "@/hooks/useLeaderboard"; @@ -31,6 +30,16 @@ const CATEGORIES: { value: LeaderboardCategory; label: string }[] = [ { value: "casual", label: "Casual" }, ]; +const SEASONS: { id: string; label: string }[] = [ + { id: "current", label: "Current Season" }, + { id: "season-5", label: "Season 5" }, + { id: "season-4", label: "Season 4" }, + { id: "season-3", label: "Season 3" }, + { id: "season-2", label: "Season 2" }, + { id: "season-1", label: "Season 1" }, + { id: "all-time", label: "All Time" }, +]; + const SORT_COLUMNS = ["eloRating", "wins", "winRate", "matchesPlayed"] as const; type SortColumn = (typeof SORT_COLUMNS)[number]; @@ -187,10 +196,15 @@ function SkeletonRows({ count }: { count: number }) { } // --------------------------------------------------------------------------- -// Main page +// Main page content (inner — uses useSearchParams) // --------------------------------------------------------------------------- -export default function LeaderboardPage() { +function LeaderboardContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [category, setCategory] = useState("global"); + const [season, setSeason] = useState(() => searchParams.get("season") ?? "current"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [sortBy, setSortBy] = useState("eloRating"); @@ -198,6 +212,22 @@ export default function LeaderboardPage() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); + const handleSeasonChange = useCallback( + (newSeason: string) => { + setSeason(newSeason); + setPage(1); + const params = new URLSearchParams(searchParams.toString()); + if (newSeason === "current") { + params.delete("season"); + } else { + params.set("season", newSeason); + } + const qs = params.toString(); + router.push(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); + // Debounce search useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search), 350); @@ -212,10 +242,11 @@ export default function LeaderboardPage() { const offset = (page - 1) * pageSize; // Fetch from the real API via useLeaderboard - const { data, isLoading, isError, refetch } = useLeaderboard( + const { data, isLoading, isFetching, isError, refetch } = useLeaderboard( category, pageSize, - offset + offset, + season, ); // Derived values @@ -276,7 +307,7 @@ export default function LeaderboardPage() { Filters -
+
{/* Search */}
+ {/* Season */} +
+ + +
+ {/* Sort */}
{/* Pagination */} - {!isLoading && totalCount > 0 && ( + {!isFetching && totalCount > 0 && ( )} @@ -541,3 +594,20 @@ export default function LeaderboardPage() {
); } + +// --------------------------------------------------------------------------- +// Page export — wraps content in Suspense for useSearchParams +// --------------------------------------------------------------------------- +export default function LeaderboardPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/frontend/src/app/[locale]/leaderboards/page.tsx b/frontend/src/app/[locale]/leaderboards/page.tsx index 133aa826..4a257c04 100644 --- a/frontend/src/app/[locale]/leaderboards/page.tsx +++ b/frontend/src/app/[locale]/leaderboards/page.tsx @@ -1,6 +1,8 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useCallback, Suspense } from "react"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { Loader2 } from "lucide-react"; import { LeaderboardTable } from "@/components/leaderboard/LeaderboardTable"; import { CategorySelector } from "@/components/leaderboard/CategorySelector"; import { SeasonSelector } from "@/components/leaderboard/SeasonSelector"; @@ -9,13 +11,32 @@ import { LeaderboardFilters } from "@/components/leaderboard/LeaderboardFilters" import { useLeaderboard, useLeaderboardStats } from "@/hooks/useLeaderboard"; import type { LeaderboardCategory } from "@/types/leaderboard"; -export default function LeaderboardsPage() { +function LeaderboardsContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [category, setCategory] = useState("global"); - const [season, setSeason] = useState("current"); + const [season, setSeason] = useState(() => searchParams.get("season") ?? "current"); const [sortBy, setSortBy] = useState<"points" | "wins" | "winRate">("points"); const [searchQuery, setSearchQuery] = useState(""); - const { data: leaderboardData, isLoading } = useLeaderboard(category, 100, 0); + const handleSeasonChange = useCallback( + (newSeason: string) => { + setSeason(newSeason); + const params = new URLSearchParams(searchParams.toString()); + if (newSeason === "current") { + params.delete("season"); + } else { + params.set("season", newSeason); + } + const qs = params.toString(); + router.push(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + const { data: leaderboardData, isLoading, isFetching } = useLeaderboard(category, 100, 0, season); const { data: statsData } = useLeaderboardStats(category); const entries = leaderboardData?.entries || []; @@ -65,7 +86,7 @@ export default function LeaderboardsPage() { {/* Filters */}
- +
@@ -77,7 +98,7 @@ export default function LeaderboardsPage() { {/* Leaderboard Table */}
-

+

{category === "global" ? "Global Rankings" : category === "tournaments" @@ -85,6 +106,9 @@ export default function LeaderboardsPage() { : category === "casual" ? "Casual Rankings" : "Ranked Rankings"} + {isFetching && !isLoading && ( + + )}

{filteredEntries.length} players @@ -93,7 +117,7 @@ export default function LeaderboardsPage() { setSortBy(col as any)} /> @@ -102,3 +126,17 @@ export default function LeaderboardsPage() {
); } + +export default function LeaderboardsPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 45219d5d..99199c9a 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -59,7 +59,9 @@ export function AppLayout({ children }: AppLayoutProps) {
-
{children}
+
+ {children} +
diff --git a/frontend/src/components/match/MatchDetailView.tsx b/frontend/src/components/match/MatchDetailView.tsx index f702150f..6764fc94 100644 --- a/frontend/src/components/match/MatchDetailView.tsx +++ b/frontend/src/components/match/MatchDetailView.tsx @@ -23,6 +23,15 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +function isValidHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + interface MatchDetailViewProps { match: MatchDetail; currentUserId?: string; @@ -39,6 +48,9 @@ export function MatchDetailView({ const player2Won = match.winnerId === match.player2Id; const isDisputed = match.status === "disputed"; + const replayUrl = match.replayUrl?.trim() ?? ""; + const hasReplay = replayUrl.length > 0 && isValidHttpUrl(replayUrl); + return (
{isDisputed && ( @@ -96,12 +108,17 @@ export function MatchDetailView({

- {match.replayUrl && ( + {hasReplay && ( )} {match.canDispute && !isDisputed && ( diff --git a/frontend/src/hooks/useLeaderboard.ts b/frontend/src/hooks/useLeaderboard.ts index 16ffa01e..575aef9c 100644 --- a/frontend/src/hooks/useLeaderboard.ts +++ b/frontend/src/hooks/useLeaderboard.ts @@ -9,12 +9,22 @@ import { const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1' -export const useLeaderboard = (category: string, limit = 100, offset = 0) => { +export const useLeaderboard = ( + category: string, + limit = 100, + offset = 0, + season?: string, +) => { return useQuery({ - queryKey: ['leaderboard', category, limit, offset], + queryKey: ['leaderboard', category, limit, offset, season], queryFn: async () => { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + }) + if (season) params.set('season', season) const res = await fetch( - `${API_BASE}/leaderboards/${category}?limit=${limit}&offset=${offset}` + `${API_BASE}/leaderboards/${category}?${params}` ) if (!res.ok) throw new Error('Failed to fetch leaderboard') return res.json() as Promise