feat(search): ephemeral results UX, count selector, channel blocklist (frontend)
- Live-search view: a results-count selector (20/40/60/100) replaces manual load-more (the free scrape source pages until that many are gathered); an 'these results are temporary' banner with a 'Clear now' button that discards them 'as if never added' (api.clearSearch) and returns to the feed. - Channel blocklist: a Block/Unblock toggle + 'Blocked' badge on the channel page (blocked channels don't auto-explore and their videos are hidden), and a 'Blocked channels' section in the Channel manager with one-click unblock. ChannelDetail.blocked from the backend. - Admin: a 'Purge discovery' button on the Scheduler page (immediate un-kept search/explore cleanup). EN/HU/DE throughout.
This commit is contained in:
parent
f27f31b6db
commit
0c18845c34
21 changed files with 263 additions and 65 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
import { notify, resolveVideo } from "../lib/notifications";
|
||||
|
|
@ -118,6 +118,9 @@ export default function Feed({
|
|||
);
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
const [ytCount, setYtCount] = useState(20);
|
||||
|
||||
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
||||
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
||||
|
|
@ -139,8 +142,8 @@ export default function Feed({
|
|||
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
||||
// revisited search from re-spending.
|
||||
const ytQuery = useInfiniteQuery({
|
||||
queryKey: ["yt-search", ytSearch],
|
||||
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
|
||||
queryKey: ["yt-search", ytSearch, ytCount],
|
||||
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
|
|
@ -316,33 +319,74 @@ export default function Feed({
|
|||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 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" (your own search finds) and rank them by relevance.
|
||||
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
||||
onExitYtSearch();
|
||||
qc.removeQueries({ queryKey: ["feed"] });
|
||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||
qc.removeQueries({ queryKey: ["facets"] });
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("feed.yt.back")}
|
||||
</button>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<Youtube className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{t("feed.yt.resultsFor", { query: ytSearch })}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
||||
</span>
|
||||
<div className="pb-3 mb-1 border-b border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
// 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" (your own search finds) and rank them by relevance.
|
||||
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
||||
onExitYtSearch();
|
||||
qc.removeQueries({ queryKey: ["feed"] });
|
||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||
qc.removeQueries({ queryKey: ["facets"] });
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("feed.yt.back")}
|
||||
</button>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<Youtube className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{t("feed.yt.resultsFor", { query: ytSearch })}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
||||
</span>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||
{t("feed.yt.show")}
|
||||
<select
|
||||
value={ytCount}
|
||||
onChange={(e) => setYtCount(Number(e.target.value))}
|
||||
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[20, 40, 60, 100].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{ytItems.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{t("feed.yt.ephemeralNote")}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const ids = ytItems.map((v) => v.id);
|
||||
api
|
||||
.clearSearch(ids)
|
||||
.then((r) => notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }))
|
||||
.catch(() => {});
|
||||
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||
onExitYtSearch();
|
||||
qc.removeQueries({ queryKey: ["feed"] });
|
||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||
qc.removeQueries({ queryKey: ["facets"] });
|
||||
qc.removeQueries({ queryKey: ["yt-search"] });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("feed.yt.clearNow")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
|
|
@ -365,21 +409,6 @@ export default function Feed({
|
|||
isFetchingNextPage={false}
|
||||
fetchNextPage={() => {}}
|
||||
/>
|
||||
{ytQuery.hasNextPage && (
|
||||
<div className="text-center pt-4">
|
||||
<button
|
||||
onClick={() => ytQuery.fetchNextPage()}
|
||||
disabled={ytQuery.isFetchingNextPage}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
{ytQuery.isFetchingNextPage
|
||||
? t("feed.loadingMore")
|
||||
: zeroQuota
|
||||
? t("feed.yt.loadMoreFree")
|
||||
: t("feed.yt.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue