feat(onboarding): auto-import subscriptions after read grant + empty-feed guidance

After read access is granted the wizard now imports the user's YouTube
subscriptions automatically (with a "Building your feed…" progress state), so a
new user lands on a populated feed — channels already in the shared catalog show
up instantly, new ones backfill in the background. The empty feed now prompts
users without read access to set up via the wizard instead of a bare message.
This commit is contained in:
npeter83 2026-06-14 06:36:12 +02:00
parent 56e65bb8a8
commit d287defc66
3 changed files with 122 additions and 11 deletions

View file

@ -179,7 +179,13 @@ export default function App() {
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
) : ( ) : (
<Feed filters={filters} setFilters={setFilters} view={view} /> <Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={meQuery.data!.can_read}
onOpenWizard={() => setWizardOpen(true)}
/>
)} )}
</main> </main>
</div> </div>

View file

@ -26,10 +26,14 @@ export default function Feed({
filters, filters,
setFilters, setFilters,
view, view,
canRead,
onOpenWizard,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
view: "grid" | "list"; view: "grid" | "list";
canRead: boolean;
onOpenWizard: () => void;
}) { }) {
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const [activeVideo, setActiveVideo] = useState<Video | null>(null); const [activeVideo, setActiveVideo] = useState<Video | null>(null);
@ -125,8 +129,26 @@ export default function Feed({
if (query.isLoading) return <div className="p-8 text-muted">Loading feed</div>; if (query.isLoading) return <div className="p-8 text-muted">Loading feed</div>;
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>; if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
if (items.length === 0) if (items.length === 0) {
if (!canRead)
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">Your feed is empty</h2>
<p className="text-sm text-muted mt-2 mb-4">
Connect your YouTube account to import your subscriptions and build your feed.
</p>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
Set up my feed
</button>
</div>
</div>
);
return <div className="p-8 text-muted">No videos match these filters.</div>; return <div className="p-8 text-muted">No videos match these filters.</div>;
}
return ( return (
<div className="p-4"> <div className="p-4">

View file

@ -1,6 +1,7 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Me } from "../lib/api"; import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding"; import { beginGrant, endOnboarding } from "../lib/onboarding";
// A first-login wizard that walks the user through granting YouTube access one scope at a // 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 // 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". // 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. // A small reusable heads-up so the Google consent warning never feels like a surprise.
function ConsentHeadsUp() { function ConsentHeadsUp() {
@ -52,6 +55,42 @@ function Dots({ index, total }: { index: number; total: number }) {
} }
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) { 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 // Closing without finishing the essential read step suppresses future auto-popups
// (it stays reachable from Settings). Once read is granted, closing is just "done". // (it stays reachable from Settings). Once read is granted, closing is just "done".
const close = () => { const close = () => {
@ -68,6 +107,15 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [me.can_read]); }, [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 step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2; 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 && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 className="text-lg font-semibold">Building your feed</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
Importing your YouTube subscriptions. Channels already in Siftlode show up
instantly; any new ones fill in automatically in the background.
</p>
</>
)}
{step === "write" && !importing && (
<> <>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent"> <div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" /> <Check className="w-6 h-6" />
</div> </div>
<h2 className="text-lg font-semibold">You're connected</h2> <h2 className="text-lg font-semibold">You're connected</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4"> <p className="text-sm text-muted leading-relaxed mt-2 mb-4">
Your feed is ready. Optionally, you can let Siftlode{" "} {importSubs.isError ? (
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you <>Your YouTube account is connected, but importing subscriptions failed you
this needs an extra write permission. Skip it to stay read-only; you can always can retry from Settings Sync. </>
enable it later in Settings. ) : (
<>
Your feed is ready
{myStatus.data ? ` (${myStatus.data.channels_total} channels)` : ""}. Optionally,
let Siftlode{" "}
</>
)}
{!importSubs.isError && (
<>
<span className="text-fg/90">unsubscribe</span> 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.
</>
)}
</p> </p>
{importSubs.isError && (
<button
onClick={() => importSubs.mutate()}
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
<RefreshCw className="w-4 h-4" /> Retry import
</button>
)}
<ConsentHeadsUp /> <ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2"> <div className="mt-5 flex flex-col gap-2">
<button <button
@ -145,7 +228,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</> </>
)} )}
{step === "done" && ( {step === "done" && !importing && (
<> <>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent"> <div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" /> <Check className="w-6 h-6" />