v3: search engine, responsive header with compact user menu
This commit is contained in:
256
src/pages/Search.tsx
Normal file
256
src/pages/Search.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user