feat(search): live YouTube search UI
Surface live YouTube search in the existing feed, triggered explicitly so the expensive API call is never per-keystroke. - Header: the search box still filters the local catalog as you type; Enter or a YouTube button escalates the term to a live search (hidden for the demo account). - Feed: a dedicated infinite query renders results in the same VirtualFeed cards + in-app player, under a banner with a back button and a quota note. No auto-paginate (each page spends 100 units) — an explicit 'Load more (uses quota)' button instead; quota/limit errors (incl. 429) shown inline. The empty local feed offers a 'Search YouTube for <q>' CTA. - Library: a 'Search-discovered' toggle reveals search-ingested videos (hidden by default); sent as exclude_search_discovered. - Admin: search_daily_limit_per_user config field; new videos_search quota label. - All new strings translated in HU/EN/DE.
This commit is contained in:
parent
9b1bdb6b42
commit
546be57963
16 changed files with 240 additions and 15 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
|
||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||
import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
|
||||
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
import { notify, resolveVideo } from "../lib/notifications";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
|
|
@ -70,6 +70,8 @@ export default function Feed({
|
|||
canRead,
|
||||
isDemo = false,
|
||||
onOpenWizard,
|
||||
ytSearch,
|
||||
setYtSearch,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
|
|
@ -77,6 +79,10 @@ export default function Feed({
|
|||
canRead: boolean;
|
||||
isDemo?: boolean;
|
||||
onOpenWizard: () => void;
|
||||
// Live YouTube search: the active search term (null = normal feed), and its setter so the
|
||||
// empty-state CTA / "back to feed" can toggle it. Search affordances are hidden for demo.
|
||||
ytSearch: string | null;
|
||||
setYtSearch: (q: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
|
|
@ -92,11 +98,27 @@ export default function Feed({
|
|||
[]
|
||||
);
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["feed", filters],
|
||||
queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
|
||||
});
|
||||
|
||||
// Live YouTube search results. Each page spends 100 quota units, so we never auto-paginate
|
||||
// on scroll — the user pulls more with an explicit button. 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),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Drop optimistic status overrides when filters change OR fresh server data
|
||||
|
|
@ -116,6 +138,7 @@ export default function Feed({
|
|||
queryKey: ["feed-count", filters],
|
||||
queryFn: () => api.feedCount(filters),
|
||||
staleTime: 30_000,
|
||||
enabled: !ytActive,
|
||||
});
|
||||
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
|
|
@ -232,6 +255,86 @@ export default function Feed({
|
|||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
|
||||
.filter((v) => matchesView(v.status, filters.show));
|
||||
|
||||
// --- Live YouTube search mode -------------------------------------------------------------
|
||||
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
||||
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
|
||||
if (ytActive) {
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
loadedRef.current = ytLoaded;
|
||||
const ytItems: Video[] = ytLoaded
|
||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
|
||||
const ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
? ytQuery.error.detail || t("feed.yt.error")
|
||||
: ytQuery.isError
|
||||
? t("feed.yt.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
|
||||
<button
|
||||
onClick={() => setYtSearch(null)}
|
||||
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">{t("feed.yt.quotaNote")}</span>
|
||||
</div>
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
||||
) : ytError ? (
|
||||
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
|
||||
) : ytItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
|
||||
) : (
|
||||
<>
|
||||
<VirtualFeed
|
||||
items={ytItems}
|
||||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={false}
|
||||
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") : t("feed.yt.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeVideo && (
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
onClose={() => setActiveVideo(null)}
|
||||
onState={onState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
||||
// Empty "my" feed with no YouTube access. The shared demo account can never connect
|
||||
|
|
@ -311,6 +414,25 @@ export default function Feed({
|
|||
</button>
|
||||
);
|
||||
})}
|
||||
{filters.scope === "all" && (
|
||||
<>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({ ...filters, showSearchDiscovered: !filters.showSearchDiscovered })
|
||||
}
|
||||
aria-pressed={!!filters.showSearchDiscovered}
|
||||
title={t("feed.searchDiscoveredTip")}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
||||
filters.showSearchDiscovered
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{t("feed.searchDiscovered")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t border-border pt-2.5">
|
||||
<span className="text-sm text-muted">
|
||||
|
|
@ -372,7 +494,18 @@ export default function Feed({
|
|||
<div className="p-4">
|
||||
{toolbar}
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
{filters.q.trim() && !isDemo && (
|
||||
<button
|
||||
onClick={() => setYtSearch(filters.q.trim())}
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" />
|
||||
{t("feed.yt.searchFor", { query: filters.q.trim() })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<VirtualFeed
|
||||
items={items}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Library, Search, User } from "lucide-react";
|
||||
import { Library, Search, User, Youtube } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
|
|
@ -13,14 +13,20 @@ export default function Header({
|
|||
setFilters,
|
||||
page,
|
||||
onGoToFullHistory,
|
||||
onYtSearch,
|
||||
}: {
|
||||
me: Me;
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
page: Page;
|
||||
onGoToFullHistory: () => void;
|
||||
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
||||
// can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const canSearchYt = !me.is_demo;
|
||||
const trimmedQ = filters.q.trim();
|
||||
|
||||
return (
|
||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||
|
|
@ -56,14 +62,31 @@ export default function Header({
|
|||
)}
|
||||
|
||||
{page === "feed" ? (
|
||||
<div className="flex-1 max-w-xl mx-auto relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
// Enter runs a live YouTube search for the typed term (the box itself filters
|
||||
// the local catalog as you type; Enter escalates to the YouTube API).
|
||||
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
|
||||
}}
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{trimmedQ && canSearchYt && (
|
||||
<button
|
||||
onClick={() => onYtSearch(trimmedQ)}
|
||||
title={t("header.searchYoutubeTip")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" />
|
||||
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 text-center text-sm font-semibold">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue