Files
projects-jenz/discord_verificiation/playtime-frontend/components/MapImpactLeaderboard.tsx
T

175 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}