import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { AppHeader } from "../components/AppHeader.tsx"; import { SearchBar } from "../components/SearchBar.tsx"; import { DumpCard } from "../components/DumpCard.tsx"; import { PlaylistCard } from "../components/PlaylistCard.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { deserializeDump, deserializePlaylist, deserializePublicUser, type Dump, type Playlist, type PublicUser, type RawDump, type RawPlaylist, type RawPublicUser, } from "../model.ts"; import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts"; type Tab = "dumps" | "users" | "playlists"; type SearchState = | { status: "idle" } | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; q: string; dumps: { items: Dump[]; total: number; hasMore: boolean; page: number; loadingMore: boolean; }; users: PublicUser[]; playlists: Playlist[]; }; export function Search() { const [searchParams, setSearchParams] = useSearchParams(); const q = searchParams.get("q") ?? ""; const tab = (searchParams.get("tab") ?? "dumps") as Tab; const { token, user } = useAuth(); const { voteCounts, myVotes, castVote, removeVote } = useWS(); const [state, setState] = useState({ status: "idle" }); const abortRef = useRef(null); const fetchSearch = useCallback(async (query: string, page: number) => { if (!query.trim()) { setState({ status: "idle" }); return; } if (page === 1) { setState({ status: "loading" }); } abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; try { const url = new URL(SEARCH_URL); url.searchParams.set("q", query); url.searchParams.set("page", String(page)); url.searchParams.set("limit", String(DEFAULT_PAGE_SIZE)); const res = await fetch(url.toString(), { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error(`Search failed (${res.status})`); const body = await res.json() as { success: true; data: { dumps: { items: RawDump[]; total: number; hasMore: boolean }; users: RawPublicUser[]; playlists: RawPlaylist[]; }; }; const { data } = body; if (page === 1) { setState({ status: "loaded", q: query, dumps: { items: data.dumps.items.map(deserializeDump), total: data.dumps.total, hasMore: data.dumps.hasMore, page: 1, loadingMore: false, }, users: data.users.map(deserializePublicUser), playlists: data.playlists.map(deserializePlaylist), }); } else { setState((prev) => { if (prev.status !== "loaded") return prev; return { ...prev, dumps: { ...prev.dumps, items: [...prev.dumps.items, ...data.dumps.items.map(deserializeDump)], hasMore: data.dumps.hasMore, page, loadingMore: false, }, }; }); } } catch (err) { if ((err as Error).name === "AbortError") return; setState({ status: "error", error: (err as Error).message }); } }, [token]); useEffect(() => { fetchSearch(q, 1); return () => abortRef.current?.abort(); }, [q, fetchSearch]); const loadMore = useCallback(() => { if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore) return; setState((prev) => { if (prev.status !== "loaded") return prev; return { ...prev, dumps: { ...prev.dumps, loadingMore: true } }; }); fetchSearch(q, state.dumps.page + 1); }, [state, q, fetchSearch]); const sentinelRef = useInfiniteScroll( loadMore, state.status === "loaded" && tab === "dumps" && state.dumps.hasMore && !state.dumps.loadingMore, ); function setTab(t: Tab) { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", t); return next; }, { replace: true }); } const dumpCount = state.status === "loaded" ? state.dumps.total : null; const userCount = state.status === "loaded" ? state.users.length : null; const playlistCount = state.status === "loaded" ? state.playlists.length : null; function tabLabel(t: Tab, count: number | null) { const label = t === "dumps" ? "Dumps" : t === "users" ? "Users" : "Playlists"; return count !== null ? `${label} (${count})` : label; } return (
} />
{q && (
{(["dumps", "users", "playlists"] as Tab[]).map((t) => ( ))}
)} {state.status === "idle" && (

Enter a query to search.

)} {state.status === "loading" && (

Searching…

)} {state.status === "error" && ( )} {state.status === "loaded" && tab === "dumps" && ( <> {state.dumps.items.length === 0 ?

No dumps match "{q}".

: (
    {state.dumps.items.map((dump) => ( ))}
)}
{state.dumps.loadingMore &&

Loading more…

} {!state.dumps.hasMore && state.dumps.items.length > 0 && (

You've reached the end.

)} )} {state.status === "loaded" && tab === "users" && ( state.users.length === 0 ?

No users match "{q}".

: (
    {state.users.map((u) => (
  • @{u.username} {u.description && ( {u.description} )}
  • ))}
) )} {state.status === "loaded" && tab === "playlists" && ( state.playlists.length === 0 ?

No playlists match "{q}".

: (
    {state.playlists.map((p) => ( ))}
) )}
); }