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:
parent
3f9c395b17
commit
6db7014b08
7 changed files with 180 additions and 22 deletions
|
|
@ -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")
|
||||
|
|
|
|||
BIN
frontend/public/welcome/channels.png
Normal file
BIN
frontend/public/welcome/channels.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
|
|
@ -48,7 +53,12 @@ export default function Welcome() {
|
|||
</section>
|
||||
|
||||
{/* 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 */}
|
||||
<section className="mt-14">
|
||||
|
|
@ -60,13 +70,27 @@ export default function Welcome() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Secondary showcase */}
|
||||
<section className="grid sm:grid-cols-2 gap-4 mt-12">
|
||||
<Preview src="/welcome/channels.png" label={t("welcome.preview.channels")} className="aspect-[4/3]" />
|
||||
<Preview src="/welcome/playlists.png" label={t("welcome.preview.playlists")} className="aspect-[4/3]" />
|
||||
{/* Secondary showcase — smaller thumbnails; click to view full size. */}
|
||||
<section className="mt-12 flex flex-wrap justify-center gap-4">
|
||||
<Preview
|
||||
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>
|
||||
</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">
|
||||
<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
|
||||
// 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);
|
||||
return (
|
||||
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
||||
{failed ? (
|
||||
if (failed) {
|
||||
return (
|
||||
<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">
|
||||
{label}
|
||||
</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
|
||||
src={src}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="w-full h-full object-cover object-top"
|
||||
className="max-h-[82vh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
<figcaption className="text-sm text-muted">{label}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCard() {
|
||||
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 bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
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 [email, setEmail] = useState("");
|
||||
|
|
@ -230,6 +333,8 @@ function AuthCard() {
|
|||
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
|
||||
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</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 ? (
|
||||
<div className="mt-3">
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@
|
|||
"verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.",
|
||||
"verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@
|
|||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@
|
|||
"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.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue