diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e3ee64..36f4f8a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -122,6 +122,9 @@ export default function App() { const saveMsgTimer = useRef(undefined); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); + // Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL, + // so a reload returns to the normal feed (a search spends quota, so we never auto-replay it). + const [ytSearch, setYtSearch] = useState(null); const [wizardOpen, setWizardOpen] = useState(false); // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); @@ -153,6 +156,8 @@ export default function App() { }; useEffect(() => { if (page !== "channels") setFocusChannelName(null); + // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. + if (page !== "feed") setYtSearch(null); }, [page]); function openReleaseNotes(highlight?: string) { @@ -500,6 +505,7 @@ export default function App() { filters={filters} setFilters={setFilters} page={page} + onYtSearch={setYtSearch} onGoToFullHistory={() => { setChannelFilter("needs_full"); setChannelsView("subscribed"); // the status filter applies to the subscriptions tab @@ -572,6 +578,8 @@ export default function App() { canRead={meQuery.data!.can_read} isDemo={meQuery.data!.is_demo} onOpenWizard={() => setWizardOpen(true)} + ytSearch={ytSearch} + setYtSearch={setYtSearch} /> )} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 300b7f9..06e473c 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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>({}); @@ -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 ( +
+
+ +
+ + {ytQuery.isLoading ? ( +
{t("feed.yt.searching")}
+ ) : ytError ? ( +
{ytError}
+ ) : ytItems.length === 0 ? ( +
{t("feed.yt.noResults")}
+ ) : ( + <> + {}} + /> + {ytQuery.hasNextPage && ( +
+ +
+ )} + + )} + + {activeVideo && ( + setActiveVideo(null)} + onState={onState} + /> + )} +
+ ); + } + if (query.isLoading) return
{t("feed.loading")}
; if (query.isError) return
{t("feed.loadError")}
; // Empty "my" feed with no YouTube access. The shared demo account can never connect @@ -311,6 +414,25 @@ export default function Feed({ ); })} + {filters.scope === "all" && ( + <> +