Compare commits

...
3 Commits
Author SHA1 Message Date
k 66450303a1 translations :3 2026-09-21 16:53:15 +02:00
k af9b8c17d0 Update AdminPanel.tsx 2026-09-21 16:49:55 +02:00
k 245d26677b , 2026-09-21 16:48:59 +02:00
10 changed files with 706 additions and 176 deletions
+2
View File
@@ -66,6 +66,8 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) | | POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request | | GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
| GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply | | GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply |
| GET | `/api/admin/mail` | admin | SMTP credentials viewer (password blurred by default in UI) |
| POST | `/api/admin/mail/verify`, `/api/admin/mail/test` | admin | verify SMTP connectivity / send test mail `{to}` |
| POST / DELETE | `/api/me/key/rotate`, `/api/me` | Bearer | rotate own key (sessions revoked) / delete own account | | POST / DELETE | `/api/me/key/rotate`, `/api/me` | Bearer | rotate own key (sessions revoked) / delete own account |
| GET/POST/DELETE | `/api/me/totp`, `/api/me/totp/setup`, `/api/me/totp/verify` | Bearer | TOTP two-factor status/setup/verify/disable | | GET/POST/DELETE | `/api/me/totp`, `/api/me/totp/setup`, `/api/me/totp/verify` | Bearer | TOTP two-factor status/setup/verify/disable |
| POST | `/api/auth/totp` | challenge | second login step `{totpToken, code}` → session | | POST | `/api/auth/totp` | challenge | second login step `{totpToken, code}` → session |
+2 -1
View File
@@ -51,7 +51,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
- `AdminCenter.tsx` — `/admin` route (admin gate + locked screen otherwise): - `AdminCenter.tsx` — `/admin` route (admin gate + locked screen otherwise):
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
`/api/admin/settings`), SMTP status card. Follows the platform theme. `/api/admin/settings`), SMTP viewer (password blurred by default, eye toggle
to reveal) + connectivity tester (verify + send test mail). Follows the platform theme.
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated - `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated
(`{ title, topic, model, authorName, authorAvatarUrl, sharedAt, messages }`); (`{ title, topic, model, authorName, authorAvatarUrl, sharedAt, messages }`);
user messages render the author's avatar via `Avatar` (URL or initials). user messages render the author's avatar via `Avatar` (URL or initials).
+3 -1
View File
@@ -57,7 +57,9 @@ unshare. See `AGENTS.md`.
The sidebar **Admin Center** button (admin only) opens the `/admin` page with The sidebar **Admin Center** button (admin only) opens the `/admin` page with
four tabs: **Accounts** (same manager as before, incl. the Ollama model four tabs: **Accounts** (same manager as before, incl. the Ollama model
catalog), **Access** (access-request wishlist triage, see below), catalog), **Access** (access-request wishlist triage, see below),
**Features** (server-side switches), **Mail** (SMTP status). **Features** (server-side switches), **Mail** (SMTP credentials viewer +
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
`POST /api/admin/mail/test`, admin Bearer only).
- `registration_enabled` (default off): when on, the login page shows a - `registration_enabled` (default off): when on, the login page shows a
working **Register** button — anyone can create an account (key shown once) working **Register** button — anyone can create an account (key shown once)
+10 -7
View File
@@ -11,6 +11,7 @@ import { SharePage } from "./components/SharePage.tsx";
import { ResetPage } from "./components/ResetPage.tsx"; import { ResetPage } from "./components/ResetPage.tsx";
import { ReviewPage } from "./components/ReviewPage.tsx"; import { ReviewPage } from "./components/ReviewPage.tsx";
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx"; import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
import { useT } from "./lib/i18n.ts";
function shareIdFromPath(): string | null { function shareIdFromPath(): string | null {
const m = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/); const m = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/);
@@ -66,6 +67,7 @@ export function App() {
const [filePreview, setFilePreview] = useState<FilePreview | null>(null); const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
const [previewBusy, setPreviewBusy] = useState(false); const [previewBusy, setPreviewBusy] = useState(false);
const [ocrAvailable, setOcrAvailable] = useState(false); const [ocrAvailable, setOcrAvailable] = useState(false);
const { t } = useT();
// Public routes: no auth needed. IDs are constant for the page lifetime. // Public routes: no auth needed. IDs are constant for the page lifetime.
if (IS_ADMIN_PAGE) { if (IS_ADMIN_PAGE) {
@@ -188,7 +190,8 @@ export function App() {
const userMsg: ChatMessage = { role: "user", content: text }; const userMsg: ChatMessage = { role: "user", content: text };
// Attachments travel as reviewed text blocks appended to the message. // Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map((a) => const blocks = attachments.map((a) =>
`[attached ${a.kind === "ocr" ? "image transcription" : "text file"}: ${a.name}]\n\`\`\`text\n${a.text}\n\`\`\``, t("attach.tpl", { kind: t(a.kind === "ocr" ? "attach.kindOcr" : "attach.kindText"), name: a.name }) +
`\n\`\`\`text\n${a.text}\n\`\`\``,
); );
if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n"); if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
const history = [...messages, userMsg]; const history = [...messages, userMsg];
@@ -211,7 +214,7 @@ export function App() {
setStreaming(""); setStreaming("");
await refreshConvs(); await refreshConvs();
} catch { } catch {
setMessages([...history, { role: "assistant", content: "**Error:** the model did not respond. Try again." }]); setMessages([...history, { role: "assistant", content: t("attach.chatError") }]);
setStreaming(""); setStreaming("");
} finally { } finally {
setSending(false); setSending(false);
@@ -223,11 +226,11 @@ export function App() {
if (!user) return; if (!user) return;
setAttachError(null); setAttachError(null);
if (kind === "ocr" && file.size > 5 * 1024 * 1024) { if (kind === "ocr" && file.size > 5 * 1024 * 1024) {
setAttachError("Image too large (max 5MB)."); setAttachError(t("attach.tooBigImg"));
return; return;
} }
if (kind === "text" && file.size > 500 * 1024) { if (kind === "text" && file.size > 500 * 1024) {
setAttachError("Text file too large (max 500KB)."); setAttachError(t("attach.tooBigText"));
return; return;
} }
setFilePreview({ name: file.name, kind, text: "", truncated: false }); setFilePreview({ name: file.name, kind, text: "", truncated: false });
@@ -250,7 +253,7 @@ export function App() {
} }
} catch (e) { } catch (e) {
setFilePreview(null); setFilePreview(null);
setAttachError(e instanceof Error ? e.message : "Upload failed"); setAttachError(e instanceof Error ? e.message : t("attach.uploadFailed"));
} finally { } finally {
setPreviewBusy(false); setPreviewBusy(false);
} }
@@ -342,8 +345,8 @@ export function App() {
<ConfirmDialog <ConfirmDialog
open={deleteConv !== null} open={deleteConv !== null}
onClose={() => setDeleteConv(null)} onClose={() => setDeleteConv(null)}
title="Delete conversation?" title={t("app.deleteConvTitle")}
message={`"${deleteConv?.title}" and all its messages will be permanently removed.`} message={t("app.deleteConvMsg", { title: deleteConv?.title ?? "" })}
onConfirm={() => deleteConv && removeConv(deleteConv)} onConfirm={() => deleteConv && removeConv(deleteConv)}
/> />
<SettingsModal <SettingsModal
+53 -51
View File
@@ -5,6 +5,7 @@ import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "features" | "mail"; type Tab = "accounts" | "access" | "features" | "mail";
@@ -15,20 +16,21 @@ function applyTheme(theme: string) {
root.style.colorScheme = dark ? "dark" : "light"; root.style.colorScheme = dark ? "dark" : "light";
} }
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: "Accounts", icon: Users },
{ id: "access", label: "Access", icon: Inbox },
{ id: "features", label: "Features", icon: SlidersHorizontal },
{ id: "mail", label: "Mail", icon: Mail },
];
export function AdminCenter() { export function AdminCenter() {
const { t } = useT();
const [allowed, setAllowed] = useState<boolean | null>(null); const [allowed, setAllowed] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>("accounts"); const [tab, setTab] = useState<Tab>("accounts");
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null); const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
{ id: "mail", label: t("center.tabMail"), icon: Mail },
];
useEffect(() => { useEffect(() => {
api.verify() api.verify()
.then((r) => { .then((r) => {
@@ -38,7 +40,7 @@ export function AdminCenter() {
} }
setAllowed(true); setAllowed(true);
applyTheme(r.user.theme); applyTheme(r.user.theme);
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : "Load failed")); api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
}) })
.catch(() => setAllowed(false)); .catch(() => setAllowed(false));
}, []); }, []);
@@ -50,7 +52,7 @@ export function AdminCenter() {
const res = await api.adminPatchSettings(patch); const res = await api.adminPatchSettings(patch);
setSettings(res.settings); setSettings(res.settings);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Save failed"); setError(e instanceof Error ? e.message : t("common.saveFailed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -65,9 +67,9 @@ export function AdminCenter() {
<div className="grid min-h-full place-items-center p-6"> <div className="grid min-h-full place-items-center p-6">
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200 bg-white p-8 text-center shadow-xl dark:border-zinc-800 dark:bg-zinc-900"> <div className="w-full max-w-sm rounded-[2rem] border border-stone-200 bg-white p-8 text-center shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
<ShieldAlert size={28} className="mx-auto mb-3 text-red-500" /> <ShieldAlert size={28} className="mx-auto mb-3 text-red-500" />
<h1 className="text-lg font-bold">Admins only</h1> <h1 className="text-lg font-bold">{t("center.locked")}</h1>
<p className="mt-1 text-sm opacity-60">Log in with the admin account to open the Admin Center.</p> <p className="mt-1 text-sm opacity-60">{t("center.lockedSub")}</p>
<a href="/" className="mt-4 inline-block rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white dark:bg-accent-500 dark:text-zinc-950">Back to login</a> <a href="/" className="mt-4 inline-block rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white dark:bg-accent-500 dark:text-zinc-950">{t("common.backToLogin")}</a>
</div> </div>
</div> </div>
); );
@@ -77,13 +79,13 @@ export function AdminCenter() {
<div className="min-h-full"> <div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70"> <header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-4 py-3"> <div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-4 py-3">
<a href="/" aria-label="Back to chat" className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"> <a href="/" aria-label={t("center.back")} className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<ArrowLeft size={18} /> <ArrowLeft size={18} />
</a> </a>
<FlowerMark size={32} dynamic={false} /> <FlowerMark size={32} dynamic={false} />
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight"> <h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" /> <LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" />
Admin Center {t("center.title")}
</h1> </h1>
</div> </div>
<div className="mx-auto w-full max-w-4xl px-4 pb-3"> <div className="mx-auto w-full max-w-4xl px-4 pb-3">
@@ -126,25 +128,25 @@ export function AdminCenter() {
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">Public registration</p> <p className="text-sm font-medium">{t("center.regTitle")}</p>
<p className="text-sm opacity-60">Show the Register button on the login page. Off = admin-created accounts only. Turning it on retires the wishlist below.</p> <p className="text-sm opacity-60">{t("center.regDesc")}</p>
</div> </div>
<Switch label="Public registration" checked={settings?.registrationEnabled ?? false} onChange={(v) => toggle({ registrationEnabled: v })} /> <Switch label={t("center.regTitle")} checked={settings?.registrationEnabled ?? false} onChange={(v) => toggle({ registrationEnabled: v })} />
</div> </div>
<div className={cn( <div className={cn(
"flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900", "flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
settings?.registrationEnabled && "opacity-60", settings?.registrationEnabled && "opacity-60",
)}> )}>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">Access requests (wishlist)</p> <p className="text-sm font-medium">{t("center.accTitle")}</p>
<p className="text-sm opacity-60"> <p className="text-sm opacity-60">
{settings?.registrationEnabled {settings?.registrationEnabled
? "Unavailable while public registration is on — it replaces the wishlist." ? t("center.accDescOn")
: "Show Request access on the login page. Visitors reserve a username and plead their case; you triage them in the Access tab."} : t("center.accDescOff")}
</p> </p>
</div> </div>
<Switch <Switch
label="Access requests" label={t("center.accTitle")}
checked={settings?.accessRequestEnabled ?? false} checked={settings?.accessRequestEnabled ?? false}
disabled={settings?.registrationEnabled ?? false} disabled={settings?.registrationEnabled ?? false}
onChange={(v) => toggle({ accessRequestEnabled: v })} onChange={(v) => toggle({ accessRequestEnabled: v })}
@@ -152,13 +154,13 @@ export function AdminCenter() {
</div> </div>
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">OCR tool</p> <p className="text-sm font-medium">{t("center.ocrTitle")}</p>
<p className="text-sm opacity-60">Server-side image transcription for attachments. Requires the tesseract binary.</p> <p className="text-sm opacity-60">{t("center.ocrDesc")}</p>
</div> </div>
<Switch label="OCR tool" checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} /> <Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
</div> </div>
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Saving…</p>} {saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Loading…</p>} {!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
</section> </section>
)} )}
@@ -181,6 +183,7 @@ type SmtpInfo = {
}; };
function MailPanel() { function MailPanel() {
const { t } = useT();
const [smtp, setSmtp] = useState<SmtpInfo | null>(null); const [smtp, setSmtp] = useState<SmtpInfo | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
@@ -190,7 +193,7 @@ function MailPanel() {
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => { useEffect(() => {
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : "Load failed")); api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
}, []); }, []);
async function verify() { async function verify() {
@@ -198,9 +201,9 @@ function MailPanel() {
setResult(null); setResult(null);
try { try {
await api.adminMailVerify(); await api.adminMailVerify();
setResult({ ok: true, text: "Connection OK — SMTP host reachable, auth accepted." }); setResult({ ok: true, text: t("center.connOk") });
} catch (e) { } catch (e) {
setResult({ ok: false, text: e instanceof Error ? e.message : "Verification failed" }); setResult({ ok: false, text: e instanceof Error ? e.message : t("center.verifyFailed") });
} finally { } finally {
setVerifying(false); setVerifying(false);
} }
@@ -211,9 +214,9 @@ function MailPanel() {
setResult(null); setResult(null);
try { try {
const r = await api.adminMailTest(to.trim()); const r = await api.adminMailTest(to.trim());
setResult({ ok: true, text: `Test mail sent to ${to.trim()}${r.messageId ? ` (${r.messageId})` : ""}.` }); setResult({ ok: true, text: t("center.testSent", { to: to.trim(), tail: r.messageId ? ` (${r.messageId})` : "" }) });
} catch (e) { } catch (e) {
setResult({ ok: false, text: e instanceof Error ? e.message : "Send failed" }); setResult({ ok: false, text: e instanceof Error ? e.message : t("center.mailSendFailed") });
} finally { } finally {
setSending(false); setSending(false);
} }
@@ -236,40 +239,40 @@ function MailPanel() {
<Mail size={17} /> <Mail size={17} />
</span> </span>
<div> <div>
<p className="text-sm font-medium">Key recovery emails</p> <p className="text-sm font-medium">{t("center.mailTitle")}</p>
<p className="text-sm opacity-60"> <p className="text-sm opacity-60">
{smtp == null ? "Checking…" : smtp.enabled ? `Enabled — sent from ${smtp.from}` : "Disabled — set SMTP_HOST on the server to enable"} {smtp == null ? t("center.mailChecking") : smtp.enabled ? t("center.mailFrom", { from: smtp.from }) : t("center.mailOff")}
</p> </p>
</div> </div>
<span className={cn( <span className={cn(
"ml-auto rounded-full px-3 py-1 text-xs font-medium", "ml-auto rounded-full px-3 py-1 text-xs font-medium",
smtp?.enabled ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-stone-200/70 dark:bg-zinc-800", smtp?.enabled ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-stone-200/70 dark:bg-zinc-800",
)}> )}>
{smtp?.enabled ? "On" : "Off"} {smtp?.enabled ? t("center.on") : t("center.off")}
</span> </span>
</div> </div>
{error && <p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>} {error && <p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{!smtp && !error && <p className="mt-3 flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Loading…</p>} {!smtp && !error && <p className="mt-3 flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{smtp && ( {smtp && (
<div className="mt-3 divide-y divide-stone-200/70 rounded-2xl border border-stone-200/70 px-4 dark:divide-zinc-800 dark:border-zinc-800"> <div className="mt-3 divide-y divide-stone-200/70 rounded-2xl border border-stone-200/70 px-4 dark:divide-zinc-800 dark:border-zinc-800">
<Row label="Host">{smtp.host || <span className="opacity-50">(not set)</span>}</Row> <Row label={t("center.smtpHost")}>{smtp.host || <span className="opacity-50">{t("center.notSet")}</span>}</Row>
<Row label="Port">{smtp.port} · {smtp.secure ? "implicit TLS (465)" : "STARTTLS (587)"}</Row> <Row label={t("center.smtpPort")}>{smtp.port} · {smtp.secure ? t("center.tlsImplicit") : t("center.tlsStarttls")}</Row>
<Row label="User">{smtp.user || <span className="opacity-50">(no auth)</span>}</Row> <Row label={t("center.smtpUser")}>{smtp.user || <span className="opacity-50">{t("center.noAuth")}</span>}</Row>
<Row label="Password"> <Row label={t("center.smtpPass")}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{!smtp.hasPass ? ( {!smtp.hasPass ? (
<span className="opacity-50">(not set)</span> <span className="opacity-50">{t("center.notSet")}</span>
) : showPass ? ( ) : showPass ? (
<code className="truncate font-mono">{smtp.pass}</code> <code className="truncate font-mono">{smtp.pass}</code>
) : ( ) : (
<code className="select-none blur-sm" aria-label="hidden password">••••••••••</code> <code className="select-none blur-sm" aria-label={t("center.hiddenPass")}>••••••••••</code>
)} )}
{smtp.hasPass && ( {smtp.hasPass && (
<> <>
<button <button
type="button" type="button"
onClick={() => setShowPass((v) => !v)} onClick={() => setShowPass((v) => !v)}
aria-label={showPass ? "Hide SMTP password" : "Show SMTP password"} aria-label={showPass ? t("center.hidePass") : t("center.showPass")}
className="rounded-full p-1.5 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800" className="rounded-full p-1.5 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"
> >
{showPass ? <EyeOff size={15} /> : <Eye size={15} />} {showPass ? <EyeOff size={15} /> : <Eye size={15} />}
@@ -279,37 +282,36 @@ function MailPanel() {
)} )}
</span> </span>
</Row> </Row>
<Row label="From"><span className="truncate">{smtp.from}</span></Row> <Row label={t("center.smtpFrom")}><span className="truncate">{smtp.from}</span></Row>
<Row label="App URL"><span className="truncate">{smtp.appUrl}</span></Row> <Row label={t("center.smtpAppUrl")}><span className="truncate">{smtp.appUrl}</span></Row>
</div> </div>
)} )}
<p className="mt-3 text-sm opacity-60"> <p className="mt-3 text-sm opacity-60">
Credentials live in the server environment (see <code>docs/OPERATIONS.md</code>), read-only here. {t("center.credsNote")}
Users set their recovery address in profile settings or at account creation.
</p> </p>
</section> </section>
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium">Connectivity test</p> <p className="text-sm font-medium">{t("center.mailTestTitle")}</p>
<p className="mt-0.5 text-sm opacity-60">Verify the connection, then send a real test mail to an address you control.</p> <p className="mt-0.5 text-sm opacity-60">{t("center.mailTestDesc")}</p>
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={verify} disabled={verifying || !smtp?.enabled}> <Button variant="secondary" size="sm" onClick={verify} disabled={verifying || !smtp?.enabled}>
{verifying ? <Spinner size={14} /> : null} {verifying ? <Spinner size={14} /> : null}
{verifying ? "Verifying…" : "Verify connection"} {verifying ? t("center.verifying") : t("center.verifyConn")}
</Button> </Button>
</div> </div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row"> <div className="mt-3 flex flex-col gap-2 sm:flex-row">
<Input <Input
type="email" type="email"
placeholder="[email protected]" placeholder="[email protected]"
aria-label="Test recipient email" aria-label={t("center.testToAria")}
value={to} value={to}
onChange={(e) => setTo(e.target.value)} onChange={(e) => setTo(e.target.value)}
className="flex-1" className="flex-1"
/> />
<Button size="sm" onClick={sendTest} disabled={sending || !smtp?.enabled || !/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(to.trim())}> <Button size="sm" onClick={sendTest} disabled={sending || !smtp?.enabled || !/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(to.trim())}>
{sending ? <Spinner size={14} /> : <Send size={14} />} {sending ? <Spinner size={14} /> : <Send size={14} />}
{sending ? "Sending…" : "Send test mail"} {sending ? t("center.sendingMail") : t("center.sendTest")}
</Button> </Button>
</div> </div>
{result && ( {result && (
+81 -77
View File
@@ -2,25 +2,27 @@ import { useEffect, useState, type FC, type ReactNode } from "react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react"; import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import type { DriverModel, User } from "../lib/types.ts"; import type { DriverModel, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx"; import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx";
/** Curated Ollama library catalog (admin downloads only). */ /** Curated Ollama library catalog (admin downloads only). Descriptions resolve via i18n (`ollama.cat.*`). */
const OLLAMA_CATALOG: { name: string; desc: string; sizes: string[] }[] = [ const OLLAMA_CATALOG: { name: string; catKey: StringKey; sizes: string[] }[] = [
{ name: "llama3.2", desc: "Meta's compact everyday model", sizes: ["1b", "3b"] }, { name: "llama3.2", catKey: "ollama.cat.llama3_2", sizes: ["1b", "3b"] },
{ name: "llama3.1", desc: "Meta's general-purpose workhorse", sizes: ["8b", "70b"] }, { name: "llama3.1", catKey: "ollama.cat.llama3_1", sizes: ["8b", "70b"] },
{ name: "qwen2.5", desc: "Alibaba's multilingual all-rounder", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] }, { name: "qwen2.5", catKey: "ollama.cat.qwen2_5", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
{ name: "gemma2", desc: "Google's efficient open model", sizes: ["2b", "9b", "27b"] }, { name: "gemma2", catKey: "ollama.cat.gemma2", sizes: ["2b", "9b", "27b"] },
{ name: "mistral", desc: "Mistral 7B — fast and capable", sizes: ["7b"] }, { name: "mistral", catKey: "ollama.cat.mistral", sizes: ["7b"] },
{ name: "phi3", desc: "Microsoft's small research model", sizes: ["3.8b", "14b"] }, { name: "phi3", catKey: "ollama.cat.phi3", sizes: ["3.8b", "14b"] },
{ name: "deepseek-r1", desc: "Reasoning model (chain-of-thought)", sizes: ["1.5b", "7b", "8b", "14b", "32b"] }, { name: "deepseek-r1", catKey: "ollama.cat.deepseek_r1", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
{ name: "codellama", desc: "Code completion and chat", sizes: ["7b", "13b", "34b"] }, { name: "codellama", catKey: "ollama.cat.codellama", sizes: ["7b", "13b", "34b"] },
{ name: "llava", desc: "Vision model (image understanding)", sizes: ["7b", "13b", "34b"] }, { name: "llava", catKey: "ollama.cat.llava", sizes: ["7b", "13b", "34b"] },
{ name: "nomic-embed-text", desc: "Text embeddings", sizes: [] }, { name: "nomic-embed-text", catKey: "ollama.cat.nomic_embed_text", sizes: [] },
]; ];
/* ---------- Ollama model catalog (admin) ---------- */ /* ---------- Ollama model catalog (admin) ---------- */
export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) { export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useT();
const [installed, setInstalled] = useState<DriverModel[]>([]); const [installed, setInstalled] = useState<DriverModel[]>([]);
const [pullName, setPullName] = useState(""); const [pullName, setPullName] = useState("");
const [pullStatus, setPullStatus] = useState(""); const [pullStatus, setPullStatus] = useState("");
@@ -52,20 +54,20 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
if (pulling || !fullName.trim()) return; if (pulling || !fullName.trim()) return;
setPulling(true); setPulling(true);
setError(""); setError("");
setPullStatus("Contacting Ollama…"); setPullStatus(t("ollama.contacting"));
setPullPct(null); setPullPct(null);
try { try {
await api.ollamaPull(fullName.trim(), (p) => { await api.ollamaPull(fullName.trim(), (p) => {
if (p.error) { setError(p.error); return; } if (p.error) { setError(p.error); return; }
setPullStatus(p.status || "Pulling…"); setPullStatus(p.status || t("ollama.pullingSt"));
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100))); if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
else setPullPct(null); else setPullPct(null);
}); });
setPullStatus("Done"); setPullStatus(t("ollama.doneSt"));
setPullPct(100); setPullPct(100);
await refreshInstalled(); await refreshInstalled();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Pull failed"); setError(err instanceof Error ? err.message : t("ollama.pullFailed"));
} finally { } finally {
setPulling(false); setPulling(false);
} }
@@ -76,15 +78,15 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
await api.ollamaDelete(name); await api.ollamaDelete(name);
await refreshInstalled(); await refreshInstalled();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Delete failed"); setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
} }
} }
return ( return (
<> <>
<Modal open={open} onClose={pulling ? () => {} : onClose} title="Ollama models" icon={Bot}> <Modal open={open} onClose={pulling ? () => {} : onClose} title={t("ollama.models")} icon={Bot}>
<p className="mb-4 text-sm opacity-70"> <p className="mb-4 text-sm opacity-70">
Download models from the Ollama library to your Ollama host, or remove local ones. Admin only. {t("ollama.desc")}
</p> </p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>} {error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
@@ -92,7 +94,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30"> <div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"> <p className="mb-2 flex items-center gap-2 text-sm font-medium">
<Download size={15} className="animate-bounce" /> <Download size={15} className="animate-bounce" />
Pulling {pullName} {t("ollama.pulling", { name: pullName })}
</p> </p>
<p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p> <p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p>
{pullPct !== null && ( {pullPct !== null && (
@@ -105,7 +107,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
<div className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700"> <div className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-52 flex-1"> <div className="min-w-52 flex-1">
<Field label="Custom model tag" hint="e.g. llama3.2:3b, or a full library name"> <Field label={t("ollama.customTag")} hint={t("ollama.customHint")}>
<Input <Input
value={pullName} value={pullName}
onChange={(e) => setPullName(e.target.value)} onChange={(e) => setPullName(e.target.value)}
@@ -116,18 +118,18 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
</Field> </Field>
</div> </div>
<Button size="sm" onClick={() => pull(pullName)} disabled={pulling || !pullName.trim()}> <Button size="sm" onClick={() => pull(pullName)} disabled={pulling || !pullName.trim()}>
{pulling ? <Spinner size={15} /> : <Download size={15} />} Pull {pulling ? <Spinner size={15} /> : <Download size={15} />} {t("ollama.pull")}
</Button> </Button>
</div> </div>
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><HardDrive size={14} /> Installed ({installed.length})</p> <p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><HardDrive size={14} /> {t("ollama.installed", { n: installed.length })}</p>
<ul className="mb-5 space-y-1.5"> <ul className="mb-5 space-y-1.5">
{installed.length === 0 && <li className="text-sm opacity-50">No local Ollama models yet.</li>} {installed.length === 0 && <li className="text-sm opacity-50">{t("ollama.noneYet")}</li>}
{installed.map((m) => ( {installed.map((m) => (
<li key={m.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800"> <li key={m.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
<span className="min-w-0 flex-1 truncate text-sm font-medium">{m.label || m.id}</span> <span className="min-w-0 flex-1 truncate text-sm font-medium">{m.label || m.id}</span>
<button <button
title={`Delete ${m.label || m.id}`} title={t("ollama.delTip", { name: m.label || m.id })}
onClick={() => setDeleteModel(m.label || m.id)} onClick={() => setDeleteModel(m.label || m.id)}
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40" className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
> >
@@ -137,7 +139,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
))} ))}
</ul> </ul>
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><Download size={14} /> Catalog</p> <p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><Download size={14} /> {t("ollama.catalog")}</p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{OLLAMA_CATALOG.map((m) => { {OLLAMA_CATALOG.map((m) => {
const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : ""); const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : "");
@@ -146,12 +148,12 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
<li key={m.name} className="flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800"> <li key={m.name} className="flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{m.name}</span> <span className="block truncate text-sm font-medium">{m.name}</span>
<span className="block text-xs opacity-50">{m.desc}</span> <span className="block text-xs opacity-50">{t(m.catKey)}</span>
</span> </span>
{m.sizes.length > 0 && ( {m.sizes.length > 0 && (
<span className="w-24 shrink-0"> <span className="w-24 shrink-0">
<Picker <Picker
ariaLabel={`${m.name} size`} ariaLabel={t("ollama.sizeAria", { name: m.name })}
value={size} value={size}
onChange={(v) => setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))} onChange={(v) => setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))}
align="right" align="right"
@@ -160,7 +162,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
</span> </span>
)} )}
<Button size="sm" variant="secondary" onClick={() => { setPullName(tag); pull(tag); }} disabled={pulling}> <Button size="sm" variant="secondary" onClick={() => { setPullName(tag); pull(tag); }} disabled={pulling}>
<Download size={14} /> Pull <Download size={14} /> {t("ollama.pull")}
</Button> </Button>
</li> </li>
); );
@@ -171,8 +173,8 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
<ConfirmDialog <ConfirmDialog
open={deleteModel !== null} open={deleteModel !== null}
onClose={() => setDeleteModel(null)} onClose={() => setDeleteModel(null)}
title={`Delete ${deleteModel}?`} title={t("ollama.delTitle", { name: deleteModel ?? "" })}
message="The model is removed from the Ollama host. Pull it again anytime." message={t("ollama.delMsg")}
onConfirm={() => deleteModel && removeModel(deleteModel)} onConfirm={() => deleteModel && removeModel(deleteModel)}
/> />
</> </>
@@ -180,6 +182,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
} }
export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) { export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) {
const { t } = useT();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -213,7 +216,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
setPages(res.pages); setPages(res.pages);
if (res.page !== page) setPage(res.page); if (res.page !== page) setPage(res.page);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Load failed"); setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -254,7 +257,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
setShowCreate(false); setShowCreate(false);
await refresh(); await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Create failed"); setError(err instanceof Error ? err.message : t("admin.createFailed"));
} }
} }
@@ -264,7 +267,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
const res = await api.adminRegenerate(u.id); const res = await api.adminRegenerate(u.id);
setFreshKey({ username: u.username, key: res.key }); setFreshKey({ username: u.username, key: res.key });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Regenerate failed"); setError(err instanceof Error ? err.message : t("admin.regenFailed"));
} }
} }
@@ -273,7 +276,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
await api.adminDelete(u.id); await api.adminDelete(u.id);
await refresh(); await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Delete failed"); setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
} }
} }
@@ -281,19 +284,19 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
// of a modal — no logic duplicated. // of a modal — no logic duplicated.
const Shell: FC<{ children: ReactNode }> = bare const Shell: FC<{ children: ReactNode }> = bare
? ({ children }) => <div className="min-w-0 flex-1 space-y-1">{children}</div> ? ({ children }) => <div className="min-w-0 flex-1 space-y-1">{children}</div>
: ({ children }) => <Modal open={open} onClose={onClose} title="Accounts" icon={Users} wide>{children}</Modal>; : ({ children }) => <Modal open={open} onClose={onClose} title={t("admin.title")} icon={Users} wide>{children}</Modal>;
return ( return (
<> <>
<Shell> <Shell>
<p className="mb-4 text-sm opacity-70"> <p className="mb-4 text-sm opacity-70">
No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate. {t("admin.intro")}
</p> </p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>} {error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
{freshKey && ( {freshKey && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30"> <div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> Key for @{freshKey.username} — shown once, copy it now:</p> <p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> {t("admin.keyOnce", { user: freshKey.username })}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey.key}</code> <code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey.key}</code>
<CopyButton text={freshKey.key} /> <CopyButton text={freshKey.key} />
@@ -304,62 +307,62 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
{!showCreate ? ( {!showCreate ? (
<div className="mb-5 flex flex-wrap gap-2"> <div className="mb-5 flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}> <Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
<UserPlus size={15} /> New account <UserPlus size={15} /> {t("admin.newAccount")}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}> <Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}>
<Bot size={15} /> Ollama models <Bot size={15} /> {t("ollama.models")}
</Button> </Button>
</div> </div>
) : ( ) : (
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700"> <form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-40 flex-1"> <div className="min-w-40 flex-1">
<Field label="Username"><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field> <Field label={t("common.username")}><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
</div> </div>
<div className="min-w-40 flex-1"> <div className="min-w-40 flex-1">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field> <Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
</div> </div>
<div className="min-w-40 flex-1"> <div className="min-w-40 flex-1">
<Field label="Email (recovery)"><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field> <Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
</div> </div>
<Button size="sm" type="submit">Create</Button> <Button size="sm" type="submit">{t("common.create")}</Button>
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button> <Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>{t("common.cancel")}</Button>
</form> </form>
)} )}
<div className="mb-3 flex flex-wrap items-center gap-2"> <div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-40 flex-1"> <div className="relative min-w-40 flex-1">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" /> <Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search accounts…" aria-label="Search accounts" className="pl-10" /> <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("admin.searchPh")} aria-label={t("admin.searchAria")} className="pl-10" />
</div> </div>
<Picker <Picker
ariaLabel="Sort accounts" ariaLabel={t("admin.sortAria")}
icon={ArrowUpDown} icon={ArrowUpDown}
value={sort} value={sort}
onChange={(v) => { setSort(v); setPage(1); }} onChange={(v) => { setSort(v); setPage(1); }}
align="right" align="right"
options={[ options={[
{ value: "newest", label: "Newest first" }, { value: "newest", label: t("admin.sortNewest") },
{ value: "oldest", label: "Oldest first" }, { value: "oldest", label: t("admin.sortOldest") },
{ value: "az", label: "A → Z" }, { value: "az", label: t("admin.sortAz") },
{ value: "za", label: "Z → A" }, { value: "za", label: t("admin.sortZa") },
]} ]}
/> />
<Picker <Picker
ariaLabel="Filter accounts" ariaLabel={t("admin.filterAria")}
icon={Filter} icon={Filter}
value={filter} value={filter}
onChange={(v) => { setFilter(v); setPage(1); }} onChange={(v) => { setFilter(v); setPage(1); }}
align="right" align="right"
options={[ options={[
{ value: "all", label: "All accounts" }, { value: "all", label: t("admin.filterAll") },
{ value: "with-email", label: "With recovery email" }, { value: "with-email", label: t("admin.filterWith") },
{ value: "no-email", label: "No recovery email" }, { value: "no-email", label: t("admin.filterNo") },
]} ]}
/> />
</div> </div>
{busy ? ( {busy ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading…</p> <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{users.map((u) => ( {users.map((u) => (
@@ -367,13 +370,13 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
<Avatar name={u.displayName} url={u.avatarUrl} size={36} /> <Avatar name={u.displayName} url={u.avatarUrl} size={36} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p> <p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p>
<p className="truncate text-xs opacity-50">{u.email || "no recovery email"} · {new Date(u.createdAt).toLocaleDateString()}</p> <p className="truncate text-xs opacity-50">{u.email || t("admin.noEmail")} · {new Date(u.createdAt).toLocaleDateString()}</p>
</div> </div>
{!u.isAdmin && ( {!u.isAdmin && (
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<button title="Edit profile" onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button> <button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title="Regenerate key (revokes old sessions)" onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button> <button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title="Delete account" onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button> <button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
</div> </div>
)} )}
</li> </li>
@@ -381,18 +384,18 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
</ul> </ul>
)} )}
<div className="mt-3 flex items-center justify-between gap-2 text-sm"> <div className="mt-3 flex items-center justify-between gap-2 text-sm">
<span className="opacity-60">{total} account{total === 1 ? "" : "s"} · page {page} of {pages}</span> <span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
{pages > 1 && ( {pages > 1 && (
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<IconButton <IconButton
title="Previous page" title={t("admin.prevPage")}
disabled={page <= 1} disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
> >
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</IconButton> </IconButton>
<IconButton <IconButton
title="Next page" title={t("admin.nextPage")}
disabled={page >= pages} disabled={page >= pages}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
> >
@@ -406,8 +409,8 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
<ConfirmDialog <ConfirmDialog
open={deleteTarget !== null} open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)} onClose={() => setDeleteTarget(null)}
title={`Delete @${deleteTarget?.username}?`} title={t("admin.deleteTitle", { user: deleteTarget?.username ?? "" })}
message="This permanently removes the account, all its conversations and its shares." message={t("admin.deleteMsg")}
onConfirm={() => deleteTarget && remove(deleteTarget)} onConfirm={() => deleteTarget && remove(deleteTarget)}
/> />
@@ -418,6 +421,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
} }
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) { function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
const { t } = useT();
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState(""); const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -442,33 +446,33 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
onSaved(); onSaved();
onClose(); onClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Save failed"); setError(err instanceof Error ? err.message : t("common.saveFailed"));
} }
} }
return ( return (
<Modal open={user !== null} onClose={onClose} title={`Edit @${user?.username}`} icon={Pencil}> <Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
<form onSubmit={save} className="space-y-4"> <form onSubmit={save} className="space-y-4">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field> <Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
<Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field> <Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
<Field label="Email (recovery)"><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field> <Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
<Field label="Theme"> <Field label={t("common.theme")}>
<Picker <Picker
ariaLabel="Theme" ariaLabel={t("common.theme")}
value={theme} value={theme}
onChange={setTheme} onChange={setTheme}
align="left" align="left"
options={[ options={[
{ value: "auto", label: "Auto" }, { value: "auto", label: t("common.themeAuto") },
{ value: "light", label: "Light" }, { value: "light", label: t("common.light") },
{ value: "dark", label: "Dark" }, { value: "dark", label: t("common.dark") },
]} ]}
/> />
</Field> </Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button> <Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
<Button size="sm" type="submit">Save</Button> <Button size="sm" type="submit">{t("common.save")}</Button>
</div> </div>
</form> </form>
</Modal> </Modal>
+24 -21
View File
@@ -5,8 +5,10 @@ import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGro
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts"; import { useFeatures } from "../lib/features.ts";
import { useT } from "../lib/i18n.ts";
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) { function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
const { t } = useT();
const isUser = msg.role === "user"; const isUser = msg.role === "user";
return ( return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}> <div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
@@ -16,7 +18,7 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}> <div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"> <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />} {isUser ? <UserIcon size={12} /> : <Bot size={12} />}
{isUser ? "You" : "KisAssistant"} {isUser ? t("chat.you") : "KisAssistant"}
</div> </div>
{isUser ? ( {isUser ? (
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950"> <div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950">
@@ -73,6 +75,7 @@ export function Chat({
}) { }) {
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false);
const { t } = useT();
const features = useFeatures(); const features = useFeatures();
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
@@ -114,35 +117,35 @@ export function Chat({
return ( return (
<div className="flex h-full min-w-0 flex-1 flex-col"> <div className="flex h-full min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800"> <header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
<IconButton title="Open chats" onClick={onOpenNav} className="md:hidden"> <IconButton title={t("chat.openNav")} onClick={onOpenNav} className="md:hidden">
<Menu size={18} /> <Menu size={18} />
</IconButton> </IconButton>
{sidebarCollapsed && ( {sidebarCollapsed && (
<span className="hidden md:inline"> <span className="hidden md:inline">
<IconButton title="Expand sidebar" onClick={onExpandSidebar}> <IconButton title={t("chat.expand")} onClick={onExpandSidebar}>
<PanelLeftOpen size={18} /> <PanelLeftOpen size={18} />
</IconButton> </IconButton>
</span> </span>
)} )}
<span className="hidden items-center gap-2 font-semibold sm:flex"> <span className="hidden items-center gap-2 font-semibold sm:flex">
<FlowerMark size={24} dynamic={false} /> <FlowerMark size={24} dynamic={false} />
{conv?.title || "New chat"} {conv?.title || t("sidebar.newChat")}
</span> </span>
{conv?.topic && ( {conv?.topic && (
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span> <span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
)} )}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<Picker <Picker
ariaLabel="Model" ariaLabel={t("chat.modelAria")}
icon={Cpu} icon={Cpu}
value={model} value={model}
onChange={onModelChange} onChange={onModelChange}
groups={modelGroups} groups={modelGroups}
placeholder={models.length === 0 ? "No models" : "Select model…"} placeholder={models.length === 0 ? t("chat.noModels") : t("chat.selectModel")}
disabled={models.length === 0} disabled={models.length === 0}
/> />
{conv && ( {conv && (
<IconButton title="Share publicly" onClick={onShare}> <IconButton title={t("sidebar.share")} onClick={onShare}>
<Share2 size={17} /> <Share2 size={17} />
</IconButton> </IconButton>
)} )}
@@ -154,8 +157,8 @@ export function Chat({
{messages.length === 0 && !streaming && ( {messages.length === 0 && !streaming && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" /> <FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2> <h2 className="text-2xl font-bold tracking-tight">{t("chat.greet", { name: user.displayName.split(" ")[0]! })}</h2>
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p> <p className="mt-1 text-sm opacity-60">{t("chat.greetSub")}</p>
</div> </div>
)} )}
{messages.filter((m) => m.role !== "system").map((m, i) => ( {messages.filter((m) => m.role !== "system").map((m, i) => (
@@ -173,7 +176,7 @@ export function Chat({
</div> </div>
)} )}
{sending && streaming === "" && ( {sending && streaming === "" && (
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">Thinking…</span></div> <div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">{t("chat.thinking")}</span></div>
)} )}
</div> </div>
</div> </div>
@@ -189,7 +192,7 @@ export function Chat({
<span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-accent-600/30 bg-accent-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-accent-500/30 dark:bg-accent-500/10"> <span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-accent-600/30 bg-accent-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-accent-500/30 dark:bg-accent-500/10">
{a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />} {a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />}
<span className="max-w-40 truncate">{a.name}</span> <span className="max-w-40 truncate">{a.name}</span>
<button onClick={() => onRemoveAttachment(a.id)} aria-label={`Remove ${a.name}`} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10"> <button onClick={() => onRemoveAttachment(a.id)} aria-label={t("chat.removeAttach", { name: a.name })} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
<X size={13} /> <X size={13} />
</button> </button>
</span> </span>
@@ -211,8 +214,8 @@ export function Chat({
<ScanText size={16} /> <ScanText size={16} />
</span> </span>
<span> <span>
<span className="block text-sm font-medium">Transcribe image {ocrAvailable ? "" : "(unavailable)"}</span> <span className="block text-sm font-medium">{t("chat.ocrOpt")} {ocrAvailable ? "" : t("chat.unavailable")}</span>
<span className="block text-xs opacity-60">JPG, PNG, WEBP — OCR on the server, you review the text first</span> <span className="block text-xs opacity-60">{t("chat.ocrHint")}</span>
</span> </span>
</button> </button>
<button <button
@@ -223,12 +226,12 @@ export function Chat({
<FileText size={16} /> <FileText size={16} />
</span> </span>
<span> <span>
<span className="block text-sm font-medium">Attach text file</span> <span className="block text-sm font-medium">{t("chat.textOpt")}</span>
<span className="block text-xs opacity-60">TXT or Markdown, max 500KB</span> <span className="block text-xs opacity-60">{t("chat.textHint")}</span>
</span> </span>
</button> </button>
<p className="px-3 pb-1.5 pt-2 text-xs opacity-60"> <p className="px-3 pb-1.5 pt-2 text-xs opacity-60">
Uploads go through platform tools only. Images are sent to the model as reviewed text — never as raw images. {t("chat.attachNote")}
</p> </p>
</div> </div>
</> </>
@@ -239,8 +242,8 @@ export function Chat({
<button <button
type="button" type="button"
onClick={() => setAttachOpen((o) => !o)} onClick={() => setAttachOpen((o) => !o)}
aria-label="Attach a file" aria-label={t("chat.attachTip")}
title="Attach a file" title={t("chat.attachTip")}
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800" className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
> >
<Paperclip size={17} className="opacity-70" /> <Paperclip size={17} className="opacity-70" />
@@ -259,14 +262,14 @@ export function Chat({
submit(); submit();
} }
}} }}
placeholder="Message KisAssistant…" placeholder={t("chat.placeholder")}
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500" className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
style={{ maxHeight: 200 }} style={{ maxHeight: 200 }}
/> />
<button <button
type="submit" type="submit"
disabled={(!draft.trim() && attachments.length === 0) || sending} disabled={(!draft.trim() && attachments.length === 0) || sending}
aria-label="Send" aria-label={t("chat.send")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400" className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
> >
<SendHorizontal size={17} /> <SendHorizontal size={17} />
@@ -275,7 +278,7 @@ export function Chat({
<p className="py-2 text-center text-xs opacity-50"> <p className="py-2 text-center text-xs opacity-50">
<a href="/wiki" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline-offset-2 hover:underline"> <a href="/wiki" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
<BookOpen size={12} /> <BookOpen size={12} />
Wiki & docs {t("chat.wiki")}
</a> </a>
</p> </p>
</div> </div>
+11 -11
View File
@@ -196,7 +196,7 @@ export function Sidebar({
) : ( ) : (
<> <>
{convs.length === 0 && ( {convs.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p> <p className="px-3 py-6 text-center text-sm opacity-50">{t("sidebar.emptyA")}<br />{t("sidebar.emptyB")}</p>
)} )}
{convs.map((c) => ( {convs.map((c) => (
<div <div
@@ -243,7 +243,7 @@ export function Sidebar({
)} )}
</span> </span>
<button <button
aria-label="Conversation options" aria-label={t("sidebar.convOptions")}
onClick={(e) => openMenu(e, c)} onClick={(e) => openMenu(e, c)}
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700" className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
> >
@@ -261,32 +261,32 @@ export function Sidebar({
y={menu.y} y={menu.y}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
items={[ items={[
{ icon: Pencil, label: "Rename", onClick: () => onRename(menu.conv) }, { icon: Pencil, label: t("sidebar.rename"), onClick: () => onRename(menu.conv) },
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) }, { icon: Tag, label: t("sidebar.topic"), onClick: () => onTopic(menu.conv) },
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) }, { icon: Share2, label: t("sidebar.share"), onClick: () => onShare(menu.conv) },
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) }, { icon: Trash2, label: t("sidebar.delete"), danger: true, onClick: () => onDelete(menu.conv) },
]} ]}
/> />
)} )}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900"> <div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title="Profile & settings"> <button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title={t("sidebar.settingsTip")}>
<Avatar name={user.displayName} url={user.avatarUrl} size={34} /> <Avatar name={user.displayName} url={user.avatarUrl} size={34} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.displayName}</span> <span className="block truncate text-sm font-medium">{user.displayName}</span>
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? " · admin" : ""}</span> <span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? ` · ${t("sidebar.admin")}` : ""}</span>
</span> </span>
<Settings size={16} className="opacity-50" /> <Settings size={16} className="opacity-50" />
</button> </button>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{user.isAdmin && ( {user.isAdmin && (
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title="Open the Admin Center"> <Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title={t("sidebar.adminCenterTip")}>
<LayoutDashboard size={15} /> <LayoutDashboard size={15} />
Admin Center {t("sidebar.adminCenter")}
</Button> </Button>
)} )}
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}> <Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
Log out {t("sidebar.logout")}
</Button> </Button>
</div> </div>
</div> </div>
+16 -1
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react"; import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import { useT } from "../lib/i18n.ts"; import { LANGS, setLang, useT, type LangId } from "../lib/i18n.ts";
/* ---------- Button ---------- */ /* ---------- Button ---------- */
@@ -234,6 +234,21 @@ export function Picker({
); );
} }
/* ---------- Language picker (framework-styled dropdown over LANGS) ---------- */
export function LangPicker({ align = "right" }: { align?: "left" | "right" }) {
const { t, lang } = useT();
return (
<Picker
ariaLabel={t("settings.language")}
value={lang}
onChange={(v) => setLang(v as LangId)}
align={align}
options={LANGS.map((l) => ({ value: l.id, label: l.label }))}
/>
);
}
/* ---------- Modal / Dialog ---------- */ /* ---------- Modal / Dialog ---------- */
export function Modal({ export function Modal({
+504 -6
View File
@@ -273,7 +273,7 @@ const en = {
"center.lockedSub": "Log in with the admin account to open the Admin Center.", "center.lockedSub": "Log in with the admin account to open the Admin Center.",
"center.regTitle": "Public registration", "center.regTitle": "Public registration",
"center.regDesc": "center.regDesc":
"Show the Register button on the login page. Off = admin-created accounts only.", "Show the Register button on the login page. Off = admin-created accounts only. Turning it on retires the wishlist below.",
"center.accTitle": "Access requests (wishlist)", "center.accTitle": "Access requests (wishlist)",
"center.accDescOn": "center.accDescOn":
"Unavailable while public registration is on — it replaces the wishlist.", "Unavailable while public registration is on — it replaces the wishlist.",
@@ -318,6 +318,144 @@ const en = {
"app.deleteConvTitle": "Delete conversation?", "app.deleteConvTitle": "Delete conversation?",
"app.deleteConvMsg": "app.deleteConvMsg":
"“{title}” and all its messages will be permanently removed.", "“{title}” and all its messages will be permanently removed.",
"settings.language": "Language",
"settings.languageHint":
"Interface language. Applied instantly, saved on this device.",
"settings.security": "Security",
"sec.accessKey": "Access key",
"sec.accessKeyHint":
"Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.",
"sec.rotate": "Rotate my access key",
"sec.rotateConfirm": "Click again to confirm rotation",
"sec.rotationFailed": "Rotation failed",
"sec.totp": "Two-factor (TOTP)",
"sec.totpChecking": "Checking…",
"sec.totpAddHint":
"Add a 6-digit code from your authenticator app on top of your access key.",
"sec.totpEnable": "Enable two-factor",
"sec.totpSecretHint":
"Add this secret to your authenticator app (or open the otpauth link), then enter a code to confirm:",
"sec.totpConfirm": "Confirm",
"sec.totpDisableLabel": "Disable with a current code",
"sec.totpDisable": "Disable 2FA",
"sec.setupFailed": "Setup failed",
"sec.passkeys": "Passkeys",
"sec.passkeysHint":
"WebAuthn / platform authenticators — needs a server RP setup.",
"sec.danger": "Danger zone",
"sec.dangerHint":
"Permanently delete your account with all chats, shares and sessions. Cannot be undone.",
"sec.deleteMe": "Delete my account",
"sec.deleteFailed": "Delete failed",
"sec.newKeyTitle": "New access key",
"sec.newKeyHint":
"Copy it now — it won't be shown again. Your old sessions are revoked.",
"sec.newKeyDone": "Done — log me out",
"sec.deleteTitle": "Delete your account?",
"sec.deleteMsg":
"This permanently removes your account with all conversations, shares and sessions. This cannot be undone.",
"sec.deleteForever": "Delete forever",
"sec.avatarTooBig": "File too large (max 5MB).",
"sec.avatarType": "Only jpg, png or webp images are accepted.",
"center.tabAccess": "Access",
"center.smtpHost": "Host",
"center.smtpPort": "Port",
"center.smtpUser": "User",
"center.smtpPass": "Password",
"center.smtpFrom": "From",
"center.smtpAppUrl": "App URL",
"center.notSet": "(not set)",
"center.noAuth": "(no auth)",
"center.tlsImplicit": "implicit TLS (465)",
"center.tlsStarttls": "STARTTLS (587)",
"center.showPass": "Show SMTP password",
"center.hidePass": "Hide SMTP password",
"center.hiddenPass": "hidden password",
"center.mailTestTitle": "Connectivity test",
"center.mailTestDesc":
"Verify the connection, then send a real test mail to an address you control.",
"center.verifyConn": "Verify connection",
"center.verifying": "Verifying…",
"center.sendTest": "Send test mail",
"center.sendingMail": "Sending…",
"center.testToAria": "Test recipient email",
"center.connOk": "Connection OK — SMTP host reachable, auth accepted.",
"center.verifyFailed": "Verification failed",
"center.testSent": "Test mail sent to {to}{tail}.",
"center.mailSendFailed": "Send failed",
"center.credsNote":
"Credentials live in the server environment (see docs/OPERATIONS.md), read-only here. Users set their recovery address in profile settings or at account creation.",
"access.status.pending": "Pending",
"access.status.reviewing": "Reviewing",
"access.status.accepted": "Accepted",
"access.status.refused": "Refused",
"access.filter.all": "all",
"access.filter.pending": "pending",
"access.filter.reviewing": "reviewing",
"access.filter.accepted": "accepted",
"access.filter.refused": "refused",
"access.refresh": "Refresh",
"access.refreshAria": "Refresh requests",
"access.empty": "No access requests. The wishlist is empty.",
"access.emptyFiltered": "Nothing with this status.",
"access.msgOne": "1 message",
"access.msgMany": "{n} messages",
"access.accountCreated": "Account created — key shown once, copy it now:",
"access.replyPh": "Reply (emailed to the requester)…",
"access.replyAria": "Reply to requester",
"access.reasonLabel": "Reason (emailed with the decision)",
"access.reasonPh": "Optional reason…",
"access.btnReviewing": "Reviewing",
"access.btnAccept": "Accept",
"access.btnRefuse": "Refuse",
"access.closedNote": "Ticket closed ({status}).",
"access.reasonWord": "Reason",
"access.youAdmin": "You (admin)",
"access.sendFailed": "Send failed",
"access.updateFailed": "Update failed",
"review.title": "Access request",
"review.loadingTicket": "Loading ticket…",
"review.notFound": "Ticket not found",
"review.notFoundSub":
"This link is unknown. Check the URL from your email, or file a new request from the login page.",
"review.adminNote": "Admin note:",
"review.acceptedHint":
"Your account was created — check your email for the access key, then log in.",
"review.closedHint":
"This ticket is closed. Replies by email are not read — if needed, file a new request.",
"review.replyPh": "Argue your case…",
"review.replyAria": "Reply to your ticket",
"review.mailHint":
"Email notifications are one-way — always reply here, not by email.",
"review.status.pending": "Pending review",
"review.status.reviewing": "Under review",
"review.status.accepted": "Accepted",
"review.status.refused": "Refused",
"review.admin": "Admin",
"review.you": "You",
"review.requestTitle": "Request access",
"review.requestIntro":
"Reserve a username and tell us why you want in. An admin reviews every request — you follow the discussion on your personal ticket page and by email.",
"review.emailHint":
"Required — status updates and ticket replies land here.",
"review.whyLabel": "Why should you get access?",
"review.whyHint": "10+ characters. This starts your ticket thread.",
"review.whyPh": "I want to…",
"review.sendRequest": "Send request",
"review.received": "Request received",
"review.reserved":
"@{user} is reserved for you while an admin reviews it. A confirmation email with your personal ticket link is on its way.",
"review.copyLink": "Copy link",
"review.openTicket": "Open my ticket",
"admin.editUserTitle": "Edit @{user}",
"admin.avatarUrl": "Avatar URL",
} as const; } as const;
export type StringKey = keyof typeof en; export type StringKey = keyof typeof en;
@@ -575,7 +713,7 @@ const es: Dict = {
"center.lockedSub": "Inicia sesión como admin para abrir el Centro de admin.", "center.lockedSub": "Inicia sesión como admin para abrir el Centro de admin.",
"center.regTitle": "Registro público", "center.regTitle": "Registro público",
"center.regDesc": "center.regDesc":
"Muestra el botón Registrarse en el login. Off = solo cuentas del admin.", "Muestra el botón Registrarse en el login. Off = solo cuentas del admin. Activarlo jubila la lista de espera.",
"center.accTitle": "Solicitudes de acceso (lista)", "center.accTitle": "Solicitudes de acceso (lista)",
"center.accDescOn": "No disponible con registro público — lo reemplaza.", "center.accDescOn": "No disponible con registro público — lo reemplaza.",
"center.accDescOff": "center.accDescOff":
@@ -618,6 +756,144 @@ const es: Dict = {
"app.deleteConvTitle": "¿Eliminar conversación?", "app.deleteConvTitle": "¿Eliminar conversación?",
"app.deleteConvMsg": "«{title}» y sus mensajes se eliminarán.", "app.deleteConvMsg": "«{title}» y sus mensajes se eliminarán.",
"settings.language": "Idioma",
"settings.languageHint":
"Idioma de la interfaz. Al instante, en este dispositivo.",
"settings.security": "Seguridad",
"sec.accessKey": "Clave de acceso",
"sec.accessKeyHint":
"Rota tu clave de acceso. Las sesiones antiguas se revocan al instante — volverás con la nueva.",
"sec.rotate": "Rotar mi clave",
"sec.rotateConfirm": "Pulsa de nuevo para confirmar",
"sec.rotationFailed": "No se pudo rotar",
"sec.totp": "Doble factor (TOTP)",
"sec.totpChecking": "Verificando…",
"sec.totpAddHint":
"Añade un código de 6 dígitos de tu app además de tu clave.",
"sec.totpEnable": "Activar doble factor",
"sec.totpSecretHint":
"Añade este secreto a tu app (o abre el enlace otpauth) y escribe un código para confirmar:",
"sec.totpConfirm": "Confirmar",
"sec.totpDisableLabel": "Desactivar con un código actual",
"sec.totpDisable": "Desactivar 2FA",
"sec.setupFailed": "No se pudo configurar",
"sec.passkeys": "Passkeys",
"sec.passkeysHint":
"WebAuthn / autenticadores del sistema — requiere RP en el servidor.",
"sec.danger": "Zona de peligro",
"sec.dangerHint":
"Elimina tu cuenta con chats, shares y sesiones. Sin vuelta atrás.",
"sec.deleteMe": "Eliminar mi cuenta",
"sec.deleteFailed": "No se pudo eliminar",
"sec.newKeyTitle": "Nueva clave de acceso",
"sec.newKeyHint":
"Cópiala ahora — no se mostrará de nuevo. Tus sesiones se revocan.",
"sec.newKeyDone": "Listo — cerrar sesión",
"sec.deleteTitle": "¿Eliminar tu cuenta?",
"sec.deleteMsg":
"Esto elimina tu cuenta con conversaciones, shares y sesiones. Sin vuelta atrás.",
"sec.deleteForever": "Eliminar para siempre",
"sec.avatarTooBig": "Archivo muy grande (máx. 5MB).",
"sec.avatarType": "Solo se aceptan imágenes jpg, png o webp.",
"center.tabAccess": "Acceso",
"center.smtpHost": "Servidor",
"center.smtpPort": "Puerto",
"center.smtpUser": "Usuario",
"center.smtpPass": "Contraseña",
"center.smtpFrom": "Remitente",
"center.smtpAppUrl": "URL de la app",
"center.notSet": "(sin definir)",
"center.noAuth": "(sin auth)",
"center.tlsImplicit": "TLS implícito (465)",
"center.tlsStarttls": "STARTTLS (587)",
"center.showPass": "Mostrar contraseña SMTP",
"center.hidePass": "Ocultar contraseña SMTP",
"center.hiddenPass": "contraseña oculta",
"center.mailTestTitle": "Prueba de conexión",
"center.mailTestDesc":
"Verifica la conexión y envía un email real a una dirección tuya.",
"center.verifyConn": "Verificar conexión",
"center.verifying": "Verificando…",
"center.sendTest": "Enviar email de prueba",
"center.sendingMail": "Enviando…",
"center.testToAria": "Email de prueba",
"center.connOk": "Conexión OK — SMTP accesible, auth aceptada.",
"center.verifyFailed": "Verificación fallida",
"center.testSent": "Email enviado a {to}{tail}.",
"center.mailSendFailed": "No se pudo enviar",
"center.credsNote":
"Las credenciales viven en el servidor (ver docs/OPERATIONS.md), solo lectura aquí. El email se define en el perfil o al crear la cuenta.",
"access.status.pending": "Pendiente",
"access.status.reviewing": "En revisión",
"access.status.accepted": "Aceptada",
"access.status.refused": "Rechazada",
"access.filter.all": "todas",
"access.filter.pending": "pendientes",
"access.filter.reviewing": "en revisión",
"access.filter.accepted": "aceptadas",
"access.filter.refused": "rechazadas",
"access.refresh": "Actualizar",
"access.refreshAria": "Actualizar solicitudes",
"access.empty": "Sin solicitudes. La lista está vacía.",
"access.emptyFiltered": "Nada con este estado.",
"access.msgOne": "1 mensaje",
"access.msgMany": "{n} mensajes",
"access.accountCreated": "Cuenta creada — clave única, cópiala ahora:",
"access.replyPh": "Responder (llega por email)…",
"access.replyAria": "Responder al solicitante",
"access.reasonLabel": "Motivo (se envía con la decisión)",
"access.reasonPh": "Motivo opcional…",
"access.btnReviewing": "Revisar",
"access.btnAccept": "Aceptar",
"access.btnRefuse": "Rechazar",
"access.closedNote": "Ticket cerrado ({status}).",
"access.reasonWord": "Motivo",
"access.youAdmin": "Tú (admin)",
"access.sendFailed": "No se pudo enviar",
"access.updateFailed": "No se pudo actualizar",
"review.title": "Solicitud de acceso",
"review.loadingTicket": "Cargando ticket…",
"review.notFound": "Ticket no encontrado",
"review.notFoundSub":
"Este enlace no existe. Revisa la URL de tu email o pide acceso desde el login.",
"review.adminNote": "Nota del admin:",
"review.acceptedHint":
"Tu cuenta fue creada — revisa tu email con la clave e inicia sesión.",
"review.closedHint":
"Ticket cerrado. Los emails no se leen — si hace falta, pide acceso de nuevo.",
"review.replyPh": "Defiende tu caso…",
"review.replyAria": "Responder a tu ticket",
"review.mailHint":
"Los emails son unidireccionales — responde aquí, no por email.",
"review.status.pending": "Pendiente de revisión",
"review.status.reviewing": "En revisión",
"review.status.accepted": "Aceptada",
"review.status.refused": "Rechazada",
"review.admin": "Admin",
"review.you": "Tú",
"review.requestTitle": "Solicitar acceso",
"review.requestIntro":
"Reserva un usuario y cuéntanos por qué quieres entrar. Un admin revisa cada solicitud — sigues el hilo en tu ticket y por email.",
"review.emailHint":
"Obligatorio — avisos y respuestas llegan aquí.",
"review.whyLabel": "¿Por qué darte acceso?",
"review.whyHint": "10+ caracteres. Esto abre tu ticket.",
"review.whyPh": "Quiero…",
"review.sendRequest": "Enviar solicitud",
"review.received": "Solicitud recibida",
"review.reserved":
"@{user} queda reservado mientras un admin lo revisa. Un email con tu ticket va en camino.",
"review.copyLink": "Copiar enlace",
"review.openTicket": "Abrir mi ticket",
"admin.editUserTitle": "Editar @{user}",
"admin.avatarUrl": "URL del avatar",
}; };
const fr: Dict = { const fr: Dict = {
@@ -873,7 +1149,7 @@ const fr: Dict = {
"center.lockedSub": "Connectez-vous en admin pour ouvrir le Centre admin.", "center.lockedSub": "Connectez-vous en admin pour ouvrir le Centre admin.",
"center.regTitle": "Inscription publique", "center.regTitle": "Inscription publique",
"center.regDesc": "center.regDesc":
"Affiche le bouton S'inscrire au login. Off = comptes admin uniquement.", "Affiche le bouton S'inscrire au login. Off = comptes admin uniquement. L'activer retire la liste d'attente ci-dessous.",
"center.accTitle": "Demandes d'accès (liste)", "center.accTitle": "Demandes d'accès (liste)",
"center.accDescOn": "Indispo avec l'inscription publique — elle la remplace.", "center.accDescOn": "Indispo avec l'inscription publique — elle la remplace.",
"center.accDescOff": "center.accDescOff":
@@ -915,6 +1191,144 @@ const fr: Dict = {
"app.deleteConvTitle": "Supprimer la conversation ?", "app.deleteConvTitle": "Supprimer la conversation ?",
"app.deleteConvMsg": "« {title} » et ses messages seront supprimés.", "app.deleteConvMsg": "« {title} » et ses messages seront supprimés.",
"settings.language": "Langue",
"settings.languageHint":
"Langue de l'interface. Aussitôt, sur cet appareil.",
"settings.security": "Sécurité",
"sec.accessKey": "Clé d'accès",
"sec.accessKeyHint":
"Faites tourner votre clé. Les anciennes sessions sont révoquées aussitôt — reconnectez-vous avec la neuve.",
"sec.rotate": "Faire tourner ma clé",
"sec.rotateConfirm": "Recliquez pour confirmer",
"sec.rotationFailed": "Rotation impossible",
"sec.totp": "Double facteur (TOTP)",
"sec.totpChecking": "Vérification…",
"sec.totpAddHint":
"Ajoutez un code à 6 chiffres de votre app en plus de votre clé.",
"sec.totpEnable": "Activer le double facteur",
"sec.totpSecretHint":
"Ajoutez ce secret à votre app (ou ouvrez le lien otpauth), puis entrez un code :",
"sec.totpConfirm": "Confirmer",
"sec.totpDisableLabel": "Désactiver avec un code actuel",
"sec.totpDisable": "Désactiver 2FA",
"sec.setupFailed": "Configuration impossible",
"sec.passkeys": "Passkeys",
"sec.passkeysHint":
"WebAuthn / authentificateurs système — requiert un RP serveur.",
"sec.danger": "Zone dangereuse",
"sec.dangerHint":
"Supprime votre compte avec chats, partages et sessions. Irréversible.",
"sec.deleteMe": "Supprimer mon compte",
"sec.deleteFailed": "Suppression impossible",
"sec.newKeyTitle": "Nouvelle clé d'accès",
"sec.newKeyHint":
"Copiez-la vite — plus affichée ensuite. Vos sessions sont révoquées.",
"sec.newKeyDone": "OK — me déconnecter",
"sec.deleteTitle": "Supprimer votre compte ?",
"sec.deleteMsg":
"Supprime définitivement votre compte avec conversations, partages et sessions. Irréversible.",
"sec.deleteForever": "Supprimer pour toujours",
"sec.avatarTooBig": "Fichier trop lourd (5 Mo max).",
"sec.avatarType": "Seules les images jpg, png ou webp sont acceptées.",
"center.tabAccess": "Accès",
"center.smtpHost": "Serveur",
"center.smtpPort": "Port",
"center.smtpUser": "Utilisateur",
"center.smtpPass": "Mot de passe",
"center.smtpFrom": "Expéditeur",
"center.smtpAppUrl": "URL de l'app",
"center.notSet": "(non défini)",
"center.noAuth": "(sans auth)",
"center.tlsImplicit": "TLS implicite (465)",
"center.tlsStarttls": "STARTTLS (587)",
"center.showPass": "Afficher le mot de passe SMTP",
"center.hidePass": "Masquer le mot de passe SMTP",
"center.hiddenPass": "mot de passe masqué",
"center.mailTestTitle": "Test de connexion",
"center.mailTestDesc":
"Vérifiez la connexion, puis envoyez un vrai mail de test à une adresse à vous.",
"center.verifyConn": "Vérifier la connexion",
"center.verifying": "Vérification…",
"center.sendTest": "Envoyer un mail de test",
"center.sendingMail": "Envoi…",
"center.testToAria": "Email de test",
"center.connOk": "Connexion OK — hôte SMTP joignable, auth acceptée.",
"center.verifyFailed": "Vérification échouée",
"center.testSent": "Mail de test envoyé à {to}{tail}.",
"center.mailSendFailed": "Envoi impossible",
"center.credsNote":
"Les identifiants vivent sur le serveur (voir docs/OPERATIONS.md), lecture seule ici. L'email se règle dans le profil ou à la création.",
"access.status.pending": "En attente",
"access.status.reviewing": "En examen",
"access.status.accepted": "Acceptée",
"access.status.refused": "Refusée",
"access.filter.all": "tous",
"access.filter.pending": "en attente",
"access.filter.reviewing": "en examen",
"access.filter.accepted": "acceptés",
"access.filter.refused": "refusés",
"access.refresh": "Actualiser",
"access.refreshAria": "Actualiser les demandes",
"access.empty": "Aucune demande. La liste est vide.",
"access.emptyFiltered": "Rien avec ce statut.",
"access.msgOne": "1 message",
"access.msgMany": "{n} messages",
"access.accountCreated": "Compte créé — clé unique, copiez-la :",
"access.replyPh": "Répondre (envoyé par email)…",
"access.replyAria": "Répondre au demandeur",
"access.reasonLabel": "Motif (envoyé avec la décision)",
"access.reasonPh": "Motif optionnel…",
"access.btnReviewing": "Examiner",
"access.btnAccept": "Accepter",
"access.btnRefuse": "Refuser",
"access.closedNote": "Ticket fermé ({status}).",
"access.reasonWord": "Motif",
"access.youAdmin": "Vous (admin)",
"access.sendFailed": "Envoi impossible",
"access.updateFailed": "Mise à jour impossible",
"review.title": "Demande d'accès",
"review.loadingTicket": "Chargement du ticket…",
"review.notFound": "Ticket introuvable",
"review.notFoundSub":
"Ce lien est inconnu. Vérifiez l'URL de votre email, ou refaites une demande depuis le login.",
"review.adminNote": "Note admin :",
"review.acceptedHint":
"Votre compte est créé — vérifiez votre email avec la clé, puis connectez-vous.",
"review.closedHint":
"Ticket fermé. Les emails ne sont pas lus — si besoin, refaites une demande.",
"review.replyPh": "Plaidez votre cas…",
"review.replyAria": "Répondre à votre ticket",
"review.mailHint":
"Les emails sont à sens unique — répondez ici, pas par email.",
"review.status.pending": "En attente d'examen",
"review.status.reviewing": "En cours d'examen",
"review.status.accepted": "Acceptée",
"review.status.refused": "Refusée",
"review.admin": "Admin",
"review.you": "Vous",
"review.requestTitle": "Demander l'accès",
"review.requestIntro":
"Réservez un pseudo et dites pourquoi vous voulez entrer. Un admin examine chaque demande — suivez le fil sur votre ticket et par email.",
"review.emailHint":
"Requis — avis et réponses arrivent ici.",
"review.whyLabel": "Pourquoi vous donner l'accès ?",
"review.whyHint": "10+ caractères. Ceci ouvre votre ticket.",
"review.whyPh": "Je veux…",
"review.sendRequest": "Envoyer la demande",
"review.received": "Demande reçue",
"review.reserved":
"@{user} est réservé pendant l'examen admin. Un email avec votre ticket est en route.",
"review.copyLink": "Copier le lien",
"review.openTicket": "Ouvrir mon ticket",
"admin.editUserTitle": "Modifier @{user}",
"admin.avatarUrl": "URL de l'avatar",
}; };
const ru: Dict = { const ru: Dict = {
@@ -1161,7 +1575,7 @@ const ru: Dict = {
"center.locked": "Только админы", "center.locked": "Только админы",
"center.lockedSub": "Войдите как админ, чтобы открыть центр.", "center.lockedSub": "Войдите как админ, чтобы открыть центр.",
"center.regTitle": "Открытая регистрация", "center.regTitle": "Открытая регистрация",
"center.regDesc": "Кнопка регистрации на входе. Выкл = только админ создаёт.", "center.regDesc": "Кнопка регистрации на входе. Выкл = только админ создаёт. Включение закрывает лист ожидания ниже.",
"center.accTitle": "Заявки (лист ожидания)", "center.accTitle": "Заявки (лист ожидания)",
"center.accDescOn": "Недоступно при открытой регистрации — она заменяет.", "center.accDescOn": "Недоступно при открытой регистрации — она заменяет.",
"center.accDescOff": "center.accDescOff":
@@ -1244,6 +1658,34 @@ const ru: Dict = {
"center.tabAccess": "Доступ", "center.tabAccess": "Доступ",
"center.smtpHost": "Хост",
"center.smtpPort": "Порт",
"center.smtpUser": "Пользователь",
"center.smtpPass": "Пароль",
"center.smtpFrom": "Отправитель",
"center.smtpAppUrl": "URL приложения",
"center.notSet": "(не задано)",
"center.noAuth": "(без auth)",
"center.tlsImplicit": "неявный TLS (465)",
"center.tlsStarttls": "STARTTLS (587)",
"center.showPass": "Показать пароль SMTP",
"center.hidePass": "Скрыть пароль SMTP",
"center.hiddenPass": "пароль скрыт",
"center.mailTestTitle": "Проверка связи",
"center.mailTestDesc":
"Проверьте соединение и отправьте тестовое письмо на свой адрес.",
"center.verifyConn": "Проверить соединение",
"center.verifying": "Проверяем…",
"center.sendTest": "Отправить тест",
"center.sendingMail": "Отправляем…",
"center.testToAria": "Адрес для теста",
"center.connOk": "Связь OK — SMTP доступен, auth принят.",
"center.verifyFailed": "Проверка не удалась",
"center.testSent": "Тест отправлен на {to}{tail}.",
"center.mailSendFailed": "Не отправилось",
"center.credsNote":
"Доступы живут в окружении сервера (см. docs/OPERATIONS.md), здесь только чтение. Email задаётся в профиле или при создании.",
"access.status.pending": "Ожидает", "access.status.pending": "Ожидает",
"access.status.reviewing": "На проверке", "access.status.reviewing": "На проверке",
"access.status.accepted": "Принята", "access.status.accepted": "Принята",
@@ -1558,7 +2000,7 @@ const it: Dict = {
"center.locked": "Solo admin", "center.locked": "Solo admin",
"center.lockedSub": "Accedi come admin per aprire il Centro.", "center.lockedSub": "Accedi come admin per aprire il Centro.",
"center.regTitle": "Registrazione pubblica", "center.regTitle": "Registrazione pubblica",
"center.regDesc": "Mostra Registrati al login. Off = solo account admin.", "center.regDesc": "Mostra Registrati al login. Off = solo account admin. Attivarlo ritira la wishlist qui sotto.",
"center.accTitle": "Richieste di accesso (lista)", "center.accTitle": "Richieste di accesso (lista)",
"center.accDescOn": "center.accDescOn":
"Non disponibile con registrazione pubblica — la sostituisce.", "Non disponibile con registrazione pubblica — la sostituisce.",
@@ -1644,6 +2086,34 @@ const it: Dict = {
"center.tabAccess": "Accesso", "center.tabAccess": "Accesso",
"center.smtpHost": "Host",
"center.smtpPort": "Porta",
"center.smtpUser": "Utente",
"center.smtpPass": "Password",
"center.smtpFrom": "Mittente",
"center.smtpAppUrl": "URL dell'app",
"center.notSet": "(non impostato)",
"center.noAuth": "(senza auth)",
"center.tlsImplicit": "TLS implicito (465)",
"center.tlsStarttls": "STARTTLS (587)",
"center.showPass": "Mostra password SMTP",
"center.hidePass": "Nascondi password SMTP",
"center.hiddenPass": "password nascosta",
"center.mailTestTitle": "Test di connessione",
"center.mailTestDesc":
"Verifica la connessione e invia una vera mail di test a un tuo indirizzo.",
"center.verifyConn": "Verifica connessione",
"center.verifying": "Verifica…",
"center.sendTest": "Invia mail di test",
"center.sendingMail": "Invio…",
"center.testToAria": "Email di test",
"center.connOk": "Connessione OK — host SMTP raggiungibile, auth accettata.",
"center.verifyFailed": "Verifica fallita",
"center.testSent": "Mail di test inviata a {to}{tail}.",
"center.mailSendFailed": "Invio fallito",
"center.credsNote":
"Le credenziali vivono sul server (vedi docs/OPERATIONS.md), qui sola lettura. L'email si imposta nel profilo o alla creazione.",
"access.status.pending": "In attesa", "access.status.pending": "In attesa",
"access.status.reviewing": "In revisione", "access.status.reviewing": "In revisione",
"access.status.accepted": "Accettata", "access.status.accepted": "Accettata",
@@ -1953,7 +2423,7 @@ const pt: Dict = {
"center.locked": "Só admins", "center.locked": "Só admins",
"center.lockedSub": "Entra como admin para abrir o Centro.", "center.lockedSub": "Entra como admin para abrir o Centro.",
"center.regTitle": "Registo público", "center.regTitle": "Registo público",
"center.regDesc": "Mostra Registar no login. Off = só contas do admin.", "center.regDesc": "Mostra Registar no login. Off = só contas do admin. Ligar aposenta a lista abaixo.",
"center.accTitle": "Pedidos de acesso (lista)", "center.accTitle": "Pedidos de acesso (lista)",
"center.accDescOn": "Indisponível com registo público — substitui.", "center.accDescOn": "Indisponível com registo público — substitui.",
"center.accDescOff": "Mostra Pedir acesso no login. Triagem na aba Acesso.", "center.accDescOff": "Mostra Pedir acesso no login. Triagem na aba Acesso.",
@@ -2035,6 +2505,34 @@ const pt: Dict = {
"center.tabAccess": "Acesso", "center.tabAccess": "Acesso",
"center.smtpHost": "Servidor",
"center.smtpPort": "Porta",
"center.smtpUser": "Utilizador",
"center.smtpPass": "Palavra-passe",
"center.smtpFrom": "Remetente",
"center.smtpAppUrl": "URL da app",
"center.notSet": "(não definido)",
"center.noAuth": "(sem auth)",
"center.tlsImplicit": "TLS implícito (465)",
"center.tlsStarttls": "STARTTLS (587)",
"center.showPass": "Mostrar palavra-passe SMTP",
"center.hidePass": "Ocultar palavra-passe SMTP",
"center.hiddenPass": "palavra-passe oculta",
"center.mailTestTitle": "Teste de ligação",
"center.mailTestDesc":
"Verifica a ligação e envia um mail real para um endereço teu.",
"center.verifyConn": "Verificar ligação",
"center.verifying": "A verificar…",
"center.sendTest": "Enviar mail de teste",
"center.sendingMail": "A enviar…",
"center.testToAria": "Email de teste",
"center.connOk": "Ligação OK — SMTP acessível, auth aceite.",
"center.verifyFailed": "Verificação falhou",
"center.testSent": "Mail de teste enviado para {to}{tail}.",
"center.mailSendFailed": "Falha ao enviar",
"center.credsNote":
"Credenciais vivem no servidor (ver docs/OPERATIONS.md), só leitura aqui. Email define-se no perfil ou criação.",
"access.status.pending": "Pendente", "access.status.pending": "Pendente",
"access.status.reviewing": "Em revisão", "access.status.reviewing": "Em revisão",
"access.status.accepted": "Aceite", "access.status.accepted": "Aceite",