Merge feature/auth-5b-welcome (welcome/landing + email-password login UI)
Public landing page with the redesigned auth card (email+password, Google, explicit demo), feature pitch, and demo-screenshot slots. Wires the 5a auth endpoints with inline errors. Login.tsx replaced by Welcome.tsx.
This commit is contained in:
commit
1f3d2b6b58
7 changed files with 602 additions and 124 deletions
|
|
@ -21,7 +21,7 @@ import { configureNotifications, getNotifSettings, notify, type NotifSettings }
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
import { useConfirm } from "./components/ConfirmProvider";
|
import { useConfirm } from "./components/ConfirmProvider";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import Login from "./components/Login";
|
import Welcome from "./components/Welcome";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import NavSidebar from "./components/NavSidebar";
|
import NavSidebar from "./components/NavSidebar";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
|
|
@ -401,7 +401,7 @@ export default function App() {
|
||||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||||
);
|
);
|
||||||
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
||||||
return <Login />;
|
return <Welcome />;
|
||||||
if (meQuery.error)
|
if (meQuery.error)
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center text-muted">
|
<div className="min-h-screen grid place-items-center text-muted">
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { api } from "../lib/api";
|
|
||||||
import { setLanguage, type LangCode } from "../i18n";
|
|
||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
|
||||||
|
|
||||||
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
|
||||||
|
|
||||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
// A denied Google login bounces back here with ?access=requested (we recorded it) or
|
|
||||||
// ?access=denied (no usable email). Surface that so the user isn't left on a raw error.
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const bounced = params.get("access"); // "requested" | "denied" | null
|
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
// Hidden demo entry: a whitelisted email signs straight into the shared demo account, with
|
|
||||||
// no button to give it away. We watch the field and — once it holds a syntactically valid
|
|
||||||
// email and typing has settled — quietly probe /auth/demo. A match sets the session, so we
|
|
||||||
// reload into the app; a non-match does nothing visible (the normal "request access" flow
|
|
||||||
// stays available). The server is rate-limited and answers uniformly, so this can't be
|
|
||||||
// hammered as an oracle. Skipped while the request-access form is mid-submit/done.
|
|
||||||
useEffect(() => {
|
|
||||||
const candidate = email.trim().toLowerCase();
|
|
||||||
const settled = phase === "requested" || phase === "approved";
|
|
||||||
if (phase === "sending" || settled || !EMAIL_RE.test(candidate)) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
api
|
|
||||||
.demoLogin(candidate)
|
|
||||||
.then((r) => {
|
|
||||||
if (r.authenticated) window.location.reload();
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, 700);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [email, phase]);
|
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
|
||||||
setPhase("sending");
|
|
||||||
try {
|
|
||||||
const r = await api.requestAccess(email.trim());
|
|
||||||
setPhase(r.status === "approved" ? "approved" : "requested");
|
|
||||||
} catch {
|
|
||||||
setError(t("login.submitError"));
|
|
||||||
setPhase("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const done = phase === "requested" || phase === "approved";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
|
||||||
<div className="absolute top-4 right-4">
|
|
||||||
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
|
||||||
</div>
|
|
||||||
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
|
|
||||||
<div className="text-3xl font-bold tracking-tight">
|
|
||||||
Sift<span className="text-accent">lode</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted my-5 leading-relaxed">{t("login.tagline")}</p>
|
|
||||||
<a
|
|
||||||
href="/auth/login"
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
|
||||||
>
|
|
||||||
{t("login.signIn")}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="mt-7 pt-6 border-t border-border text-left">
|
|
||||||
{phase === "approved" ? (
|
|
||||||
<p className="text-sm text-fg/80">{t("login.approved")}</p>
|
|
||||||
) : done ? (
|
|
||||||
<p className="text-sm text-fg/80">{t("login.requested")}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
|
||||||
{t("login.noAccessYet")}
|
|
||||||
</div>
|
|
||||||
{bounced === "denied" && (
|
|
||||||
<p className="text-xs text-muted mb-2">{t("login.denied")}</p>
|
|
||||||
)}
|
|
||||||
<form onSubmit={submit} className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder={t("login.emailPlaceholder")}
|
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={phase === "sending"}
|
|
||||||
className="shrink-0 px-3 py-2 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{phase === "sending" ? t("login.sending") : t("login.requestAccess")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="mt-5 flex gap-4 text-xs text-muted">
|
|
||||||
<a href="/privacy" className="hover:text-fg transition">
|
|
||||||
{t("common.privacyPolicy")}
|
|
||||||
</a>
|
|
||||||
<a href="/terms" className="hover:text-fg transition">
|
|
||||||
{t("common.termsOfService")}
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
367
frontend/src/components/Welcome.tsx
Normal file
367
frontend/src/components/Welcome.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
Inbox,
|
||||||
|
ListVideo,
|
||||||
|
Lock,
|
||||||
|
PlayCircle,
|
||||||
|
Tags,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
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();
|
||||||
|
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">
|
||||||
|
<div className="text-xl font-bold tracking-tight">
|
||||||
|
Sift<span className="text-accent">lode</span>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-5 sm:px-8 pb-16">
|
||||||
|
{/* Hero: pitch + auth card */}
|
||||||
|
<section className="grid lg:grid-cols-2 gap-10 items-center py-8 lg:py-14">
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-tight">
|
||||||
|
{t("welcome.hero.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
|
||||||
|
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
|
||||||
|
</div>
|
||||||
|
<AuthCard />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* App preview */}
|
||||||
|
<Preview src="/welcome/feed.png" label={t("welcome.preview.feed")} className="aspect-video" />
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="mt-14">
|
||||||
|
<h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<FeatureCard key={f.key} icon={f.icon} k={f.key} />
|
||||||
|
))}
|
||||||
|
</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]" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline">·</span>
|
||||||
|
<a href="/privacy" className="hover:text-fg transition">
|
||||||
|
{t("common.privacyPolicy")}
|
||||||
|
</a>
|
||||||
|
<a href="/terms" className="hover:text-fg transition">
|
||||||
|
{t("common.termsOfService")}
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES: { key: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: "readable", icon: Filter },
|
||||||
|
{ key: "neverMiss", icon: Inbox },
|
||||||
|
{ key: "playlists", icon: ListVideo },
|
||||||
|
{ key: "player", icon: PlayCircle },
|
||||||
|
{ key: "channels", icon: Tags },
|
||||||
|
{ key: "private", icon: Lock },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-2xl p-5">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center mb-3">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{t(`welcome.features.${k}.title`)}</h3>
|
||||||
|
<p className="text-sm text-muted leading-relaxed mt-1">{t(`welcome.features.${k}.body`)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
||||||
|
{failed ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className="w-full h-full object-cover object-top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthCard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = 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 [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [done, setDone] = useState<string>(""); // 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 (
|
||||||
|
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
|
||||||
|
|
||||||
|
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||||
|
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||||
|
{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>}
|
||||||
|
|
||||||
|
{done ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMode("signin");
|
||||||
|
setDone("");
|
||||||
|
}}
|
||||||
|
className="mt-4 text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t("welcome.auth.backToSignin")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3 mt-3">
|
||||||
|
{mode !== "reset" && (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={t("welcome.auth.email")}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode !== "forgot" && (
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mode === "reset" || mode === "register"
|
||||||
|
? t("welcome.auth.newPassword", { min: PASSWORD_MIN })
|
||||||
|
: t("welcome.auth.password")
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? t("welcome.auth.working")
|
||||||
|
: t(
|
||||||
|
mode === "register"
|
||||||
|
? "welcome.auth.createButton"
|
||||||
|
: mode === "forgot"
|
||||||
|
? "welcome.auth.forgotButton"
|
||||||
|
: mode === "reset"
|
||||||
|
? "welcome.auth.resetButton"
|
||||||
|
: "welcome.auth.signinButton"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Mode switches */}
|
||||||
|
<div className="flex items-center justify-between mt-3 text-xs">
|
||||||
|
{mode === "signin" ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => { reset(); setMode("register"); }} className="text-accent hover:underline">
|
||||||
|
{t("welcome.auth.createLink")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { reset(); setMode("forgot"); }} className="text-muted hover:text-fg">
|
||||||
|
{t("welcome.auth.forgotLink")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { reset(); setMode("signin"); }} className="text-accent hover:underline">
|
||||||
|
{t("welcome.auth.backToSignin")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "signin" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
{t("welcome.auth.or")}
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/auth/login"
|
||||||
|
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
|
||||||
|
>
|
||||||
|
{t("welcome.auth.google")}
|
||||||
|
</a>
|
||||||
|
{!showDemo ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDemo(true)}
|
||||||
|
className="w-full mt-2 px-4 py-2.5 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
|
||||||
|
>
|
||||||
|
{t("welcome.auth.tryDemo")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
|
||||||
|
<p className="text-[11px] text-muted">{t("welcome.auth.demoHint")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={demoEmail}
|
||||||
|
onChange={(e) => setDemoEmail(e.target.value)}
|
||||||
|
placeholder={t("welcome.auth.demoEmail")}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={demoBusy}
|
||||||
|
className="shrink-0 px-3 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{demoError && <p className="text-xs text-red-400">{demoError}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>;
|
||||||
|
}
|
||||||
73
frontend/src/i18n/locales/de/welcome.json
Normal file
73
frontend/src/i18n/locales/de/welcome.json
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Deine YouTube-Abos, so wie ein Feed funktionieren sollte.",
|
||||||
|
"subtitle": "Siftlode sammelt jeden Upload der Kanäle, denen du folgst, in einem aufgeräumten, filterbaren Feed — kein Algorithmus entscheidet, was du siehst, und kein Shorts- oder Live-Lärm, außer du willst es.",
|
||||||
|
"trust": "Selbst gehostet und privat. Melde dich per E-Mail an oder fahre mit Google fort."
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.",
|
||||||
|
"channels": "Kanal-Verwaltung",
|
||||||
|
"playlists": "Wiedergabelisten"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"heading": "Warum Siftlode",
|
||||||
|
"readable": {
|
||||||
|
"title": "Deine Abos, endlich lesbar",
|
||||||
|
"body": "Sortiere und filtere nach Kanal, Sprache, Thema, Länge oder Datum — und blende Kanäle aus, ohne sie zu deabonnieren. Du entscheidest, was erscheint, nicht ein Algorithmus."
|
||||||
|
},
|
||||||
|
"neverMiss": {
|
||||||
|
"title": "Verpasse keinen Upload",
|
||||||
|
"body": "Jedes Video jedes Kanals, dem du folgst, landet in einem Feed. Shorts und Livestreams sind standardmäßig ausgeblendet — so sind es nur die Videos, für die du gekommen bist."
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"title": "Playlists, die in beide Richtungen synchronisieren",
|
||||||
|
"body": "Erstelle Playlists lokal und halte sie mit YouTube synchron — Änderungen fließen in beide Richtungen."
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"title": "Schau in der App, mach da weiter, wo du aufgehört hast",
|
||||||
|
"body": "Ein In-App-Player setzt jedes Video dort fort, wo du gestoppt hast — kein Empfehlungs-Strudel, der dich ablenkt."
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"title": "Eine echte Kanal-Verwaltung",
|
||||||
|
"body": "Priorisiere die Kanäle, die dir wichtig sind, tagge sie nach deiner Art und filtere den Feed nach deinen eigenen Tags."
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"title": "Selbst gehostet, privat, mehrsprachig",
|
||||||
|
"body": "Betreibe es selbst — deine Daten bleiben deine. Mehrbenutzerfähig, mit der Oberfläche auf Englisch, Ungarisch und Deutsch."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signinTitle": "Anmelden",
|
||||||
|
"createTitle": "Konto erstellen",
|
||||||
|
"forgotTitle": "Passwort zurücksetzen",
|
||||||
|
"resetTitle": "Neues Passwort festlegen",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Passwort",
|
||||||
|
"newPassword": "Neues Passwort (min. {{min}} Zeichen)",
|
||||||
|
"invalidEmail": "Gib eine gültige E-Mail-Adresse ein.",
|
||||||
|
"weakPassword": "Das Passwort muss mindestens {{min}} Zeichen lang sein.",
|
||||||
|
"genericError": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||||
|
"working": "Einen Moment…",
|
||||||
|
"signinButton": "Anmelden",
|
||||||
|
"createButton": "Konto erstellen",
|
||||||
|
"forgotButton": "Reset-Link senden",
|
||||||
|
"resetButton": "Passwort festlegen",
|
||||||
|
"createLink": "Konto erstellen",
|
||||||
|
"forgotLink": "Passwort vergessen?",
|
||||||
|
"backToSignin": "Zurück zur Anmeldung",
|
||||||
|
"or": "oder",
|
||||||
|
"google": "Mit Google fortfahren",
|
||||||
|
"tryDemo": "Demo ausprobieren",
|
||||||
|
"demoHint": "Gib eine E-Mail ein, die für das Demo-Konto freigeschaltet ist.",
|
||||||
|
"demoEmail": "Demo-E-Mail",
|
||||||
|
"demoEnter": "Eintreten",
|
||||||
|
"demoDenied": "Diese E-Mail ist nicht für die Demo freigeschaltet. Bitte den Admin, sie hinzuzufügen.",
|
||||||
|
"registerDone": "Fast geschafft — prüfe deine E-Mails auf den Bestätigungslink. Nach der Bestätigung gibt ein Admin dein Konto frei, bevor du dich anmelden kannst.",
|
||||||
|
"forgotDone": "Falls zu dieser E-Mail ein Konto gehört, haben wir einen Reset-Link gesendet. Prüfe dein Postfach.",
|
||||||
|
"resetDone": "Dein Passwort wurde festgelegt — du kannst dich jetzt 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.",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/src/i18n/locales/en/welcome.json
Normal file
73
frontend/src/i18n/locales/en/welcome.json
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Your YouTube subscriptions, the way a feed should work.",
|
||||||
|
"subtitle": "Siftlode pulls every upload from the channels you follow into one clean, filterable feed — no algorithm deciding what you see, and no Shorts or live noise unless you want it.",
|
||||||
|
"trust": "Self-hosted and private. Sign in with email, or continue with Google."
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"feed": "The feed — filter by channel, language, topic, length and date.",
|
||||||
|
"channels": "Channel manager",
|
||||||
|
"playlists": "Playlists"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"heading": "Why Siftlode",
|
||||||
|
"readable": {
|
||||||
|
"title": "Your subscriptions, actually readable",
|
||||||
|
"body": "Sort and filter by channel, language, topic, length or date — and hide channels without unsubscribing. You decide what surfaces, not an algorithm."
|
||||||
|
},
|
||||||
|
"neverMiss": {
|
||||||
|
"title": "Never miss an upload",
|
||||||
|
"body": "Every video from every channel you follow lands in one feed. Shorts and live streams are tucked away by default, so it's just the videos you came for."
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"title": "Playlists that sync both ways",
|
||||||
|
"body": "Build playlists locally and keep them in sync with YouTube — changes flow in both directions."
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"title": "Watch in-app, pick up where you left off",
|
||||||
|
"body": "An in-app player resumes each video where you stopped — no recommendation rabbit-hole pulling you off course."
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"title": "A real channel manager",
|
||||||
|
"body": "Prioritise the channels you care about, tag them your way, and filter the feed by your own tags."
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"title": "Self-hosted, private, multilingual",
|
||||||
|
"body": "Run it yourself — your data stays yours. Multi-user, with the interface in English, Hungarian and German."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signinTitle": "Sign in",
|
||||||
|
"createTitle": "Create your account",
|
||||||
|
"forgotTitle": "Reset your password",
|
||||||
|
"resetTitle": "Set a new password",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"newPassword": "New password (min {{min}} characters)",
|
||||||
|
"invalidEmail": "Enter a valid email address.",
|
||||||
|
"weakPassword": "Password must be at least {{min}} characters.",
|
||||||
|
"genericError": "Something went wrong. Please try again.",
|
||||||
|
"working": "Please wait…",
|
||||||
|
"signinButton": "Sign in",
|
||||||
|
"createButton": "Create account",
|
||||||
|
"forgotButton": "Send reset link",
|
||||||
|
"resetButton": "Set password",
|
||||||
|
"createLink": "Create an account",
|
||||||
|
"forgotLink": "Forgot password?",
|
||||||
|
"backToSignin": "Back to sign in",
|
||||||
|
"or": "or",
|
||||||
|
"google": "Continue with Google",
|
||||||
|
"tryDemo": "Try the demo",
|
||||||
|
"demoHint": "Enter an email that's been enabled for the demo account.",
|
||||||
|
"demoEmail": "Demo email",
|
||||||
|
"demoEnter": "Enter",
|
||||||
|
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
|
||||||
|
"registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.",
|
||||||
|
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
|
||||||
|
"resetDone": "Your password has been set — you can sign in now.",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/src/i18n/locales/hu/welcome.json
Normal file
73
frontend/src/i18n/locales/hu/welcome.json
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "A YouTube-feliratkozásaid úgy, ahogy egy hírfolyamnak működnie kéne.",
|
||||||
|
"subtitle": "A Siftlode minden feltöltést egyetlen tiszta, szűrhető hírfolyamba gyűjt a követett csatornáidról — nincs algoritmus, ami eldönti, mit látsz, és nincs Shorts- vagy élő-zaj, hacsak nem kéred.",
|
||||||
|
"trust": "Self-hosted és privát. Lépj be e-maillel, vagy folytasd Google-fiókkal."
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
|
||||||
|
"channels": "Csatorna-kezelő",
|
||||||
|
"playlists": "Lejátszási listák"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"heading": "Miért a Siftlode",
|
||||||
|
"readable": {
|
||||||
|
"title": "A feliratkozásaid végre olvashatóan",
|
||||||
|
"body": "Rendezz és szűrj csatorna, nyelv, téma, hossz vagy dátum szerint — és rejts el csatornákat leiratkozás nélkül. Te döntöd el, mi kerül elő, nem egy algoritmus."
|
||||||
|
},
|
||||||
|
"neverMiss": {
|
||||||
|
"title": "Egy feltöltés se vesszen el",
|
||||||
|
"body": "Minden videó minden követett csatornádról egyetlen hírfolyamba kerül. A Shorts és az élő adások alapból félrerakva — így tényleg csak azt látod, amiért jöttél."
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"title": "Kétirányban szinkronizált lejátszási listák",
|
||||||
|
"body": "Építs helyi lejátszási listákat, és tartsd őket szinkronban a YouTube-bal — a változások mindkét irányba áramlanak."
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"title": "Nézd appon belül, onnan folytatva, ahol abbahagytad",
|
||||||
|
"body": "Az appon belüli lejátszó ott folytatja a videót, ahol megálltál — nincs ajánló-örvény, ami eltérítene."
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"title": "Igazi csatorna-kezelő",
|
||||||
|
"body": "Priorizáld a számodra fontos csatornákat, címkézd a saját módodon, és szűrd a hírfolyamot a saját címkéid szerint."
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"title": "Self-hosted, privát, többnyelvű",
|
||||||
|
"body": "Futtasd magad — az adataid a tieid maradnak. Többfelhasználós, a felület angolul, magyarul és németül."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signinTitle": "Belépés",
|
||||||
|
"createTitle": "Fiók létrehozása",
|
||||||
|
"forgotTitle": "Jelszó visszaállítása",
|
||||||
|
"resetTitle": "Új jelszó beállítása",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Jelszó",
|
||||||
|
"newPassword": "Új jelszó (min. {{min}} karakter)",
|
||||||
|
"invalidEmail": "Adj meg egy érvényes e-mail-címet.",
|
||||||
|
"weakPassword": "A jelszónak legalább {{min}} karakteresnek kell lennie.",
|
||||||
|
"genericError": "Valami hiba történt. Próbáld újra.",
|
||||||
|
"working": "Egy pillanat…",
|
||||||
|
"signinButton": "Belépés",
|
||||||
|
"createButton": "Fiók létrehozása",
|
||||||
|
"forgotButton": "Visszaállító link küldése",
|
||||||
|
"resetButton": "Jelszó beállítása",
|
||||||
|
"createLink": "Hozz létre egy fiókot",
|
||||||
|
"forgotLink": "Elfelejtetted a jelszót?",
|
||||||
|
"backToSignin": "Vissza a belépéshez",
|
||||||
|
"or": "vagy",
|
||||||
|
"google": "Folytatás Google-lel",
|
||||||
|
"tryDemo": "Demó kipróbálása",
|
||||||
|
"demoHint": "Adj meg egy e-mailt, amely engedélyezve van a demó fiókhoz.",
|
||||||
|
"demoEmail": "Demó e-mail",
|
||||||
|
"demoEnter": "Belépés",
|
||||||
|
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
|
||||||
|
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
|
||||||
|
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
|
||||||
|
"resetDone": "A jelszavad be van állítva — most már 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.",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -194,6 +194,9 @@ const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
|
||||||
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
|
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
|
||||||
interface ReqConfig {
|
interface ReqConfig {
|
||||||
idempotent?: boolean;
|
idempotent?: boolean;
|
||||||
|
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
|
||||||
|
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
|
||||||
|
quiet?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
|
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
|
||||||
|
|
@ -248,6 +251,8 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||||
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
||||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||||
markConnectivityLost();
|
markConnectivityLost();
|
||||||
|
} else if (cfg.quiet) {
|
||||||
|
/* caller handles the error inline — no global modal */
|
||||||
} else if (r.status >= 500) {
|
} else if (r.status >= 500) {
|
||||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||||
|
|
@ -608,6 +613,15 @@ export const api = {
|
||||||
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// --- auth: email + password (errors handled inline via quiet) ---
|
||||||
|
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||||
|
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||||
|
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
|
||||||
|
req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||||
|
requestPasswordReset: (email: string): Promise<{ status: string }> =>
|
||||||
|
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
|
||||||
|
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
||||||
|
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
|
||||||
// --- onboarding / admin ---
|
// --- onboarding / admin ---
|
||||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue