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 4b3bb897b5
commit 51f859fd5a
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" ? (
<Stats />
) : (
<Feed filters={filters} setFilters={setFilters} view={view} />
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={meQuery.data!.can_read}
onOpenWizard={() => setWizardOpen(true)}
/>
)}
</main>
</div>

View file

@ -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<Record<string, string>>({});
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.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-4">

View file

@ -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 && (
<>
<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">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">You're connected</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
Your feed is ready. Optionally, you can let Siftlode{" "}
<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.
{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 && (
<>
<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>
{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 />
<div className="mt-5 flex flex-col gap-2">
<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">
<Check className="w-6 h-6" />