import { useEffect, useRef, useState } from "react"; import { useHistorySubview } from "../lib/history"; import { useTranslation } from "react-i18next"; import { ArrowRight, Check, Clapperboard, Clock, Code2, Download, Expand, Filter, Inbox, Link2Off, ListVideo, Lock, MailWarning, PlayCircle, Search, ShieldAlert, X, type LucideIcon, } from "lucide-react"; // Siftlode is open source; the landing footer links to the public repository. const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode"; import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api"; import { HttpError } from "../lib/api"; import { setLanguage, type LangCode } from "../i18n"; import LanguageSwitcher from "./LanguageSwitcher"; import { Button } from "./ui/Button"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib/layerStack"; import { inputCls } from "./ui/form"; import { entryHash, entrySearch } from "../lib/entryUrl"; import type { VerifyEntry } from "../lib/useVerifyEntry"; import { MODE_URL, type Mode } from "./welcomeMode"; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const PASSWORD_MIN = 10; // Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries) // keeps adding a new mode a one-line change and makes a missing key a compile error. const TITLE_KEY: Record = { signin: "welcome.auth.signinTitle", register: "welcome.auth.createTitle", forgot: "welcome.auth.forgotTitle", reset: "welcome.auth.resetTitle", resend: "welcome.auth.resendTitle", }; const SUBMIT_KEY: Record = { signin: "welcome.auth.signinButton", register: "welcome.auth.createButton", forgot: "welcome.auth.forgotButton", reset: "welcome.auth.resetButton", resend: "welcome.auth.resendButton", }; // Public landing + authentication. One scrollable page: a hero with the auth card (email+ // password, Google, explicit demo) over a short marketing pitch with feature cards. Shown // whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the // backend); errors are surfaced inline (the api calls use the quiet flag). export default function Welcome({ verify }: { verify: VerifyEntry }) { 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 (
Siftlode
{/* Hero: pitch + auth card */}

{t("welcome.hero.title")}

{t("welcome.hero.subtitle")}

{t("welcome.hero.trust")}

{/* App preview */} {/* Open source — Siftlode's code is public; nudge visitors to read it or self-host. The footer keeps a compact link too. */} {t("welcome.openSource.title")} {t("welcome.openSource.body")} {t("welcome.openSource.link")} {/* Features */}

{t("welcome.features.heading")}

{FEATURES.map((f) => ( ))}
{/* Gallery — one shot per module; click any to view it full size. */}
{GALLERY.map((g) => ( ))}
{zoomed && setZoomed(null)} />}
); } // The screenshot gallery below the hero — one shot per module, each opening full-size in the // lightbox. Order roughly follows the nav rail; the feed leads as the hero above the fold. const GALLERY: { key: string; src: string }[] = [ { key: "channels", src: "/welcome/channels.webp" }, { key: "playlists", src: "/welcome/playlists.webp" }, { key: "search", src: "/welcome/search.webp" }, { key: "player", src: "/welcome/player.webp" }, { key: "downloads", src: "/welcome/downloads.webp" }, { key: "plex", src: "/welcome/plex.webp" }, ]; const FEATURES: { key: string; icon: LucideIcon }[] = [ { key: "readable", icon: Filter }, { key: "neverMiss", icon: Inbox }, { key: "search", icon: Search }, { key: "playlists", icon: ListVideo }, { key: "player", icon: PlayCircle }, { key: "downloads", icon: Download }, { key: "plex", icon: Clapperboard }, { key: "private", icon: Lock }, ]; function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) { const { t } = useTranslation(); return (

{t(`welcome.features.${k}.title`)}

{t(`welcome.features.${k}.body`)}

); } // 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/). // 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); if (failed) { return (
{label}
); } return ( ); } // 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 }) { const { t } = useTranslation(); // An `aria-modal` shell that round 5's focus extraction did not sweep in: it declared itself a // modal dialog while leaving focus on the thumbnail behind an 80%-black scrim, announcing nothing, // and letting Tab walk the landing page underneath — the ✕ reachable only after tabbing past every // remaining gallery shot (deep-review round 6). The contract comes from the same place `ui/Modal` // and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer // membership below. // // It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth, // joined in E11 S2, so all four (`ui/Modal`, the wizard, this, the player) now share // `useDialogChrome` and its one counted scroll lock. const panelRef = useRef(null); useDialogChrome(panelRef); // Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal). const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { // Round 6 gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other three // have, so its Escape and Tab fired from a raw window listener whatever was on top of it. That // is the shape the wizard's own comment records being bitten by — "one Esc closed the error AND // ended onboarding" — and it is unreachable today only because `App` returns `` before // it mounts `ErrorDialog` and the Toaster, i.e. by an ordering nothing enforces. Joining the // stack costs two lines and removes the trap-fights-trap failure mode from the next layer the // landing page grows (deep-review round 7). const id = pushLayer(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && isTopmost(id)) { e.stopPropagation(); onCloseRef.current(); return; } // The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer. const panel = panelRef.current; if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel); }; window.addEventListener("keydown", onKey); return () => { window.removeEventListener("keydown", onKey); removeLayer(id); }; }, []); return (
e.stopPropagation()} > {label}
{label}
); } // One answer per reset token, however many times React mounts this card. StrictMode double-invokes // effects in dev and the shell remounts the card as the session probe settles — measured in UAT on // 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget // after a couple of reloads and silently drop back to drawing the form unchecked. Holding the // in-flight promise means a remount joins the request already running instead of starting another. const resetTokenChecks = new Map>(); function checkResetToken(token: string): Promise { let pending = resetTokenChecks.get(token); if (!pending) { pending = api .checkPasswordReset(token) .then((r) => r.ok) // A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets // itself: a network blip must never be cached as "this link is dead". .catch(() => { resetTokenChecks.delete(token); return true; }); resetTokenChecks.set(token, pending); } return pending; } function AuthCard({ verify }: { verify: VerifyEntry }) { const { t } = useTranslation(); // The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can // rewrite the address bar out from under this card before it mounts — and so the App-level hook and // this card read the SAME values regardless of which mounts first. Clearing the address bar is that // hook's job now, since it is the one thing that runs on every visit, signed in or not. // // State, not a const: once the token has been spent the reset view must stop being the card's // root, or a Back from the "password changed" screen lands on a form whose token is already used. const [resetToken, setResetToken] = useState(() => entryHash.get("reset")); // The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now, // and an address in the query string would land in every reverse-proxy access log. `linked` is set // only when the request already existed under ANOTHER spelling of the same mailbox — the case worth // explaining; the same spelling twice is just a repeat. const bounced = entryHash.get("access"); // Google denied → "requested" | "denied" const bouncedEmail = entryHash.get("email"); const bouncedLinked = entryHash.get("linked"); const deleted = entrySearch.get("deleted"); // self-service account deletion just completed // Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the // first deliberate action, like the verify banner — otherwise it lingers stalely on later screens. const [login, setLogin] = useState(() => entrySearch.get("login")); // The verification outcome is confirmed in App (above the signed-in branch) and handed down, so // one emailed link behaves the same whether or not this browser already holds a session. const verifyOutcome = verify.outcome; const { view: mode, open: setMode } = useHistorySubview( resetToken ? "reset" : "signin", MODE_URL, ); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); // Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear // and a set in one handler commit together, leaving the node untouched). const [attempt, setAttempt] = useState(0); // Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth // configured. Optimistic (most instances have it); the rare Google-less instance just sees it // disappear once the config loads. const [googleEnabled, setGoogleEnabled] = useState(true); useEffect(() => { api .authConfig() .then((c) => setGoogleEnabled(c.google_enabled)) .catch(() => {}); }, []); const [done, setDone] = useState(""); // success/info message replacing the form // Why password sign-in refused, when the server named a state worth a screen rather than a red line // (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven // correct, so there is nothing to retype — leaving the form up would only invite a pointless retry. const [blocked, setBlocked] = useState(null); // The emailed reset link turned out to be spent, expired, or to belong to an account that no longer // exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen. const [deadReset, setDeadReset] = useState(false); // Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank // until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link // rendered a full "set a new password" form that could only ever answer 400 — the user finds out // after typing. This is advisory (the confirm below stays the authority); a check that errors is // treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link. const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null); useEffect(() => { const token = entryHash.get("reset"); if (!token) return; let live = true; void checkResetToken(token).then((ok) => { if (!live) return; if (!ok) { setDeadReset(true); setResetToken(null); // and stop the reset view being the card's root, so Back can't return } setCheckingReset(false); }); return () => { live = false; }; }, []); // `done` and `error` belong to the view that produced them, and the render is `done ? msg : form` // — without this, Back out of a submitted form would show the old success message under the // previous view's title. Clearing on view change covers both Back/Forward and the mode links. useEffect(() => { setDone(""); setError(""); setBlocked(null); setDeadReset(false); }, [mode]); // Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated // by the demo whitelist server-side. const [showDemo, setShowDemo] = useState(false); const [demoEmail, setDemoEmail] = useState(""); const [demoBusy, setDemoBusy] = useState(false); const [demoError, setDemoError] = useState(""); const reset = () => { setError(""); setDone(""); setBlocked(null); setDeadReset(false); // The verify/login banners are one-time entry notices — dismiss once the user takes any // deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens. verify.dismiss(); setLogin(null); }; // Shared by the expired-link banner CTA and the sign-in "didn't get the email?" link. const goResend = () => { reset(); setMode("resend"); }; // Used by the "verified, but your password was revoked" banner — the only way back into that // account is a reset link, so put the user one click from it. const goForgot = () => { reset(); setMode("forgot"); }; async function onSubmit(e: React.FormEvent) { e.preventDefault(); setAttempt((n) => n + 1); reset(); const mail = email.trim().toLowerCase(); if ( (mode === "signin" || mode === "register" || mode === "forgot" || mode === "resend") && !EMAIL_RE.test(mail) ) { setError(t("welcome.auth.invalidEmail")); return; } if ((mode === "register" || mode === "reset") && password.length < PASSWORD_MIN) { setError(t("welcome.auth.weakPassword", { min: PASSWORD_MIN })); return; } setBusy(true); try { if (mode === "signin") { await api.passwordLogin(mail, password); window.location.reload(); return; } if (mode === "register") { await api.register(mail, password); setDone(t("welcome.auth.registerDone")); } else if (mode === "forgot") { await api.requestPasswordReset(mail); setDone(t("welcome.auth.forgotDone")); } else if (mode === "resend") { await api.resendVerification(mail); setDone(t("welcome.auth.resendDone")); } else if (mode === "reset") { await api.confirmPasswordReset(resetToken ?? "", password); setResetToken(null); // single-use: the reset view is no longer a place to come back to setDone(t("welcome.auth.resetDone")); } } catch (err) { // An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line // under the form, because there the form IS the next step. const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null; if (refusal && PANEL_REASONS.has(refusal.reason)) { setBlocked(refusal); return; } // The token died between the pre-flight check and this submit — or was never checked, because // the probe itself failed. Either way the answer is the same screen, not a red line. if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) { setDeadReset(true); setResetToken(null); return; } setError( err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"), ); } finally { setBusy(false); } } async function onDemo(e: React.FormEvent) { e.preventDefault(); setDemoError(""); const mail = demoEmail.trim().toLowerCase(); if (!EMAIL_RE.test(mail)) { setDemoError(t("welcome.auth.invalidEmail")); return; } setDemoBusy(true); try { const r = await api.demoLogin(mail); if (r.authenticated) window.location.reload(); else setDemoError(t("welcome.auth.demoDenied")); } catch { setDemoError(t("welcome.auth.demoDenied")); } finally { setDemoBusy(false); } } // A named state owns the card's heading while it is up, so it reads as the card's subject rather // than a box sitting inside a heading that still says "Set a new password". const titleKey = blocked ? `welcome.auth.blocked.${blocked.reason}.title` : deadReset ? "welcome.auth.deadLink.title" : TITLE_KEY[mode]; const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null; return (

{StateIcon && ( )} {t(titleKey)}

{/* One-time status banners from a redirect (Google-denied, email verification). */} {verifyOutcome === "ok" && {t("welcome.auth.verifyOk")}} {verifyOutcome === "ok_reset" && ( {t("welcome.auth.verifyOkReset")}{" "} )} {/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply could not reach the server — so pushing the user to request a fresh link would act on a statement we know to be false (DX-16). Retrying the same link is the correct next move. */} {verifyOutcome === "unreachable" && ( {t("welcome.auth.verifyUnreachable")} )} {verifyOutcome === "invalid" && ( {t("welcome.auth.verifyInvalid")}{" "} )} {bounced === "requested" && (bouncedEmail ? ( {t("welcome.auth.accessRequestedFor")}
{t("welcome.auth.accessRequestedReview")} ) : ( {t("welcome.auth.accessRequested")} ))} {/* The alias insight, stated only when it applies: Google answered with one spelling of a mailbox whose request is already on file under another. Silence here is what let the 2026-09-03 registrant believe nothing had been recorded at all. */} {bounced === "requested" && bouncedLinked && ( {t("welcome.auth.accessLinked")}
{t("welcome.auth.accessLinkedTail")} )} {bounced === "denied" && {t("welcome.auth.accessDenied")}} {login === "suspended" && {t("welcome.auth.suspended")}} {login === "oauth_cancelled" && ( {t("welcome.auth.oauthCancelled")} )} {deleted && {t("welcome.auth.deleted")}} {blocked ? ( setBlocked(null)} /> ) : deadReset ? ( { reset(); setMode("signin"); }} /> ) : checkingReset ? ( // Blank rather than the form: showing a form we may be about to withdraw is the flicker this // whole check exists to remove.

{t("welcome.auth.working")}

) : done ? (

{done}

) : ( <>
{mode !== "reset" && ( setEmail(e.target.value)} placeholder={t("welcome.auth.email")} className={inputCls} /> )} {mode !== "forgot" && mode !== "resend" && ( setPassword(e.target.value)} placeholder={ mode === "reset" || mode === "register" ? t("welcome.auth.newPassword", { min: PASSWORD_MIN }) : t("welcome.auth.password") } className={inputCls} /> )} {error && (

{error}

)}
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a repeat sign-up. Offered right under the form rather than below the mode switches: it is the second thing a stuck person needs, after the password they just tried. */} {mode === "signin" && ( )} {/* Mode switches */}
{mode === "signin" ? ( <> ) : ( )}
{mode === "signin" && ( <>
{t("welcome.auth.or")}
{googleEnabled && ( {t("welcome.auth.google")} )} {!showDemo ? ( ) : (

{t("welcome.auth.demoHint")}

setDemoEmail(e.target.value)} placeholder={t("welcome.auth.demoEmail")} className={inputCls} />
{demoError &&

{demoError}

}
)} )} )}
); } // An email address the user has to READ and compare, character by character — the recorded address on // a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because // that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody // noticed on 2026-09-03. function Address({ value }: { value: string }) { return {value}; } // Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered // BEFORE the password is proven, carry no account detail, and leave the form as the next step — a // line under it is the honest size for them. const PANEL_REASONS = new Set([ "email_unverified", "pending_approval", "suspended", ]); const BLOCKED_ICON: Partial> = { email_unverified: MailWarning, pending_approval: Clock, suspended: ShieldAlert, }; // The frame every named state in this card shares: a sentence, whatever detail that state can give, // then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state // reads as the card's subject. One frame, because two states that mean "this form cannot succeed" // should not drift apart in shape. function StatePanel({ body, cta, onBack, children, }: { body: string; cta?: { label: string; onClick: () => void }; onBack: () => void; children?: React.ReactNode; }) { const { t } = useTranslation(); // Announced and focused, because this panel REPLACES the form the user just submitted: without a // live region a screen reader reports nothing at all, and without the focus move the button they // were standing on is unmounted, dropping focus to so Tab restarts at the top of the page. // The Banner in this same file got `role="status"` for the same reason; the panel is the louder // half — it is the whole answer to a submit — so it announces assertively. const heading = useRef(null); useEffect(() => heading.current?.focus(), []); return (

{body}

{children}
{cta && ( )}
); } // The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the // account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval" // string it replaces was true and useless: it never revealed that the address the account carried was // a different spelling of the one the user had just typed. function BlockedPanel({ blocked, onResend, onBack, }: { blocked: LoginBlocked; onResend: () => void; onBack: () => void; }) { const { t } = useTranslation(); const { reason, email, emailVerified, approved, operator } = blocked; // Suspension is not a gate that will open on its own, so the checklist would be a lie there. const gated = reason !== "suspended"; return ( {gated && (
)} {email && (
{t("welcome.auth.blocked.addressLabel")}
)} {operator && (

{t("welcome.auth.blocked.operator", { contact: operator })}

)}
); } // One row of the two-gate checklist. Both states get their own full sentence rather than a shared // label plus a status word: Hungarian puts them together differently, and gluing translated // fragments is how a bilingual UI ends up reading like neither language. function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) { const { t } = useTranslation(); const Icon = done ? Check : Clock; return ( {t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)} ); } // `role="status"` because these appear in response to something that just happened (a verification // link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest — // without it a screen-reader user gets no announcement that anything changed. function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) { const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg"; return (
{children}
); }