import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowRight, Code2, Expand, Filter, Inbox, ListVideo, Lock, PlayCircle, Search, X, type LucideIcon, } from "lucide-react"; // Siftlode is open source; the landing footer links to the public repository. const REPO_URL = "https://forge.b1fr0st.eu/peter/siftlode"; import { api } from "../lib/api"; import { HttpError } from "../lib/api"; import { setLanguage, type LangCode } from "../i18n"; import LanguageSwitcher from "./LanguageSwitcher"; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const PASSWORD_MIN = 10; type Mode = "signin" | "register" | "forgot" | "reset"; // 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() { 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) => ( ))}
{/* Secondary showcase — smaller thumbnails; click to view full size. */}
{zoomed && ( setZoomed(null)} /> )}
); } 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: "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 }) { 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 (
e.stopPropagation()} > {label}
{label}
); } function AuthCard() { const { t } = useTranslation(); // 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(resetToken ? "reset" : "signin"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); // 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 // 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(""); }; async function onSubmit(e: React.FormEvent) { e.preventDefault(); reset(); const mail = email.trim().toLowerCase(); if ((mode === "signin" || mode === "register" || mode === "forgot") && !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 === "reset") { await api.confirmPasswordReset(resetToken ?? "", password); setDone(t("welcome.auth.resetDone")); } } catch (err) { 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); } } const inputCls = "w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent"; const titleKey = mode === "register" ? "welcome.auth.createTitle" : mode === "forgot" ? "welcome.auth.forgotTitle" : mode === "reset" ? "welcome.auth.resetTitle" : "welcome.auth.signinTitle"; return (

{t(titleKey)}

{/* One-time status banners from a redirect (Google-denied, email verification). */} {verify === "ok" && {t("welcome.auth.verifyOk")}} {verify === "invalid" && {t("welcome.auth.verifyInvalid")}} {bounced === "requested" && {t("welcome.auth.accessRequested")}} {bounced === "denied" && {t("welcome.auth.accessDenied")}} {login === "suspended" && {t("welcome.auth.suspended")}} {deleted && {t("welcome.auth.deleted")}} {done ? (

{done}

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

{error}

}
{/* 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}

}
)} )} )}
); } function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) { const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-amber-500/15 text-fg"; return
{children}
; }