/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …". A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it. A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
/**
|
|
* Renders the results of a search, in a tab for dumps, one for users and one
|
|
* for playlists, the dumps and the playlists narrowed to a category of their
|
|
* own.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
import { Link, useSearchParams } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
|
import { TabBar } from "../components/shared/TabBar.tsx";
|
|
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
|
|
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
|
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
|
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
|
|
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
|
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
|
import { useTabParam } from "../hooks/shared/useTabParam.ts";
|
|
import {
|
|
deserializeDump,
|
|
deserializePlaylist,
|
|
deserializePublicUser,
|
|
} from "../utils/shared/deserialize.ts";
|
|
import type { Dump } from "../../shared/dump.ts";
|
|
import type { Playlist } from "../../shared/playlist.ts";
|
|
import type { PublicUser } from "../../shared/user.ts";
|
|
import type { Wire } from "../../shared/api.ts";
|
|
import type { SearchResults } from "../../shared/search.ts";
|
|
import type { CategoryKind } from "../../shared/category.ts";
|
|
import { SEARCH_URL } from "../config/api.ts";
|
|
import { expectOk } from "../utils/shared/api.ts";
|
|
import {
|
|
canLoadMore,
|
|
firstPage,
|
|
type PagedList,
|
|
pageUrl,
|
|
refetchPages,
|
|
} from "../utils/feed/pagedList.ts";
|
|
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
|
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
|
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
|
import { useOnReconnect } from "../hooks/ws/useWS.ts";
|
|
import {
|
|
SEARCH_TABS,
|
|
searchPath,
|
|
type SearchTab as Tab,
|
|
userPath,
|
|
} from "../../shared/paths.ts";
|
|
import { NotFound } from "./NotFound.tsx";
|
|
import { markdownToPlainText } from "../../shared/markdown.ts";
|
|
|
|
/**
|
|
* Held by `Search` for a query, from the first page of `SearchResults`. The
|
|
* dumps take the next pages.
|
|
*/
|
|
interface Results {
|
|
dumps: PagedList<Dump>;
|
|
/** It counts every match, which the pages loaded so far may not reach. */
|
|
dumpTotal: number;
|
|
users: PublicUser[];
|
|
playlists: Playlist[];
|
|
}
|
|
|
|
/** Returns the first page of a search's answer, as `Search` holds it. */
|
|
function toResults(
|
|
{ dumps, users, playlists }: Wire<SearchResults>,
|
|
): Results {
|
|
return {
|
|
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
|
|
dumpTotal: dumps.total ?? 0,
|
|
users: users.map(deserializePublicUser),
|
|
playlists: playlists.map(deserializePlaylist),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Names, for each tab a category narrows, the kind of its categories and the
|
|
* parameter of `GET /api/search` that takes one.
|
|
*/
|
|
const TAB_CATEGORIES: Partial<
|
|
Record<Tab, { kind: CategoryKind; param: string }>
|
|
> = {
|
|
dumps: { kind: "dump", param: "category" },
|
|
playlists: { kind: "playlist", param: "playlistCategory" },
|
|
};
|
|
|
|
/**
|
|
* Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come
|
|
* from `GET /api/search`, signed in when the viewer is, so they find their own
|
|
* private dumps and playlists too. The dumps load a page at a time as the
|
|
* "dumps" tab scrolls. A new `q` aborts the search under way. The socket keeps
|
|
* the dumps and playlists found live, as edited or deleted, but adds none; a
|
|
* reconnection runs the search again, for every page of dumps loaded.
|
|
*
|
|
* On the "dumps" and "playlists" tabs, a menu narrows the results to a
|
|
* category of their kind, kept in the URL's `?category=`, which another tab
|
|
* drops.
|
|
*/
|
|
export function Search(): React.JSX.Element {
|
|
useDocumentTitle(t`Search`);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const q = searchParams.get("q") ?? "";
|
|
const categorySlug = searchParams.get("category") || undefined;
|
|
const [tab, setTab] = useTabParam<Tab>(searchPath(), SEARCH_TABS, "dumps");
|
|
const filter = tab ? TAB_CATEGORIES[tab] : undefined;
|
|
const categoryParam = filter && categorySlug ? filter.param : undefined;
|
|
|
|
const { user, authFetch } = useAuth();
|
|
const search = useCallback(async (page: number, signal?: AbortSignal) => {
|
|
const url = new URL(SEARCH_URL, location.origin);
|
|
url.searchParams.set("q", q);
|
|
if (categoryParam && categorySlug) {
|
|
url.searchParams.set(categoryParam, categorySlug);
|
|
}
|
|
return expectOk<Wire<SearchResults>>(
|
|
await authFetch(pageUrl(url.toString(), page), { signal }),
|
|
);
|
|
}, [authFetch, q, categoryParam, categorySlug]);
|
|
|
|
const { state, setData } = useFetchState<Results>(
|
|
q.trim()
|
|
? `${q}:${user?.id ?? "guest"}:${categoryParam ?? ""}:${
|
|
categorySlug ?? ""
|
|
}`
|
|
: undefined,
|
|
(signal) => search(1, signal).then(toResults),
|
|
);
|
|
|
|
const dumps = state.status === "loaded" ? state.data.dumps : null;
|
|
const loadMore = useLoadMore(
|
|
dumps,
|
|
useCallback(
|
|
(fn: (dumps: PagedList<Dump>) => PagedList<Dump>) =>
|
|
setData((d) => ({ ...d, dumps: fn(d.dumps) })),
|
|
[setData],
|
|
),
|
|
(page) =>
|
|
search(page).then(({ dumps }) => ({
|
|
items: dumps.items.map(deserializeDump),
|
|
hasMore: dumps.hasMore,
|
|
})),
|
|
);
|
|
useDumpListSync(
|
|
dumps?.items ?? [],
|
|
useCallback(
|
|
(fn: (items: Dump[]) => Dump[]) =>
|
|
setData((d) => ({
|
|
...d,
|
|
dumps: { ...d.dumps, items: fn(d.dumps.items) },
|
|
})),
|
|
[setData],
|
|
),
|
|
// Signed in, the results hold the user's own private dumps and playlists.
|
|
{ isOwner: true },
|
|
);
|
|
usePlaylistListSync(
|
|
state.status === "loaded" ? state.data.playlists : [],
|
|
useCallback(
|
|
(fn: (items: Playlist[]) => Playlist[]) =>
|
|
setData((d) => ({ ...d, playlists: fn(d.playlists) })),
|
|
[setData],
|
|
),
|
|
{ isOwner: true },
|
|
);
|
|
|
|
// What changed while the socket was down: every page of dumps loaded is
|
|
// fetched again, and the users and playlists, which come with the first.
|
|
const searchRef = useRef(search);
|
|
useLayoutEffect(() => {
|
|
searchRef.current = search;
|
|
});
|
|
useOnReconnect(() => {
|
|
if (state.status !== "loaded") return;
|
|
const asked = search;
|
|
let first: Wire<SearchResults> | undefined;
|
|
refetchPages(state.data.dumps.page, (page) =>
|
|
asked(page).then((res) => {
|
|
if (page === 1) first = res;
|
|
return {
|
|
items: res.dumps.items.map(deserializeDump),
|
|
hasMore: res.dumps.hasMore,
|
|
};
|
|
}))
|
|
.then(({ items, hasMore }) => {
|
|
// The query changed meanwhile, and its own results are on screen.
|
|
if (!first || asked !== searchRef.current) return;
|
|
const fresh = toResults(first);
|
|
setData((d) => ({ ...fresh, dumps: { ...d.dumps, items, hasMore } }));
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
|
|
const sentinelRef = useInfiniteScroll(
|
|
loadMore,
|
|
tab === "dumps" && canLoadMore(dumps),
|
|
);
|
|
|
|
const results = state.status === "loaded" ? state.data : null;
|
|
const dumpCount = results ? results.dumpTotal : null;
|
|
const userCount = results ? results.users.length : null;
|
|
const playlistCount = results ? results.playlists.length : null;
|
|
|
|
function tabLabel(tab: Tab, count: number | null) {
|
|
const label = tab === "dumps"
|
|
? t`Dumps`
|
|
: tab === "users"
|
|
? t`Users`
|
|
: t`Playlists`;
|
|
return count !== null ? t`${label} (${count})` : label;
|
|
}
|
|
|
|
if (!tab) return <NotFound />;
|
|
|
|
return (
|
|
<div className="page-shell">
|
|
<AppHeader />
|
|
<main className="search-page">
|
|
{q && (
|
|
<TabBar
|
|
tabs={SEARCH_TABS.map((key) => ({
|
|
key,
|
|
label: tabLabel(
|
|
key,
|
|
key === "dumps"
|
|
? dumpCount
|
|
: key === "users"
|
|
? userCount
|
|
: playlistCount,
|
|
),
|
|
}))}
|
|
activeTab={tab}
|
|
onChange={(next) => {
|
|
// Each tab's categories are its own.
|
|
const params = new URLSearchParams(searchParams);
|
|
params.delete("category");
|
|
const query = params.toString();
|
|
setTab(next, query ? `?${query}` : "");
|
|
}}
|
|
className="search-tabs"
|
|
innerClassName="search-tabs-inner"
|
|
/>
|
|
)}
|
|
|
|
{q.trim() && filter && (
|
|
<div className="search-filter">
|
|
<CategorySwitcher
|
|
kind={filter.kind}
|
|
categorySlug={categorySlug}
|
|
onSelect={(slug) =>
|
|
setSearchParams((p) => {
|
|
if (slug) p.set("category", slug);
|
|
else p.delete("category");
|
|
return p;
|
|
})}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!q.trim() && (
|
|
<p className="search-page-empty">
|
|
<Trans>Enter a query to search.</Trans>
|
|
</p>
|
|
)}
|
|
|
|
{q.trim() && state.status === "loading" && (
|
|
<p className="search-page-empty">
|
|
<Trans>Searching…</Trans>
|
|
</p>
|
|
)}
|
|
|
|
{state.status === "error" && (
|
|
<ErrorCard title={t`Search failed`} message={state.error} />
|
|
)}
|
|
|
|
{results && tab === "dumps" && (
|
|
results.dumps.items.length === 0
|
|
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
|
|
: (
|
|
<>
|
|
<DumpCardList dumps={results.dumps.items} />
|
|
<FeedFooter
|
|
hasMore={results.dumps.hasMore}
|
|
loadingMore={results.dumps.loadingMore}
|
|
sentinelRef={sentinelRef}
|
|
/>
|
|
</>
|
|
)
|
|
)}
|
|
|
|
{results && tab === "users" && (
|
|
results.users.length === 0
|
|
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
|
|
: (
|
|
<ul className="user-results">
|
|
{results.users.map((u) => {
|
|
// The profile renders the description's Markdown; this
|
|
// line has room for its text only.
|
|
const description = u.description &&
|
|
markdownToPlainText(u.description, "");
|
|
return (
|
|
<li key={u.id}>
|
|
<Link
|
|
to={userPath(u.username)}
|
|
className="user-result-item"
|
|
>
|
|
<span className="user-result-name">@{u.username}</span>
|
|
{description && (
|
|
<span className="user-result-description">
|
|
{description}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)
|
|
)}
|
|
|
|
{results && tab === "playlists" && (
|
|
results.playlists.length === 0
|
|
? (
|
|
<p className="search-page-empty">
|
|
{t`No playlists match "${q}".`}
|
|
</p>
|
|
)
|
|
: <PlaylistCardList playlists={results.playlists} />
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|