feat(welcome): landing lightbox, prod-served screenshots & session-end UX

- 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.
This commit is contained in:
npeter83 2026-06-19 19:52:29 +02:00
parent 4571800991
commit c59695d2b3
7 changed files with 180 additions and 22 deletions

View file

@ -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