From 8f49bb56a2587ff1a3e2c7348590bf5b19da453f Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 16:35:46 +0200 Subject: [PATCH] fixing translations --- docs/FRONTEND.md | 2 + frontend/src/App.tsx | 17 ++++++-- frontend/src/components/Login.tsx | 2 +- frontend/src/components/dialogs.tsx | 60 +++++++++++++------------ src/mail.ts | 68 +++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 34 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c54fa90..0fef424 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -19,6 +19,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app. - `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`, `{ error }` parsing, `ApiError` with status, auto logout+reload on 401 (except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`. +- Boot shows `LoadingScreen` for at least 2.5 s (`MIN_SPLASH_MS` in `App.tsx`) + so the bloom animation always plays, even when the session restores instantly. - Sending: ensure a server conversation exists (created lazily on first send), optimistic user message, stream tokens into `streaming`, then append the full assistant message and refresh the list (server auto-titles new chats). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca8b031..3b8cc52 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,10 @@ function reviewIdFromPath(): string | null { return m ? m[1]! : null; } +/** Minimum time the boot splash stays visible, so the bloom animation + * actually plays even when the session restores instantly. */ +const MIN_SPLASH_MS = 2500; + function applyTheme(theme: string) { const root = document.documentElement; const dark = @@ -43,7 +47,7 @@ function applyTheme(theme: string) { export function App() { const [user, setUser] = useState(null); - const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE && !!getToken()); + const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE); const [models, setModels] = useState([]); const [model, setModel] = useState(""); const [convs, setConvs] = useState([]); @@ -90,10 +94,15 @@ export function App() { } }, []); - // Session restore + // Session restore (boot splash stays at least MIN_SPLASH_MS) useEffect(() => { + const t0 = Date.now(); + const done = () => { + const wait = Math.max(0, MIN_SPLASH_MS - (Date.now() - t0)); + window.setTimeout(() => setChecking(false), wait); + }; if (!getToken()) { - setChecking(false); + done(); return; } api.verify() @@ -112,7 +121,7 @@ export function App() { if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id); }) .catch(() => {}) - .finally(() => setChecking(false)); + .finally(done); }, []); // Load messages for active conversation diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 06a8a9c..986a830 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -89,7 +89,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) { {totpToken ? (

- {t("login.totpIntro", { user: "@" + totpUsername })} + {t("login.totpIntro", { user: totpUsername })}

diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index db250ae..489e1c4 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -109,7 +109,7 @@ export function RecoverDialog({ open, onClose, from }: { open: boolean; onClose:

{t("dlg.recoverSent", { - user: "@" + (username.trim().toLowerCase() || "…"), + user: username.trim().toLowerCase() || "…", fromPart: from ? t("dlg.recoverSentFrom", { from }) : "", })}

@@ -253,35 +253,35 @@ export function RegisterDialog({ } return ( - + {key ? (

- Welcome, @{username.trim().toLowerCase()}! Your access key — copy it now, it won't be shown again: + {t("dlg.regWelcome", { user: username.trim().toLowerCase() })}

{key}
- +
) : ( - + setUsername(e.target.value)} placeholder="alice" maxLength={32} required /> - + setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} /> - + setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" /> {error &&

{error}

}
- - + +
)} @@ -292,6 +292,7 @@ export function RegisterDialog({ /* ---------- Share ---------- */ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) { + const { t } = useT(); const [url, setUrl] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -305,7 +306,7 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo setError(""); api.share(conv.id) .then((r) => setUrl(window.location.origin + r.url)) - .catch((err) => setError(err instanceof Error ? err.message : "Share failed")) + .catch((err) => setError(err instanceof Error ? err.message : t("dlg.reqFailed"))) .finally(() => setBusy(false)); }, [conv]); @@ -316,20 +317,20 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo } return ( - - {busy &&

Creating public link…

} + + {busy &&

{t("dlg.shareCreating")}

} {error &&

{error}

} {url && (
-

Anyone with this link can read {conv?.title}. No login required.

+

{t("dlg.shareIntro", { title: conv?.title ?? "" })}

{url}
- - + +
)} @@ -350,6 +351,7 @@ export function SettingsModal({ onSaved: (u: User) => void; onKeyRotated?: () => void; }) { + const { t } = useT(); const [displayName, setDisplayName] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); const [email, setEmail] = useState(""); @@ -365,10 +367,10 @@ export function SettingsModal({ // Two-column settings: categories on the left, content on the right. // Searching filters the category list and jumps to the first match. const CATS = [ - { id: "profile", title: "Profile", icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] }, - { id: "appearance", title: "Appearance", icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette"] }, - { id: "features", title: "Features", icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] }, - { id: "security", title: "Security", icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] }, + { id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] }, + { id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] }, + { id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] }, + { id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] }, ] as const; type CatId = (typeof CATS)[number]["id"]; const [cat, setCat] = useState("profile"); @@ -395,11 +397,11 @@ export function SettingsModal({ async function uploadFile(f: File) { if (!user) return; if (f.size > 5 * 1024 * 1024) { - setUploadError("File too large (max 5MB)."); + setUploadError(t("sec.avatarTooBig")); return; } if (!/^image\/(jpeg|png|webp)$/.test(f.type)) { - setUploadError("Only jpg, png or webp images are accepted."); + setUploadError(t("sec.avatarType")); return; } setUploading(true); @@ -411,7 +413,7 @@ export function SettingsModal({ // preview (this modal, sidebar, chat bubbles) re-fetches immediately. bumpCdnVersion(); } catch (err) { - setUploadError(err instanceof Error ? err.message : "Upload failed"); + setUploadError(err instanceof Error ? err.message : t("attach.uploadFailed")); } finally { setUploading(false); } @@ -427,28 +429,28 @@ export function SettingsModal({ onSaved(res.user); onClose(); } catch (err) { - setError(err instanceof Error ? err.message : "Save failed"); + setError(err instanceof Error ? err.message : t("common.saveFailed")); } finally { setBusy(false); } } return ( - +
setSearch(e.target.value)} - placeholder="Search settings…" - aria-label="Search settings" + placeholder={t("settings.searchPh")} + aria-label={t("settings.searchAria")} className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900" /> {search && (
- Avatar upload + {t("settings.avatarUpload")}
fileRef.current?.click()} onKeyDown={(e) => e.key === "Enter" && fileRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} diff --git a/src/mail.ts b/src/mail.ts index 7bba8e3..6b8b7f1 100644 --- a/src/mail.ts +++ b/src/mail.ts @@ -8,6 +8,74 @@ export function mailEnabled(): boolean { return config.smtpHost.length > 0; } +/** Sanitized snapshot for the Admin Center mail viewer (admin-only route). */ +export function smtpStatus(): { + enabled: boolean; + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + hasPass: boolean; + from: string; + appUrl: string; +} { + return { + enabled: mailEnabled(), + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure, + user: config.smtpUser, + pass: config.smtpPass, + hasPass: config.smtpPass.length > 0, + from: config.smtpFrom, + appUrl: config.appUrl, + }; +} + +/** Verify SMTP connectivity (connect + auth, no mail sent). Throws on failure. */ +export async function verifySmtp(): Promise { + if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled"); + const mailer = await loadMailer(); + if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support"); + const t = mailer.createTransport({ + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure, + auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined, + }); + try { + await t.verify(); + } finally { + t.close(); + } +} + +/** Send a connectivity test mail to `to`. Throws on failure, returns messageId. */ +export async function sendSmtpTestMail(to: string): Promise { + if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled"); + const mailer = await loadMailer(); + if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support"); + const t = mailer.createTransport({ + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure, + auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined, + }); + try { + const info = await t.sendMail({ + from: config.smtpFrom, + to, + subject: "KisAssistant — SMTP test", + text: "This is a KisAssistant SMTP connectivity test. If you received it, outgoing mail works.", + html: `

This is a KisAssistant SMTP connectivity test. If you received it, outgoing mail works.

`, + }); + return info.messageId ?? ""; + } finally { + t.close(); + } +} + // Lazy import: the backend boots (and serves) fine without node_modules // installed; SMTP is only needed when actually sending. async function loadMailer(): Promise {