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/public/welcome/channels.png b/frontend/public/welcome/channels.png new file mode 100644 index 0000000..31cf1e9 Binary files /dev/null and b/frontend/public/welcome/channels.png differ 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)} /> + )} +