Files

155 lines
5.9 KiB
TypeScript
Raw Permalink 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 { 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 (AZ)</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>
);
}