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:
parent
6cef826ecc
commit
2e5919399c
4 changed files with 43 additions and 15 deletions
|
|
@ -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") })
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue