Merge improvement/demo-ux: graceful demo handling of unavailable YouTube actions
This commit is contained in:
commit
c35d4f9184
6 changed files with 50 additions and 18 deletions
|
|
@ -320,13 +320,17 @@ def require_human(user: User = Depends(current_user)) -> User:
|
||||||
|
|
||||||
@router.get("/upgrade")
|
@router.get("/upgrade")
|
||||||
async def 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
|
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||||
read-only (enough to build the feed); `access=write` additionally grants management
|
read-only (enough to build the feed); `access=write` additionally grants management
|
||||||
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
(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
|
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
||||||
the least-privileged read scope."""
|
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
|
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||||
return await oauth.google.authorize_redirect(
|
return await oauth.google.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -291,13 +291,14 @@ export default function App() {
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
view={view}
|
view={view}
|
||||||
canRead={meQuery.data!.can_read}
|
canRead={meQuery.data!.can_read}
|
||||||
|
isDemo={meQuery.data!.is_demo}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{wizardOpen && meQuery.data && (
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
)}
|
)}
|
||||||
{aboutOpen && (
|
{aboutOpen && (
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,14 @@ export default function Feed({
|
||||||
setFilters,
|
setFilters,
|
||||||
view,
|
view,
|
||||||
canRead,
|
canRead,
|
||||||
|
isDemo = false,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
|
isDemo?: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -204,26 +206,42 @@ export default function Feed({
|
||||||
|
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
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>;
|
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,
|
// Empty "my" feed with no YouTube access. The shared demo account can never connect
|
||||||
// with no toolbar (content-type/sort would be meaningless here).
|
// 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")
|
if (items.length === 0 && !canRead && filters.scope === "my")
|
||||||
return (
|
return (
|
||||||
<div className="p-8 grid place-items-center text-center">
|
<div className="p-8 grid place-items-center text-center">
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
|
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
|
||||||
<button
|
</h2>
|
||||||
onClick={onOpenWizard}
|
<p className="text-sm text-muted mt-2 mb-4">
|
||||||
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"
|
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||||
>
|
</p>
|
||||||
{t("feed.setUp")}
|
{isDemo ? (
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||||
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"
|
||||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
>
|
||||||
>
|
{t("feed.browseLibrary")}
|
||||||
{t("feed.browseShared")}
|
</button>
|
||||||
</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"
|
||||||
|
>
|
||||||
|
{t("feed.setUp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||||
|
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("feed.browseShared")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
||||||
"setUp": "Meinen Feed einrichten",
|
"setUp": "Meinen Feed einrichten",
|
||||||
"browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen",
|
"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.",
|
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
||||||
"videoCount_one": "{{formattedCount}} Video",
|
"videoCount_one": "{{formattedCount}} Video",
|
||||||
"videoCount_other": "{{formattedCount}} Videos",
|
"videoCount_other": "{{formattedCount}} Videos",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
||||||
"setUp": "Set up my feed",
|
"setUp": "Set up my feed",
|
||||||
"browseShared": "Or just browse the shared library",
|
"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.",
|
"noMatches": "No videos match these filters.",
|
||||||
"videoCount_one": "{{formattedCount}} video",
|
"videoCount_one": "{{formattedCount}} video",
|
||||||
"videoCount_other": "{{formattedCount}} videos",
|
"videoCount_other": "{{formattedCount}} videos",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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",
|
"setUp": "Hírfolyam beállítása",
|
||||||
"browseShared": "Vagy csak böngészd a közös könyvtárat",
|
"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.",
|
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||||
"videoCount_one": "{{formattedCount}} videó",
|
"videoCount_one": "{{formattedCount}} videó",
|
||||||
"videoCount_other": "{{formattedCount}} videó",
|
"videoCount_other": "{{formattedCount}} videó",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue