fix(demo): graceful UX when YouTube actions are unavailable

The shared demo account no longer hits YouTube affordances: the onboarding
wizard never opens (or renders) for it, the empty-feed prompt nudges into
the shared library instead of the connect wizard, and the browser-facing
/auth/upgrade redirects the demo home instead of returning a raw 403 JSON.
This commit is contained in:
npeter83 2026-06-16 09:27:34 +02:00
parent cc43488781
commit cac0ff7489
6 changed files with 50 additions and 18 deletions

View file

@ -320,13 +320,17 @@ def require_human(user: User = Depends(current_user)) -> User:
@router.get("/upgrade")
async def upgrade(
request: Request, access: str = "read", user: User = Depends(require_human)
request: Request, access: str = "read", user: User = Depends(current_user)
):
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
read-only (enough to build the feed); `access=write` additionally grants management
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
the least-privileged read scope."""
# This is the browser-facing redirect entry point, so the shared demo account is bounced
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo:
return RedirectResponse(url="/")
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect(
request,

View file

@ -291,13 +291,14 @@ export default function App() {
setFilters={setFilters}
view={view}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
/>
)}
</main>
</div>
</div>
{wizardOpen && meQuery.data && (
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
{aboutOpen && (

View file

@ -68,12 +68,14 @@ export default function Feed({
setFilters,
view,
canRead,
isDemo = false,
onOpenWizard,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
canRead: boolean;
isDemo?: boolean;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
@ -204,14 +206,28 @@ export default function Feed({
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
// Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
// with no toolbar (content-type/sort would be meaningless here).
// Empty "my" feed with no YouTube access. The shared demo account can never connect
// YouTube, so it gets a gentle nudge into the shared library instead of the connect
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<h2 className="text-lg font-semibold">
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
</h2>
<p className="text-sm text-muted mt-2 mb-4">
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
</p>
{isDemo ? (
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
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"
>
{t("feed.browseLibrary")}
</button>
) : (
<>
<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"
@ -224,6 +240,8 @@ export default function Feed({
>
{t("feed.browseShared")}
</button>
</>
)}
</div>
</div>
);

View file

@ -5,6 +5,9 @@
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
"setUp": "Meinen Feed einrichten",
"browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen",
"demoEmptyTitle": "Hier in der Demo ist nichts",
"demoEmptyBody": "Das Demo-Konto hat keine eigenen Abos — aber die gesamte gemeinsame Bibliothek steht dir offen: durchsuchen, filtern und sortieren.",
"browseLibrary": "Bibliothek durchsuchen",
"noMatches": "Keine Videos entsprechen diesen Filtern.",
"videoCount_one": "{{formattedCount}} Video",
"videoCount_other": "{{formattedCount}} Videos",

View file

@ -5,6 +5,9 @@
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
"setUp": "Set up my feed",
"browseShared": "Or just browse the shared library",
"demoEmptyTitle": "Nothing here in the demo",
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
"browseLibrary": "Browse the library",
"noMatches": "No videos match these filters.",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",

View file

@ -5,6 +5,9 @@
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
"setUp": "Hírfolyam beállítása",
"browseShared": "Vagy csak böngészd a közös könyvtárat",
"demoEmptyTitle": "Itt a demóban nincs semmi",
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"browseLibrary": "Könyvtár böngészése",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",