feat(feed): Source filter in Mine, Relevance sort, auto search-source

Frontend for the Mine search finds + relevance search:
- The Source filter (organic / include search / search-only) now shows in Mine
  scope too, not just the Library.
- Returning from a YouTube search via 'Back to feed' switches the Source filter
  to 'search' so your just-found videos show in the feed you land on (filtered
  by the kept term).
- New 'Relevance' sort, offered while a search term is present and auto-selected
  when you start searching (reverts to newest when you clear it). EN/HU/DE.
This commit is contained in:
npeter83 2026-06-30 00:39:21 +02:00
parent 6cef826ecc
commit 2e5919399c
4 changed files with 43 additions and 15 deletions

View file

@ -23,9 +23,11 @@ const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle";
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance";
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string }> = {
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
@ -33,11 +35,11 @@ const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" },
};
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle">, "asc" | "desc"> = {
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
};
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
if (s === "shuffle") return { key: "shuffle", dir: "desc" };
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
@ -45,7 +47,8 @@ function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
return { key: "date", dir: "desc" };
}
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir];
if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir];
}
function matchesView(status: string, show: string): boolean {
@ -134,6 +137,22 @@ export default function Feed({
retry: false,
});
// When a search term first appears, rank the local feed by relevance (YouTube-like); when
// it's cleared, fall back to the default newest sort. Only fires on the empty↔non-empty
// transition, so it never overrides a sort the user picks while a query stays active.
const qEmpty = !filters.q.trim();
const prevQEmptyRef = useRef(qEmpty);
useEffect(() => {
const sortKeyNow = parseSort(filters.sort).key;
if (prevQEmptyRef.current && !qEmpty && sortKeyNow !== "relevance") {
setFilters({ ...filters, sort: "relevance" });
} else if (!prevQEmptyRef.current && qEmpty && sortKeyNow === "relevance") {
setFilters({ ...filters, sort: buildSort("date", "desc") });
}
prevQEmptyRef.current = qEmpty;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qEmpty]);
// Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it
@ -296,6 +315,9 @@ export default function Feed({
// The search just ingested new catalog videos, so the normal feed (which was
// disabled and still holds its pre-search cache) is stale. Drop those caches so
// it re-fetches fresh on return instead of showing the old (often empty) result.
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" so your own search finds show (filtered by the kept term).
setFilters({ ...filters, librarySource: "search" });
onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] });
@ -445,7 +467,7 @@ export default function Feed({
</button>
);
})}
{filters.scope === "all" && (
{(
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
@ -484,7 +506,9 @@ export default function Feed({
value={sortKey}
onChange={(e) => {
const key = e.target.value as SortKey;
const dir = key === "shuffle" ? "desc" : SORT_DEFAULT_DIR[key];
const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
setFilters({
...filters,
sort: buildSort(key, dir),
@ -493,13 +517,14 @@ export default function Feed({
}}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{SORT_KEYS.map((k) => (
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...SORT_KEYS] as SortKey[]) : SORT_KEYS).map((k) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{sortKey !== "shuffle" && (
{!DIRECTIONLESS.includes(sortKey) && (
<button
onClick={() =>
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })