diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5d5b59..6d157e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import Login from "./components/Login"; import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; -import Channels from "./components/Channels"; +import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; @@ -64,6 +64,7 @@ export default function App() { const [page, setPageState] = useState(readPage); const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); + const [channelFilter, setChannelFilter] = useState("all"); function setFilters(next: FeedFilters) { setFiltersState(next); @@ -157,6 +158,10 @@ export default function App() { page={page} setPage={setPage} onOpenSettings={() => setSettingsOpen(true)} + onGoToFullHistory={() => { + setChannelFilter("needs_full"); + setPage("channels"); + }} />
{page === "feed" && ( @@ -171,6 +176,8 @@ export default function App() { {page === "channels" ? ( { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setPage("feed"); @@ -179,7 +186,13 @@ export default function App() { ) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : ( - + setWizardOpen(true)} + /> )}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index f36afef..a9ffc63 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -18,12 +18,25 @@ import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; +export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; + +const STATUS_FILTERS: { id: ChannelStatusFilter; label: string }[] = [ + { id: "all", label: "All" }, + { id: "needs_full", label: "Needs full history" }, + { id: "fully_synced", label: "Fully synced" }, + { id: "hidden", label: "Hidden" }, +]; + export default function Channels({ canWrite, onViewChannel, + statusFilter, + setStatusFilter, }: { canWrite: boolean; onViewChannel: (id: string, name: string) => void; + statusFilter: ChannelStatusFilter; + setStatusFilter: (f: ChannelStatusFilter) => void; }) { const qc = useQueryClient(); const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); @@ -54,6 +67,19 @@ export default function Channels({ qc.invalidateQueries({ queryKey: ["feed-count"] }); }, }); + // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list + // doesn't jump (the server list is priority-sorted; refetching would reorder rows and + // lose your scroll position). The new order takes effect next time the page loads. + const bumpPriority = (id: string, current: number, delta: number) => { + const next = current + delta; + qc.setQueryData(["channels"], (old) => + (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)) + ); + api + .updateChannel(id, { priority: next }) + .catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); + }; + const attach = useMutation({ mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId), onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), @@ -103,9 +129,17 @@ export default function Channels({ onError: () => notify({ level: "error", message: "Couldn't request full history" }), }); - const channels = (channelsQuery.data ?? []).filter( - (c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()) - ); + const channels = (channelsQuery.data ?? []) + .filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())) + .filter((c) => + statusFilter === "needs_full" + ? !c.backfill_done + : statusFilter === "fully_synced" + ? c.backfill_done + : statusFilter === "hidden" + ? c.hidden + : true + ); const s = statusQuery.data; return ( @@ -179,6 +213,23 @@ export default function Channels({ + {/* Status filter */} +
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+

Set a channel's priority to push its videos up when you sort by “Channel priority”, attach your own tags to filter the feed, @@ -244,7 +295,7 @@ export default function Channels({ unsubscribe.mutate(c.id); }} onView={() => onViewChannel(c.id, c.title ?? "This channel")} - onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })} + onPriority={(d) => bumpPriority(c.id, c.priority, d)} onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index b7d7a35..b29955d 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -26,10 +26,14 @@ export default function Feed({ filters, setFilters, view, + canRead, + onOpenWizard, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; view: "grid" | "list"; + canRead: boolean; + onOpenWizard: () => void; }) { const [overrides, setOverrides] = useState>({}); const [activeVideo, setActiveVideo] = useState

Loading feed…
; if (query.isError) return
Couldn't load the feed.
; - if (items.length === 0) + if (items.length === 0) { + if (!canRead) + return ( +
+
+

Your feed is empty

+

+ Connect your YouTube account to import your subscriptions and build your feed. +

+ +
+
+ ); return
No videos match these filters.
; + } return (
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 99e4eda..70190dd 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -13,6 +13,7 @@ export default function Header({ page, setPage, onOpenSettings, + onGoToFullHistory, }: { me: Me; filters: FeedFilters; @@ -20,6 +21,7 @@ export default function Header({ page: Page; setPage: (p: Page) => void; onOpenSettings: () => void; + onGoToFullHistory: () => void; }) { async function logout() { await fetch("/auth/logout", { method: "POST", credentials: "include" }); @@ -36,7 +38,7 @@ export default function Header({ Siftlode - + {page === "feed" ? (
diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 2bc6981..51ac1dd 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -1,6 +1,7 @@ -import { useEffect } from "react"; -import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react"; -import type { Me } from "../lib/api"; +import { useEffect, useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react"; +import { api, type Me } from "../lib/api"; import { beginGrant, endOnboarding } from "../lib/onboarding"; // A first-login wizard that walks the user through granting YouTube access one scope at a @@ -10,6 +11,8 @@ import { beginGrant, endOnboarding } from "../lib/onboarding"; // // The visible step is derived from what's already granted, so the wizard resumes correctly // after each redirect: no read -> "read", read but no write -> "write", both -> "done". +// Right after read is granted, it auto-imports the user's subscriptions so they land on a +// populated feed (channels already in the shared catalog appear instantly). // A small reusable heads-up so the Google consent warning never feels like a surprise. function ConsentHeadsUp() { @@ -52,6 +55,42 @@ function Dots({ index, total }: { index: number; total: number }) { } export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) { + const qc = useQueryClient(); + const importTriggered = useRef(false); + + // Once we have read access, find out whether any subscriptions are imported yet. + const myStatus = useQuery({ + queryKey: ["my-status"], + queryFn: api.myStatus, + enabled: me.can_read, + }); + + const importSubs = useMutation({ + mutationFn: () => api.syncSubscriptions(), + onSuccess: () => { + // Feed/channels/status now have content — refresh the app behind the wizard. + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["feed-count"] }); + qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: ["my-status"] }); + }, + }); + + // First time we see read access with zero imported channels, pull the user's + // subscriptions automatically so they don't have to hunt for a button. + useEffect(() => { + if ( + me.can_read && + myStatus.data && + myStatus.data.channels_total === 0 && + !importTriggered.current + ) { + importTriggered.current = true; + importSubs.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me.can_read, myStatus.data?.channels_total]); + // Closing without finishing the essential read step suppresses future auto-popups // (it stays reachable from Settings). Once read is granted, closing is just "done". const close = () => { @@ -68,6 +107,15 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () // eslint-disable-next-line react-hooks/exhaustive-deps }, [me.can_read]); + // Are we currently importing (or about to)? Show a progress screen instead of the + // "you're connected" step until the first import settles. + const importing = + me.can_read && + !importSubs.isError && + (importSubs.isPending || + (importTriggered.current && !importSubs.isSuccess) || + (myStatus.data?.channels_total === 0 && !importSubs.isSuccess)); + const step = !me.can_read ? "read" : !me.can_write ? "write" : "done"; const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2; @@ -112,18 +160,53 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () )} - {step === "write" && ( + {/* Read granted: show import progress before the optional write step. */} + {me.can_read && importing && ( + <> +
+ +
+

Building your feed…

+

+ Importing your YouTube subscriptions. Channels already in Siftlode show up + instantly; any new ones fill in automatically in the background. +

+ + )} + + {step === "write" && !importing && ( <>

You're connected

- Your feed is ready. Optionally, you can let Siftlode{" "} - unsubscribe from channels on YouTube for you — - this needs an extra write permission. Skip it to stay read-only; you can always - enable it later in Settings. + {importSubs.isError ? ( + <>Your YouTube account is connected, but importing subscriptions failed — you + can retry from Settings → Sync. + ) : ( + <> + Your feed is ready + {myStatus.data ? ` (${myStatus.data.channels_total} channels)` : ""}. Optionally, + let Siftlode{" "} + + )} + {!importSubs.isError && ( + <> + unsubscribe from channels on YouTube for you + — this needs an extra write permission. Skip it to stay read-only; you can + always enable it later in Settings. + + )}

+ {importSubs.isError && ( + + )}
+ + + )} + {isAdmin && (