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() {