feat(auth): welcome/landing page + email-password login UI (5b)
Replace the old Google-only login card with a public landing page (Welcome.tsx): hero + auth card (email+password Sign in / Create account / Forgot password, Continue with Google, explicit Try-the-demo) over a feature pitch (6 pain->solution cards) and demo-account screenshot slots (graceful placeholder until the images are dropped into frontend/public/welcome/). Auth forms wire to the 5a endpoints; errors show inline via a new req() quiet flag (no global modal for a wrong password). Handles ?verify, ?reset (set-new-password form), ?access banners. EN/HU/DE. Verified end-to-end on a fresh load: render, register->approve->password-login.
This commit is contained in:
parent
40a3f5c005
commit
3fd5950f4c
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 { useConfirm } from "./components/ConfirmProvider";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import Login from "./components/Login";
|
||||
import Welcome from "./components/Welcome";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
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>
|
||||
);
|
||||
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
||||
return <Login />;
|
||||
return <Welcome />;
|
||||
if (meQuery.error)
|
||||
return (
|
||||
<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.
|
||||
interface ReqConfig {
|
||||
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> {
|
||||
|
|
@ -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.
|
||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||
markConnectivityLost();
|
||||
} else if (cfg.quiet) {
|
||||
/* caller handles the error inline — no global modal */
|
||||
} else if (r.status >= 500) {
|
||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
|
|
@ -608,6 +613,15 @@ export const api = {
|
|||
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 ---
|
||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue