After the clean name/email sign-in, a wizard walks the user through granting YouTube read (then optionally write) one step at a time, each with a plain rationale and an up-front heads-up about Google's "unverified app" screen. The visible step is derived from the granted scopes (can_read/can_write) so the flow resumes correctly across the full-page consent redirect; it's dismissible and reopenable from Settings -> Account, which now lists read and write as separate, individually-grantable access rows.
176 lines
7.2 KiB
TypeScript
176 lines
7.2 KiB
TypeScript
import { useEffect } from "react";
|
|
import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react";
|
|
import 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
|
|
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
|
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
|
|
// always an explicit, informed opt-in here.
|
|
//
|
|
// 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".
|
|
|
|
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
|
function ConsentHeadsUp() {
|
|
return (
|
|
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
|
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
|
<p className="text-xs leading-relaxed text-fg/80">
|
|
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
|
|
screen — that's expected, because Subfeed hasn't gone through Google's full review.
|
|
It's safe to continue: click <span className="font-medium">Advanced</span> →{" "}
|
|
<span className="font-medium">Go to Subfeed</span>. You can revoke access anytime from
|
|
your{" "}
|
|
<a
|
|
href="https://myaccount.google.com/permissions"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent hover:underline"
|
|
>
|
|
Google Account
|
|
</a>
|
|
.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dots({ index, total }: { index: number; total: number }) {
|
|
return (
|
|
<div className="flex justify-center gap-1.5">
|
|
{Array.from({ length: total }).map((_, i) => (
|
|
<span
|
|
key={i}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
|
// 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 = () => {
|
|
endOnboarding(!me.can_read);
|
|
onClose();
|
|
};
|
|
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") close();
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [me.can_read]);
|
|
|
|
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
|
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 grid place-items-center p-4">
|
|
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
|
|
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
|
<button
|
|
onClick={close}
|
|
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
|
|
{step === "read" && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Youtube className="w-6 h-6" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
|
You're signed in. To build your feed, Subfeed needs{" "}
|
|
<span className="text-fg/90">read-only</span> access to the channels you're
|
|
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
|
|
</p>
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<button
|
|
onClick={() => beginGrant("read")}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
|
>
|
|
<Youtube className="w-4 h-4" /> Connect (read-only)
|
|
</button>
|
|
<button
|
|
onClick={close}
|
|
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
|
>
|
|
Skip for now
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === "write" && (
|
|
<>
|
|
<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 Subfeed{" "}
|
|
<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>
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<button
|
|
onClick={() => beginGrant("write")}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
|
>
|
|
<ShieldCheck className="w-4 h-4" /> Enable unsubscribe
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
endOnboarding(false);
|
|
onClose();
|
|
}}
|
|
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
|
>
|
|
No thanks — keep it read-only
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === "done" && (
|
|
<>
|
|
<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">All set</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
|
Read and unsubscribe access are both granted. You can review or revoke either one
|
|
anytime in Settings → Account, or from your Google Account.
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
endOnboarding(false);
|
|
onClose();
|
|
}}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
|
|
>
|
|
Done
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="mt-6">
|
|
<Dots index={stepIndex} total={3} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|