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:
parent
4b3bb897b5
commit
51f859fd5a
3 changed files with 122 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue