From e77c5f4780fe97862c9be7327148384e901559f3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 19:52:29 +0200 Subject: [PATCH] feat(welcome): landing lightbox, prod-served screenshots & session-end UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Serve real files at the SPA root (e.g. /welcome/*.png, favicon) from the built app — the landing screenshots were only ever shown by the Vite dev server, never in production. - Add the Channel-manager screenshot; the two secondary previews are smaller thumbnails that open in a custom full-size lightbox (fit-to-viewport, Esc/backdrop/✕ to close). - Drop a suspended/deleted user to the login page on the next 401 (and poll the session only while signed in, so the public login page no longer flickers); 'suspended' and 'account deleted' confirmation banners; strip redirect query params for a clean address bar. --- backend/app/main.py | 7 ++ frontend/src/App.tsx | 46 +++++++- frontend/src/components/Welcome.tsx | 137 +++++++++++++++++++--- frontend/src/i18n/locales/de/welcome.json | 4 +- frontend/src/i18n/locales/en/welcome.json | 4 +- frontend/src/i18n/locales/hu/welcome.json | 4 +- 6 files changed, 180 insertions(+), 22 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 77c2a5e..220b1a5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -107,4 +107,11 @@ async def spa_fallback(full_path: str) -> FileResponse: # Client-side routes fall back to index.html; real API/asset paths are matched above. if full_path.startswith(("api/", "auth/", "healthz", "assets/")): raise HTTPException(status_code=404) + # Serve real files that live at the SPA root (Vite copies public/ there — e.g. the landing + # screenshots under /welcome/, favicon). /assets is already mounted above; everything else + # that isn't a real file is a client-side route → index.html. Guard against path traversal. + if full_path: + candidate = (STATIC_DIR / full_path).resolve() + if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: + return FileResponse(candidate) return FileResponse(STATIC_DIR / "index.html") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 009f5a3..b1a03a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { api, HttpError, type FeedFilters } from "./lib/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; import { applyTheme, @@ -264,7 +264,47 @@ export default function App() { return () => window.removeEventListener("popstate", onPop); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); + const queryClient = useQueryClient(); + // Poll the session so an account suspended/deleted by an admin is noticed even with no + // interaction (option 1): the refetch 401s → the 401 handler below drops to the login page. + // Only poll while signed in (data present) — polling on the public login page is pointless and + // its periodic refetch caused a visible flicker there. + const meQuery = useQuery({ + queryKey: ["me"], + queryFn: api.me, + refetchInterval: (query) => (query.state.data ? 60_000 : false), + }); + + // Any request that 401s means the session ended server-side. If we were signed in (cached + // `me` exists), reload to the login page at once (option 2 — also covers any click that hits + // the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop. + useEffect(() => { + setUnauthorizedHandler(() => { + if (queryClient.getQueryData(["me"])) { + queryClient.clear(); + window.location.reload(); + } + }); + return () => setUnauthorizedHandler(null); + }, [queryClient]); + + // Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…). + // Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on + // Settings → Account so the new state is visible, then strip the param for a clean URL. + useEffect(() => { + const result = new URLSearchParams(window.location.search).get("link"); + if (!result) return; + if (result === "ok") { + notify({ level: "success", message: t("settings.account.googleLink.linked") }); + void meQuery.refetch(); + localStorage.setItem("siftlode.settingsTab", "account"); + setPage("settings"); + } else { + const key = result === "conflict" || result === "mismatch" ? result : "error"; + notify({ level: "error", message: t(`settings.account.googleLink.${key}`) }); + } + stripUrlParams(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps // First-login onboarding: prompt the user to connect YouTube (and resume the flow after // each consent redirect). Derived from granted scopes + storage flags so it's stable diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 3bddb6b..df0daa9 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -1,12 +1,14 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + Expand, Filter, Inbox, ListVideo, Lock, PlayCircle, Tags, + X, type LucideIcon, } from "lucide-react"; import { api } from "../lib/api"; @@ -25,6 +27,9 @@ type Mode = "signin" | "register" | "forgot" | "reset"; // backend); errors are surfaced inline (the api calls use the quiet flag). export default function Welcome() { const { t, i18n } = useTranslation(); + // Which screenshot (if any) is open in the full-size lightbox. + const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null); + const open = (src: string, label: string) => setZoomed({ src, label }); return (
@@ -48,7 +53,12 @@ export default function Welcome() { {/* App preview */} - + {/* Features */}
@@ -60,13 +70,27 @@ export default function Welcome() {
- {/* Secondary showcase */} -
- - + {/* Secondary showcase — smaller thumbnails; click to view full size. */} +
+ +
+ {zoomed && ( + setZoomed(null)} /> + )} +