Merge: onboarding auto-import + empty-feed guidance
This commit is contained in:
commit
930381f2d4
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