Files

128 lines
4.3 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 { formatMinutes } from '@/lib/steam';
interface MapSummary {
map_name: string;
times_played: number;
last_played: string;
total_minutes: number;
}
export default function MapSearch() {
const [q, setQ] = useState('');
const [maps, setMaps] = useState<MapSummary[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const offsetRef = useRef(0);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
async function fetchPage(searchQ: string, offset: number) {
const params = new URLSearchParams({ q: searchQ, offset: String(offset) });
const res = await fetch(`/api/maps?${params.toString()}`);
return res.json() as Promise<{ maps: MapSummary[]; hasMore: boolean }>;
}
useEffect(() => {
const handle = setTimeout(async () => {
setLoading(true);
offsetRef.current = 0;
const data = await fetchPage(q, 0);
setMaps(data.maps);
setHasMore(data.hasMore);
offsetRef.current = data.maps.length;
setLoading(false);
}, 250);
return () => clearTimeout(handle);
}, [q]);
async function loadMore() {
if (loadingMoreRef.current || !hasMore || loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const data = await fetchPage(q, offsetRef.current);
setMaps((prev) => [...prev, ...data.maps]);
setHasMore(data.hasMore);
offsetRef.current += data.maps.length;
setLoadingMore(false);
loadingMoreRef.current = false;
}
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: '400px' },
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasMore, loading, q]);
return (
<div className="space-y-4">
<input
type="text"
placeholder="Search by map name…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full 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"
/>
<div className="panel row-divide">
{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 maps found.</div>
) : (
maps.map((m) => (
<Link
key={m.map_name}
href={`/maps/${encodeURIComponent(m.map_name)}`}
className="group flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="text-ink text-sm">{m.map_name}</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end text-xs text-ink-faint font-mono gap-0.5">
<span>{m.times_played}× played · {formatMinutes(m.total_minutes)} total</span>
<span>last played {new Date(m.last_played.replace(' ', 'T')).toLocaleString()}</span>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-ink-faint group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0"
>
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</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>
);
}