From 2e5919399c9e4c013681414863a5433d9d9fce5f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 00:39:21 +0200 Subject: [PATCH] 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. --- frontend/src/components/Feed.tsx | 43 ++++++++++++++++++++------ frontend/src/i18n/locales/de/feed.json | 5 +-- frontend/src/i18n/locales/en/feed.json | 5 +-- frontend/src/i18n/locales/hu/feed.json | 5 +-- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 0223735..13ac40a 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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, { asc: string; desc: string }> = { +const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"]; +const SORT_MAP: Record, { 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, { asc: string; desc: string subscribers: { desc: "subscribers", asc: "subscribers_asc" }, priority: { desc: "priority", asc: "priority_asc" }, }; -const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { +const SORT_DEFAULT_DIR: Record, "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({ ); })} - {filters.scope === "all" && ( + {( <>