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 // 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. // (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_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" }, date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" }, popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_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" }, subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_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", date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
}; };
function parseSort(s: string): { key: SortKey; dir: "asc" | "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)[]) { 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].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" }; 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" }; return { key: "date", dir: "desc" };
} }
function buildSort(key: SortKey, dir: "asc" | "desc"): string { 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 { function matchesView(status: string, show: string): boolean {
@ -134,6 +137,22 @@ export default function Feed({
retry: false, 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 // Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override // 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 // (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 // 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 // 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. // 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(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: ["feed-count"] });
@ -445,7 +467,7 @@ export default function Feed({
</button> </button>
); );
})} })}
{filters.scope === "all" && ( {(
<> <>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <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"> <label className="inline-flex items-center gap-1.5 text-xs text-muted">
@ -484,7 +506,9 @@ export default function Feed({
value={sortKey} value={sortKey}
onChange={(e) => { onChange={(e) => {
const key = e.target.value as SortKey; 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({ setFilters({
...filters, ...filters,
sort: buildSort(key, dir), 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" 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}> <option key={k} value={k}>
{t("feed.sortKey." + k)} {t("feed.sortKey." + k)}
</option> </option>
))} ))}
</select> </select>
{sortKey !== "shuffle" && ( {!DIRECTIONLESS.includes(sortKey) && (
<button <button
onClick={() => onClick={() =>
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") }) setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })

View file

@ -21,7 +21,8 @@
"title": "Name", "title": "Name",
"subscribers": "Kanal-Abonnenten", "subscribers": "Kanal-Abonnenten",
"priority": "Kanal-Priorität", "priority": "Kanal-Priorität",
"shuffle": "Überraschung" "shuffle": "Überraschung",
"relevance": "Relevanz"
}, },
"loadingMore": "Mehr wird geladen…", "loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”", "hiddenNamed": "Ausgeblendet: „{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Nicht mehr als angesehen", "unwatch": "Nicht mehr als angesehen",
"source": { "source": {
"label": "Quelle", "label": "Quelle",
"tip": "Bibliothek danach filtern, wie Videos hierher kamen: nur organischer Katalog, mit Suchergebnissen, oder nur über Live-YouTube-Suche gefundene Videos.", "tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.",
"organic": "Ohne Suchergebnisse", "organic": "Ohne Suchergebnisse",
"all": "Mit Suchergebnissen", "all": "Mit Suchergebnissen",
"only": "Nur Suchergebnisse" "only": "Nur Suchergebnisse"

View file

@ -21,7 +21,8 @@
"title": "Name", "title": "Name",
"subscribers": "Channel subscribers", "subscribers": "Channel subscribers",
"priority": "Channel priority", "priority": "Channel priority",
"shuffle": "Surprise me" "shuffle": "Surprise me",
"relevance": "Relevance"
}, },
"loadingMore": "Loading more…", "loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”", "hiddenNamed": "Hidden “{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Unwatch", "unwatch": "Unwatch",
"source": { "source": {
"label": "Source", "label": "Source",
"tip": "Filter the Library by how videos got here: organic catalog only, plus search results, or only videos found via live YouTube search.", "tip": "Filter by how videos got here: your subscriptions/catalog only, plus videos you found via live YouTube search, or only those search results.",
"organic": "Hide search results", "organic": "Hide search results",
"all": "Include search results", "all": "Include search results",
"only": "Search results only" "only": "Search results only"

View file

@ -21,7 +21,8 @@
"title": "Név", "title": "Név",
"subscribers": "Csatorna feliratkozók", "subscribers": "Csatorna feliratkozók",
"priority": "Csatorna prioritás", "priority": "Csatorna prioritás",
"shuffle": "Meglepetés" "shuffle": "Meglepetés",
"relevance": "Relevancia"
}, },
"loadingMore": "Továbbiak betöltése…", "loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”", "hiddenNamed": "Elrejtve: „{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Megnézés visszavonása", "unwatch": "Megnézés visszavonása",
"source": { "source": {
"label": "Forrás", "label": "Forrás",
"tip": "Szűrd a könyvtárat aszerint, hogyan kerültek be a videók: csak organikus katalógus, kereséssel együtt, vagy csak az élő YouTube-keresésből talált videók.", "tip": "Szűrés aszerint, hogyan kerültek be a videók: csak a feliratkozásaid/katalógus, az élő YouTube-keresésből talált videóiddal együtt, vagy csak azok a keresési találatok.",
"organic": "Keresés nélkül", "organic": "Keresés nélkül",
"all": "Kereséssel együtt", "all": "Kereséssel együtt",
"only": "Csak keresésből" "only": "Csak keresésből"