155 lines
5.9 KiB
TypeScript
155 lines
5.9 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
|
||
import { formatRelativeTime } from '@/lib/dates';
|
||
|
||
interface Player {
|
||
steamid: string;
|
||
current_name: string;
|
||
current_country: string | null;
|
||
matched_name: string | null;
|
||
total_minutes: number | null;
|
||
last_connected: string | null;
|
||
avatarUrl: string;
|
||
}
|
||
|
||
export default function PlayerSearch() {
|
||
const [q, setQ] = useState('');
|
||
const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent');
|
||
const [players, setPlayers] = useState<Player[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
|
||
const offsetRef = useRef(0);
|
||
const loadingMoreRef = useRef(false); // guards against the observer firing twice for one fetch
|
||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||
|
||
async function fetchPage(searchQ: string, searchSort: string, offset: number) {
|
||
const params = new URLSearchParams({ q: searchQ, sort: searchSort, offset: String(offset) });
|
||
const res = await fetch(`/api/players?${params.toString()}`);
|
||
return res.json() as Promise<{ players: Player[]; hasMore: boolean }>;
|
||
}
|
||
|
||
// Search or sort changed: reset and load page 1 fresh, replacing the list.
|
||
useEffect(() => {
|
||
const handle = setTimeout(async () => {
|
||
setLoading(true);
|
||
offsetRef.current = 0;
|
||
const data = await fetchPage(q, sort, 0);
|
||
setPlayers(data.players);
|
||
setHasMore(data.hasMore);
|
||
offsetRef.current = data.players.length;
|
||
setLoading(false);
|
||
}, 250); // debounce so we're not hitting the DB on every keystroke
|
||
|
||
return () => clearTimeout(handle);
|
||
}, [q, sort]);
|
||
|
||
async function loadMore() {
|
||
if (loadingMoreRef.current || !hasMore || loading) return;
|
||
loadingMoreRef.current = true;
|
||
setLoadingMore(true);
|
||
const data = await fetchPage(q, sort, offsetRef.current);
|
||
setPlayers((prev) => [...prev, ...data.players]);
|
||
setHasMore(data.hasMore);
|
||
offsetRef.current += data.players.length;
|
||
setLoadingMore(false);
|
||
loadingMoreRef.current = false;
|
||
}
|
||
|
||
// Fetch the next page once the sentinel at the bottom of the list scrolls
|
||
// into view, rather than waiting for an explicit "load more" click.
|
||
useEffect(() => {
|
||
const el = sentinelRef.current;
|
||
if (!el) return;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting) loadMore();
|
||
},
|
||
{ rootMargin: '400px' }, // start fetching a bit before it's actually visible
|
||
);
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [hasMore, loading, q, sort]);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex gap-3">
|
||
<input
|
||
type="text"
|
||
placeholder="Search by name or SteamID…"
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
className="flex-1 bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
|
||
/>
|
||
<select
|
||
value={sort}
|
||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||
className="bg-base-panel border border-base-border rounded-lg px-3 text-sm text-ink-muted focus:border-accent/50 transition-colors"
|
||
>
|
||
<option value="recent">Recently active</option>
|
||
<option value="name">Name (A–Z)</option>
|
||
<option value="playtime">Highest playtime</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="panel row-divide">
|
||
{loading ? (
|
||
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
|
||
) : players.length === 0 ? (
|
||
<div className="p-4 text-sm text-ink-muted">No players found.</div>
|
||
) : (
|
||
players.map((p, i) => (
|
||
<Link
|
||
key={p.steamid}
|
||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-ink-faint text-xs w-8 text-right shrink-0">{i + 1}</span>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={p.avatarUrl}
|
||
alt=""
|
||
className="w-9 h-9 rounded-full object-cover ring-1 ring-base-border shrink-0"
|
||
/>
|
||
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
|
||
<div>
|
||
<div className="text-ink text-sm">{p.current_name}</div>
|
||
{p.matched_name && p.matched_name !== p.current_name && (
|
||
<div className="text-xs text-ink-faint">previously: {p.matched_name}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className="mono text-ink-faint text-xs w-16 text-right">
|
||
{formatRelativeTime(p.last_connected)}
|
||
</span>
|
||
<span className="mono text-ink-faint text-xs w-20 text-right">
|
||
{formatMinutes(p.total_minutes)}
|
||
</span>
|
||
<span className="mono text-ink-faint">{p.steamid}</span>
|
||
</div>
|
||
</Link>
|
||
))
|
||
)}
|
||
|
||
{/* Sentinel — fetches the next page once this scrolls into view */}
|
||
{!loading && players.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>
|
||
);
|
||
}
|