175 lines
6.5 KiB
TypeScript
175 lines
6.5 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { formatMinutes } from '@/lib/steam';
|
||
import { useInfiniteScroll } from '@/lib/useInfiniteScroll';
|
||
|
||
interface MapImpactRow {
|
||
map_name: string;
|
||
times_played: number;
|
||
total_minutes: number;
|
||
last_played: string;
|
||
avg_net_change: number | null;
|
||
avg_retention_pct: number | null;
|
||
best_session_change: number | null;
|
||
worst_session_change: number | null;
|
||
}
|
||
|
||
type SortBy = 'impact' | 'popularity';
|
||
type Direction = 'asc' | 'desc';
|
||
|
||
function ChangeBadge({ value }: { value: number | null }) {
|
||
if (value == null) return <span className="text-ink-faint">—</span>;
|
||
const rounded = Math.round(value * 10) / 10;
|
||
const color = rounded > 0 ? '#3FD37A' : rounded < 0 ? '#E0665C' : '#8A9691';
|
||
return (
|
||
<span className="mono" style={{ color }}>
|
||
{rounded > 0 ? '+' : ''}
|
||
{rounded}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function MapImpactLeaderboard() {
|
||
const [sortBy, setSortBy] = useState<SortBy>('impact');
|
||
const [direction, setDirection] = useState<Direction>('asc'); // impact default: worst (most population-harming) first
|
||
const [maps, setMaps] = useState<MapImpactRow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
|
||
const offsetRef = useRef(0);
|
||
const loadingMoreRef = useRef(false);
|
||
|
||
async function fetchPage(sBy: SortBy, dir: Direction, offset: number) {
|
||
const params = new URLSearchParams({ sortBy: sBy, direction: dir, offset: String(offset) });
|
||
const res = await fetch(`/api/maps/impact?${params.toString()}`);
|
||
return res.json() as Promise<{ maps: MapImpactRow[]; hasMore: boolean }>;
|
||
}
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
offsetRef.current = 0;
|
||
fetchPage(sortBy, direction, 0).then((data) => {
|
||
setMaps(data.maps);
|
||
setHasMore(data.hasMore);
|
||
offsetRef.current = data.maps.length;
|
||
setLoading(false);
|
||
});
|
||
}, [sortBy, direction]);
|
||
|
||
async function loadMore() {
|
||
if (loadingMoreRef.current || !hasMore || loading) return;
|
||
loadingMoreRef.current = true;
|
||
setLoadingMore(true);
|
||
const data = await fetchPage(sortBy, direction, offsetRef.current);
|
||
setMaps((prev) => [...prev, ...data.maps]);
|
||
setHasMore(data.hasMore);
|
||
offsetRef.current += data.maps.length;
|
||
setLoadingMore(false);
|
||
loadingMoreRef.current = false;
|
||
}
|
||
|
||
const sentinelRef = useInfiniteScroll(loadMore, !loading && hasMore);
|
||
|
||
function switchSort(next: SortBy) {
|
||
if (next === sortBy) return;
|
||
setSortBy(next);
|
||
// Each metric has its own sensible default direction.
|
||
setDirection(next === 'popularity' ? 'desc' : 'asc');
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="flex rounded-lg border border-base-border overflow-hidden text-sm">
|
||
<button
|
||
onClick={() => switchSort('impact')}
|
||
className={`px-3 py-2 transition-colors ${
|
||
sortBy === 'impact' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
|
||
}`}
|
||
>
|
||
Sort by impact
|
||
</button>
|
||
<button
|
||
onClick={() => switchSort('popularity')}
|
||
className={`px-3 py-2 transition-colors border-l border-base-border ${
|
||
sortBy === 'popularity' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
|
||
}`}
|
||
>
|
||
Sort by popularity
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||
className="text-xs text-ink-muted hover:text-accent transition-colors underline"
|
||
>
|
||
{sortBy === 'impact'
|
||
? direction === 'asc'
|
||
? 'Showing worst impact first'
|
||
: 'Showing best impact first'
|
||
: direction === 'desc'
|
||
? 'Showing most played first'
|
||
: 'Showing least played first'}
|
||
{' '}— flip
|
||
</button>
|
||
</div>
|
||
|
||
<div className="panel row-divide">
|
||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
|
||
<div>Map</div>
|
||
<div className="text-right w-16">Played</div>
|
||
<div className="text-right w-24">Avg Δ</div>
|
||
<div className="text-right w-20">Retention</div>
|
||
<div className="text-right w-28">Best / worst Δ</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
|
||
) : maps.length === 0 ? (
|
||
<div className="p-4 text-sm text-ink-muted">No completed map sessions recorded yet.</div>
|
||
) : (
|
||
maps.map((m, i) => (
|
||
<Link
|
||
key={m.map_name}
|
||
href={`/maps/${encodeURIComponent(m.map_name)}`}
|
||
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 items-center px-4 py-3 hover:bg-base transition-colors text-sm"
|
||
>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="text-ink-faint text-xs w-6 text-right shrink-0">{i + 1}</span>
|
||
<div className="min-w-0">
|
||
<div className="text-ink truncate">{m.map_name}</div>
|
||
<div className="text-xs text-ink-faint">{formatMinutes(m.total_minutes)} total</div>
|
||
</div>
|
||
</div>
|
||
<div className="mono text-ink-muted text-right w-16">{m.times_played}×</div>
|
||
<div className="text-right w-24">
|
||
<ChangeBadge value={m.avg_net_change} />
|
||
</div>
|
||
<div className="mono text-ink-muted text-right w-20">
|
||
{m.avg_retention_pct == null ? '—' : `${Math.round(m.avg_retention_pct)}%`}
|
||
</div>
|
||
<div className="text-right w-28 text-xs">
|
||
<ChangeBadge value={m.best_session_change} /> <span className="text-ink-faint">/</span>{' '}
|
||
<ChangeBadge value={m.worst_session_change} />
|
||
</div>
|
||
</Link>
|
||
))
|
||
)}
|
||
|
||
{!loading && maps.length > 0 && (
|
||
<div ref={sentinelRef} className="p-4 text-center">
|
||
{loadingMore ? (
|
||
<span className="text-xs text-ink-faint font-mono">loading more…</span>
|
||
) : !hasMore ? (
|
||
<span className="text-xs text-ink-faint">— end of list —</span>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|