v3: search engine, responsive header with compact user menu

This commit is contained in:
khannurien
2026-03-29 11:56:31 +00:00
parent f0f6472db6
commit cbb3505139
31 changed files with 1206 additions and 178 deletions

256
src/pages/Search.tsx Normal file
View 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>
);
}