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 3f9c395b17
commit 6db7014b08
7 changed files with 180 additions and 22 deletions

View file

@ -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. # Client-side routes fall back to index.html; real API/asset paths are matched above.
if full_path.startswith(("api/", "auth/", "healthz", "assets/")): if full_path.startswith(("api/", "auth/", "healthz", "assets/")):
raise HTTPException(status_code=404) 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") return FileResponse(STATIC_DIR / "index.html")

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, HttpError, type FeedFilters } from "./lib/api"; import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n"; import { setLanguage, isSupported, type LangCode } from "./i18n";
import { import {
applyTheme, applyTheme,
@ -264,7 +264,47 @@ export default function App() {
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // 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 // 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 // each consent redirect). Derived from granted scopes + storage flags so it's stable

View file

@ -1,12 +1,14 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Expand,
Filter, Filter,
Inbox, Inbox,
ListVideo, ListVideo,
Lock, Lock,
PlayCircle, PlayCircle,
Tags, Tags,
X,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { api } from "../lib/api"; 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). // backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome() { export default function Welcome() {
const { t, i18n } = useTranslation(); 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 ( return (
<div className="min-h-screen bg-bg text-fg overflow-y-auto"> <div className="min-h-screen bg-bg text-fg overflow-y-auto">
<header className="flex items-center justify-between px-5 sm:px-8 py-4"> <header className="flex items-center justify-between px-5 sm:px-8 py-4">
@ -48,7 +53,12 @@ export default function Welcome() {
</section> </section>
{/* App preview */} {/* App preview */}
<Preview src="/welcome/feed.png" label={t("welcome.preview.feed")} className="aspect-video" /> <Preview
src="/welcome/feed.png"
label={t("welcome.preview.feed")}
className="aspect-video"
onOpen={open}
/>
{/* Features */} {/* Features */}
<section className="mt-14"> <section className="mt-14">
@ -60,13 +70,27 @@ export default function Welcome() {
</div> </div>
</section> </section>
{/* Secondary showcase */} {/* Secondary showcase — smaller thumbnails; click to view full size. */}
<section className="grid sm:grid-cols-2 gap-4 mt-12"> <section className="mt-12 flex flex-wrap justify-center gap-4">
<Preview src="/welcome/channels.png" label={t("welcome.preview.channels")} className="aspect-[4/3]" /> <Preview
<Preview src="/welcome/playlists.png" label={t("welcome.preview.playlists")} className="aspect-[4/3]" /> src="/welcome/channels.png"
label={t("welcome.preview.channels")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
<Preview
src="/welcome/playlists.png"
label={t("welcome.preview.playlists")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
</section> </section>
</main> </main>
{zoomed && (
<Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />
)}
<footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center"> <footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center">
<span> <span>
Sift<span className="text-accent">lode</span> Sift<span className="text-accent">lode</span>
@ -107,33 +131,112 @@ function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder // Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
// frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/). // frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/).
function Preview({ src, label, className }: { src: string; label: string; className?: string }) { // When loaded it's a button that opens the full-size lightbox (onOpen).
function Preview({
src,
label,
className,
onOpen,
}: {
src: string;
label: string;
className?: string;
onOpen?: (src: string, label: string) => void;
}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
return ( if (failed) {
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}> return (
{failed ? ( <div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center"> <div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label} {label}
</div> </div>
) : ( </div>
);
}
return (
<button
type="button"
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
>
<img
src={src}
alt={label}
loading="lazy"
onError={() => setFailed(true)}
className="w-full h-full object-cover object-top"
/>
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
</span>
</button>
);
}
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; // no background scroll while open
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
return (
<div
className="fixed inset-0 z-[60] grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={label}
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
>
<X className="w-5 h-5" />
</button>
<figure
className="flex max-h-full max-w-full flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
>
<img <img
src={src} src={src}
alt={label} alt={label}
loading="lazy" className="max-h-[82vh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
onError={() => setFailed(true)}
className="w-full h-full object-cover object-top"
/> />
)} <figcaption className="text-sm text-muted">{label}</figcaption>
</figure>
</div> </div>
); );
} }
function AuthCard() { function AuthCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const params = new URLSearchParams(window.location.search); // Snapshot the redirect params ONCE so we can clean them from the address bar below without
// dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL).
const [params] = useState(() => new URLSearchParams(window.location.search));
const resetToken = params.get("reset"); const resetToken = params.get("reset");
const bounced = params.get("access"); // Google denied → "requested" | "denied" const bounced = params.get("access"); // Google denied → "requested" | "denied"
const verify = params.get("verify"); // "ok" | "invalid" const verify = params.get("verify"); // "ok" | "invalid"
const login = params.get("login"); // Google login blocked → "suspended"
const deleted = params.get("deleted"); // self-service account deletion just completed
// Strip the query string so the address bar stays clean (http://host/) after we've captured it.
useEffect(() => {
if (window.location.search) {
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin"); const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -230,6 +333,8 @@ function AuthCard() {
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>} {verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>} {bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>} {bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? ( {done ? (
<div className="mt-3"> <div className="mt-3">

View file

@ -68,6 +68,8 @@
"verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.", "verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.",
"verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.", "verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.",
"accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.", "accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.",
"accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang." "accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang.",
"suspended": "Dieses Konto wurde gesperrt und kann sich nicht anmelden. Falls das ein Irrtum ist, wende dich an den Betreiber (Details in deiner E-Mail).",
"deleted": "Dein Konto und alle Daten wurden endgültig gelöscht. Eine Bestätigung haben wir dir per E-Mail geschickt."
} }
} }

View file

@ -68,6 +68,8 @@
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.", "verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
"verifyInvalid": "That verification link is invalid or has expired.", "verifyInvalid": "That verification link is invalid or has expired.",
"accessRequested": "Thanks — we recorded your request. An admin will review it.", "accessRequested": "Thanks — we recorded your request. An admin will review it.",
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access." "accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
} }
} }

View file

@ -68,6 +68,8 @@
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.", "verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.", "verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.", "accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól." "accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
} }
} }