257 lines
8.0 KiB
TypeScript
257 lines
8.0 KiB
TypeScript
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<SearchState>({ status: "idle" });
|
|
const abortRef = useRef<AbortController | null>(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 (
|
|
<div className="page-shell">
|
|
<AppHeader centerSlot={<SearchBar />} />
|
|
<main className="search-page">
|
|
{q && (
|
|
<div className="search-tabs">
|
|
{(["dumps", "users", "playlists"] as Tab[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
className={`feed-sort-btn${tab === t ? " active" : ""}`}
|
|
onClick={() => setTab(t)}
|
|
>
|
|
{tabLabel(t, t === "dumps" ? dumpCount : t === "users" ? userCount : playlistCount)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "idle" && (
|
|
<p className="search-page-empty">Enter a query to search.</p>
|
|
)}
|
|
|
|
{state.status === "loading" && (
|
|
<p className="search-page-empty">Searching…</p>
|
|
)}
|
|
|
|
{state.status === "error" && (
|
|
<ErrorCard title="Search failed" message={state.error} />
|
|
)}
|
|
|
|
{state.status === "loaded" && tab === "dumps" && (
|
|
<>
|
|
{state.dumps.items.length === 0
|
|
? <p className="search-page-empty">No dumps match "{q}".</p>
|
|
: (
|
|
<ul className="dump-feed">
|
|
{state.dumps.items.map((dump) => (
|
|
<DumpCard
|
|
key={dump.id}
|
|
dump={dump}
|
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
|
voted={myVotes.has(dump.id)}
|
|
canVote={!!user}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
isOwner={user?.id === dump.userId}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div ref={sentinelRef} />
|
|
{state.dumps.loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
|
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
|
|
<p className="feed-end">You've reached the end.</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{state.status === "loaded" && tab === "users" && (
|
|
state.users.length === 0
|
|
? <p className="search-page-empty">No users match "{q}".</p>
|
|
: (
|
|
<ul className="user-results">
|
|
{state.users.map((u) => (
|
|
<li key={u.id}>
|
|
<Link to={`/users/${u.username}`} className="user-result-item">
|
|
<span className="user-result-name">@{u.username}</span>
|
|
{u.description && (
|
|
<span className="user-result-description">{u.description}</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
)}
|
|
|
|
{state.status === "loaded" && tab === "playlists" && (
|
|
state.playlists.length === 0
|
|
? <p className="search-page-empty">No playlists match "{q}".</p>
|
|
: (
|
|
<ul className="dump-feed">
|
|
{state.playlists.map((p) => (
|
|
<PlaylistCard key={p.id} playlist={p} />
|
|
))}
|
|
</ul>
|
|
)
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|