Files
webmail-client/frontend/src/routes/settings/Settings.tsx
T
lukasandClaude Opus 5.5 1f2e539eb2 Compose: spelling and grammar check with LanguageTool
A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by
`make up`) checks what the user writes in the compose window, through
POST /api/spellcheck — the browser never talks to the engine directly.

- Marks are CSS highlights over the text, never written into the message: red
  wavy for spelling, blue for grammar/style. Clicking one shows the message,
  up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore.
  The popup, like the link popup, opens above its anchor when there is no
  room below.
- Checked after a pause in typing; the signature and quoted original are
  skipped; marks on words just edited drop at once; a check overtaken by a
  newer one, by turning checking off, or by closing the window is discarded.
- With "auto", text is checked in chunks of lines, each in its own detected
  language — LanguageTool settles on one language per request, so a mixed
  Polish/English message had every English word flagged as a Polish typo.
- Settings > Appearance: on/off and language (Auto-detect or any of the
  engine's languages); the compose window's More menu overrides it for one
  message. The browser's own spellcheck steps aside while ours runs, and comes
  back when it is off or the server has no engine (503 from the endpoint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:51:31 +02:00

1796 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { CalendarOut, ReminderPref } from "../../lib/types";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot";
import { usePluginSettingsSections } from "../../plugins/manifest";
import { AccountWizard } from "../wizard/AccountWizard";
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
type Section = (typeof SECTIONS)[number];
export function Settings() {
const t = useT();
const params = useParams();
const [searchParams] = useSearchParams();
// Where the back button returns to — set by whoever opened Settings (Mail or a
// plugin like Calendar via ?from=). Only accept internal paths; default to Mail.
const navigate = useNavigate();
const rawFrom = searchParams.get("from") ?? "";
const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
const fromQuery = `?from=${encodeURIComponent(from)}`;
// The add-account wizard renders inside this layout (route /settings/accounts/new),
// with the Accounts nav item highlighted.
const addingAccount = useLocation().pathname.endsWith("/accounts/new");
// Plugins contributing `settings.page` each get their own nav entry, keyed by
// plugin name, rather than being stacked at the bottom of Accounts.
const pluginSections = usePluginSettingsSections();
const isPluginSection = pluginSections.some((b) => b.plugin === params.section);
const section = (
addingAccount
? "accounts"
: SECTIONS.includes(params.section as Section) || isPluginSection
? params.section
: "profile"
) as Section;
return (
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link to={from} className="text-sm text-app-accent">
← {t("nav.back")}
</Link>
<span className="font-semibold">{t("settings.title")}</span>
</header>
<div className="flex min-h-0 flex-1">
<nav className="w-52 shrink-0 border-r border-app-border p-2">
{SECTIONS.map((s) => (
<Link
key={s}
to={`/settings/${s}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
}`}
>
{t(`settings.${s}` as const)}
</Link>
))}
{pluginSections.map((b) => (
<Link
key={b.plugin}
to={`/settings/${b.plugin}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${
section === (b.plugin as Section)
? "bg-app-active font-medium text-app-fg"
: "hover:bg-app-hover"
}`}
>
{b.title ?? b.plugin}
</Link>
))}
</nav>
<main className="flex-1 overflow-y-auto p-6">
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
{!addingAccount && section === "profile" && <ProfileSection />}
{!addingAccount && section === "security" && <SecuritySection />}
{!addingAccount && section === "accounts" && <AccountsSection />}
{section === "calendars" && <CalendarsSection />}
{section === "appearance" && <AppearanceSection />}
{section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
per plugin, selected from the nav like any built-in section. */}
{isPluginSection && (
<div className="max-w-5xl">
<PluginSlot name="settings.page" plugin={section} />
</div>
)}
</main>
</div>
</div>
);
}
function ProfileSection() {
const t = useT();
const { data } = useMe();
return (
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
<RecoveryEmailRow current={data?.recovery_email ?? null} />
</section>
);
}
// Whoever controls the recovery address can take the account over with a reset
// link, so adding, changing or removing it asks for the current password.
function RecoveryEmailRow({ current }: { current: string | null }) {
const t = useT();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const save = useMutation({
mutationFn: () => api.setRecoveryEmail(password, email.trim() || null),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
setEditing(false);
setPassword("");
},
});
if (!editing) {
return (
<div className="border-b border-app-border py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.recoveryEmail")}</span>
<span className="flex items-center gap-3">
<span>{current ?? "—"}</span>
<button
className="rounded border border-app-border px-2.5 py-1 text-app-fg hover:bg-app-hover"
onClick={() => {
setEmail(current ?? "");
setPassword("");
save.reset();
setEditing(true);
}}
>
{current ? t("settings.change") : t("settings.add")}
</button>
</span>
</div>
<p className="mt-1 text-xs text-app-faint">{t("settings.recoveryEmailHint")}</p>
</div>
);
}
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
return (
<form
className="space-y-2 rounded-lg border border-app-border p-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
<label className="block space-y-1">
<span className="text-app-muted">{t("settings.recoveryEmailNew")}</span>
<input
className={field}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
autoFocus
/>
{current && (
<span className="block text-xs text-app-faint">{t("settings.recoveryEmailRemoveHint")}</span>
)}
</label>
<label className="block space-y-1">
<span className="text-app-muted">{t("settings.currentPassword")}</span>
<TypedPasswordInput
className={field}
value={password}
onChange={setPassword}
hint={t("settings.typePassword")}
/>
</label>
{save.error instanceof ApiError && <p className="text-red-500">{save.error.message}</p>}
<div className="flex justify-end gap-2">
<button type="button" className="px-2 text-app-muted" onClick={() => setEditing(false)}>
{t("common.cancel")}
</button>
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={save.isPending || !password}
>
{save.isPending ? t("settings.saving") : t("common.save")}
</button>
</div>
</form>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{label}</span>
<span>{value}</span>
</div>
);
}
function SecuritySection() {
const t = useT();
const queryClient = useQueryClient();
const { data: me } = useMe();
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
const [code, setCode] = useState("");
const [disabling, setDisabling] = useState(false);
const [disablePw, setDisablePw] = useState("");
const [error, setError] = useState("");
const sessions = useQuery({ queryKey: ["sessions"], queryFn: api.sessions });
const enabled = Boolean(me?.totp_enabled);
const startSetup = async () => {
const res = await api.totpSetup();
setSetup({ secret: res.secret, uri: res.provisioning_uri });
};
const confirm = async () => {
setError("");
try {
await api.totpEnable(code);
setSetup(null);
queryClient.invalidateQueries({ queryKey: ["me"] });
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
}
};
const disable = async () => {
setError("");
try {
await api.totpDisable(disablePw);
setDisabling(false);
setDisablePw("");
queryClient.invalidateQueries({ queryKey: ["me"] });
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
}
};
const revoke = useMutation({
mutationFn: (sid: string) => api.revokeSession(sid),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
});
return (
<section className="max-w-4xl space-y-6">
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
{enabled ? (
<div className="space-y-2">
<p className="text-sm text-emerald-500">{t("settings.twoFactorOn")}</p>
{disabling ? (
<div className="space-y-2 rounded-lg border border-app-border p-3">
<p className="text-sm text-app-muted">{t("settings.disable2faPrompt")}</p>
<div className="flex gap-2">
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
type="password"
value={disablePw}
onChange={(e) => setDisablePw(e.target.value)}
autoFocus
/>
<button className="rounded bg-red-600 px-3 py-1 text-sm text-white" onClick={disable}>
{t("settings.disable")}
</button>
<button className="px-2 text-sm text-app-muted" onClick={() => setDisabling(false)}>
{t("common.cancel")}
</button>
</div>
</div>
) : (
<button
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
onClick={() => setDisabling(true)}
>
{t("settings.disable2fa")}
</button>
)}
</div>
) : setup ? (
<div className="space-y-3 rounded-lg border border-app-border p-3">
<p className="text-sm text-app-muted">{t("settings.scanQr")}</p>
<div className="flex gap-4">
<div className="shrink-0 rounded bg-white p-2 ring-1 ring-gray-200">
<QRCodeSVG value={setup.uri} size={160} level="M" />
</div>
<div className="min-w-0 space-y-1">
<p className="text-xs text-app-muted">
{t("settings.secret")}:
<br />
<code className="break-all">{setup.secret}</code>
</p>
<details className="text-xs text-app-faint">
<summary className="cursor-pointer">otpauth URI</summary>
<div className="mt-1 break-all rounded bg-app-surface px-2 py-1">{setup.uri}</div>
</details>
</div>
</div>
<div className="flex gap-2">
<input
className="w-32 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<button className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg" onClick={confirm}>
{t("settings.confirm")}
</button>
</div>
</div>
) : (
<button className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg" onClick={startSetup}>
{t("settings.enable2fa")}
</button>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.sessions")}</h2>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(sessions.data ?? []).map((s) => (
<li key={s.sid} className="flex items-center justify-between px-3 py-2 text-sm">
<span>
{s.ua || "session"}{" "}
{s.current && <span className="text-xs text-emerald-500">({t("settings.thisDevice")})</span>}
<span className="block text-xs text-app-faint">
{new Date(s.created).toLocaleString()}
</span>
</span>
{!s.current && (
<button className="text-xs text-red-500" onClick={() => revoke.mutate(s.sid)}>
{t("settings.revoke")}
</button>
)}
</li>
))}
</ul>
</div>
</section>
);
}
function AccountsSection() {
const t = useT();
const navigate = useNavigate();
const queryClient = useQueryClient();
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
null,
);
// Which account is being deleted — drives the blocking spinner overlay until the
// (slow) server cascade finishes and the refetched list no longer includes it.
const [deletingId, setDeletingId] = useState<string | null>(null);
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setDeletingId(null);
},
});
const downloadAll = useMutation({
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
const close = () => {
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["accounts"] });
};
return (
<section className="max-w-5xl space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
<button
className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg"
onClick={() => navigate("/settings/accounts/new")}
>
{t("settings.addAccount")}
</button>
</div>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(accounts.data ?? []).map((a) => (
<li key={a.id} className="px-3 py-2 text-sm">
{editing?.id === a.id && editing.mode === "account" ? (
<AccountEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : editing?.id === a.id && editing.mode === "signature" ? (
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : (
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">
{t("settings.syncState", { state: a.state })}
</span>
{a.sync_window !== "all" && (
<>
<span className="text-xs text-app-faint">·</span>
<span className="text-xs text-app-faint">
{t("settings.syncedPartially")}
</span>
</>
)}
</span>
<span className="flex items-center gap-3">
{a.sync_window !== "all" && (
<button
className="text-xs text-app-accent disabled:opacity-50"
disabled={downloadAll.isPending || a.state === "backfilling"}
onClick={() => downloadAll.mutate(a.id)}
>
{t("settings.downloadAllMail")}
</button>
)}
<button
className="text-xs text-app-accent"
onClick={() => setEditing({ id: a.id, mode: "signature" })}
>
{t("settings.editSignature")}
</button>
<button
className="text-xs text-app-accent"
onClick={() => setEditing({ id: a.id, mode: "account" })}
>
{t("settings.configureAccount")}
</button>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) {
setDeletingId(a.id);
remove.mutate(a.id);
}
}}
>
{t("common.remove")}
</button>
</span>
</div>
)}
</li>
))}
</ul>
{deletingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 rounded-xl bg-app-elevated px-8 py-6 shadow-2xl">
<span
className="h-8 w-8 animate-spin rounded-full border-2 border-app-border border-t-app-accent"
role="status"
aria-label={t("settings.removingAccount")}
/>
<span className="text-sm text-app-muted">{t("settings.removingAccount")}</span>
</div>
</div>
)}
</section>
);
}
function AccountEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) });
const [displayName, setDisplayName] = useState("");
const [color, setColor] = useState("#888888");
const [showServer, setShowServer] = useState(false);
const [imapHost, setImapHost] = useState("");
const [imapPort, setImapPort] = useState("993");
const [imapTls, setImapTls] = useState("ssl");
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("465");
const [smtpTls, setSmtpTls] = useState("ssl");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
const d = detail.data;
if (!d) return;
setDisplayName(d.display_name);
setColor(d.color);
setImapHost(String(d.imap_config.host ?? ""));
setImapPort(String(d.imap_config.port ?? "993"));
setImapTls(String(d.imap_config.tls ?? "ssl"));
setSmtpHost(String(d.smtp_config.host ?? ""));
setSmtpPort(String(d.smtp_config.port ?? "465"));
setSmtpTls(String(d.smtp_config.tls ?? "ssl"));
}, [detail.data]);
const save = useMutation({
mutationFn: () => {
const patch: Parameters<typeof api.updateAccount>[1] = {
display_name: displayName,
color,
};
if (showServer) {
patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" };
patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" };
patch.password = password;
}
return api.updateAccount(accountId, patch);
},
onSuccess: (updated) => {
queryClient.setQueryData(["account", accountId], updated);
onSaved();
},
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
const canSave = !save.isPending && (!showServer || password.length > 0);
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
className="h-8 w-8 shrink-0 cursor-pointer rounded border border-app-border bg-transparent p-0.5"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t("settings.color")}
/>
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1"
placeholder={t("wizard.displayName")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
{!showServer ? (
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
{t("settings.serverSettings")}
</button>
) : (
<div className="space-y-2 rounded border border-app-border p-2">
<p className="text-xs text-app-faint">{t("settings.serverSettingsHint")}</p>
<ServerRow
label={t("wizard.imapServer")}
host={imapHost}
port={imapPort}
tls={imapTls}
onHost={setImapHost}
onPort={setImapPort}
onTls={setImapTls}
/>
<ServerRow
label={t("wizard.smtpServer")}
host={smtpHost}
port={smtpPort}
tls={smtpTls}
onHost={setSmtpHost}
onPort={setSmtpPort}
onTls={setSmtpTls}
/>
<input
className="w-full rounded border border-app-border bg-app-surface px-2 py-1"
type="password"
placeholder={t("wizard.accountPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={!canSave}
onClick={() => {
setError("");
save.mutate();
}}
>
{save.isPending ? t("common.saving") : t("common.save")}
</button>
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
{t("common.cancel")}
</button>
</div>
</div>
);
}
function SignatureEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const detail = useQuery({
queryKey: ["account", accountId],
queryFn: () => api.accountDetail(accountId),
});
const [signature, setSignature] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (detail.data) setSignature(detail.data.signature);
}, [detail.data]);
const save = useMutation({
mutationFn: () => api.updateAccount(accountId, { signature }),
// Keep the cached detail current: the editor seeds from it only once on mount,
// so a stale entry would reopen the editor with the old (e.g. empty) signature.
onSuccess: (updated) => {
queryClient.setQueryData(["account", accountId], updated);
onSaved();
},
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
return (
<div className="space-y-2">
<div className="text-xs text-app-muted">
{t("settings.signature")} — {detail.data.email}
</div>
<div className="flex flex-col overflow-hidden rounded border border-app-border">
<RichTextEditor
initialHtml={detail.data.signature}
onInput={setSignature}
minHeight="min-h-[160px]"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={save.isPending}
onClick={() => {
setError("");
save.mutate();
}}
>
{save.isPending ? t("common.saving") : t("common.save")}
</button>
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
{t("common.cancel")}
</button>
</div>
</div>
);
}
function ServerRow({
label,
host,
port,
tls,
onHost,
onPort,
onTls,
}: {
label: string;
host: string;
port: string;
tls: string;
onHost: (v: string) => void;
onPort: (v: string) => void;
onTls: (v: string) => void;
}) {
const t = useT();
return (
<div className="flex items-center gap-1.5">
<span className="w-24 shrink-0 text-xs text-app-muted">{label}</span>
<input
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.host")}
value={host}
onChange={(e) => onHost(e.target.value)}
/>
<input
className="w-16 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.port")}
value={port}
onChange={(e) => onPort(e.target.value)}
/>
<select
className="rounded border border-app-border bg-app-surface px-1 py-1 text-sm"
value={tls}
onChange={(e) => onTls(e.target.value)}
aria-label={t("wizard.security")}
>
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
</select>
</div>
);
}
// Spelling and grammar check in the compose window (LanguageTool on the server).
function SpellcheckSetting() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
// Also tells us whether this server has a checker at all (503 when not).
const languages = useQuery({
queryKey: ["spellcheck-languages"],
queryFn: api.spellcheckLanguages,
retry: false,
staleTime: 3_600_000,
});
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
});
const on = settings.data?.spellcheck_enabled ?? true;
const unavailable = languages.isError;
return (
<div className="space-y-2 border-b border-app-border py-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.spellcheck")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{([true, false] as const).map((value) => (
<button
key={String(value)}
disabled={unavailable || patch.isPending}
onClick={() => patch.mutate({ spellcheck_enabled: value })}
className={`rounded px-3 py-1 disabled:opacity-50 ${
on === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
}`}
>
{t(value ? "settings.on" : "settings.off")}
</button>
))}
</div>
</div>
{unavailable ? (
<p className="text-xs text-app-faint">{t("settings.spellcheckUnavailable")}</p>
) : (
<>
<label className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.spellcheckLanguage")}</span>
<select
className="max-w-[60%] rounded border border-app-border bg-app-surface px-2 py-1 disabled:opacity-50"
disabled={!on || patch.isPending}
value={settings.data?.spellcheck_language ?? "auto"}
onChange={(e) => patch.mutate({ spellcheck_language: e.target.value })}
>
<option value="auto">{t("settings.spellcheckAuto")}</option>
{(languages.data ?? []).map((l) => (
<option key={l.code} value={l.code}>
{l.name}
</option>
))}
</select>
</label>
</>
)}
</div>
);
}
function AppearanceSection() {
const t = useT();
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
return (
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.theme")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{(["light", "dark", "system"] as const).map((value) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`rounded px-3 py-1 ${
theme === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
}`}
>
{t(`theme.${value}` as const)}
</button>
))}
</div>
</div>
<div className="space-y-2 border-b border-app-border py-3 text-sm">
<span className="text-app-muted">{t("display.readingPane")}</span>
<div className="max-w-md">
<ReadingPanePicker />
</div>
{/* Saved per device, like the theme: the right choice depends on the screen. */}
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
</div>
<SpellcheckSetting />
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.threadOrder")}</span>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1"
value={settings.data?.thread_order ?? "asc"}
onChange={(e) => patch.mutate({ thread_order: e.target.value as "asc" | "desc" })}
>
<option value="asc">{t("mail.orderOldest")}</option>
<option value="desc">{t("mail.orderNewest")}</option>
</select>
</label>
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.timeFormat")}</span>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1"
value={settings.data?.time_format ?? "24"}
onChange={(e) => {
const fmt = e.target.value as "12" | "24";
setTimeFormat(fmt);
patch.mutate({ time_format: fmt });
}}
>
<option value="24">{t("settings.timeFormat24")}</option>
<option value="12">{t("settings.timeFormat12")}</option>
</select>
</label>
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
</section>
);
}
const DEFAULT_JITSI = "https://meet.jit.si/";
function IntegrationsSection() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const [jitsi, setJitsi] = useState<string | null>(null);
const value = jitsi ?? settings.data?.jitsi_server ?? "";
return (
<section className="max-w-4xl space-y-6">
<h2 className="text-lg font-semibold">{t("settings.integrations")}</h2>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("settings.jitsi")}</h3>
<p className="text-sm text-app-muted">{t("settings.jitsiHint")}</p>
<input
type="url"
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={DEFAULT_JITSI}
value={value}
onChange={(e) => setJitsi(e.target.value)}
onBlur={(e) => patch.mutate({ jitsi_server: e.target.value.trim() })}
/>
</div>
</section>
);
}
const CAL_COLORS: [string, string][] = [
["Blue", "#3b82f6"],
["Green", "#22c55e"],
["Red", "#ef4444"],
["Orange", "#f97316"],
["Purple", "#a855f7"],
["Teal", "#14b8a6"],
["Pink", "#ec4899"],
["Gray", "#6b7280"],
];
function BellIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
);
}
// A compact colour picker showing circular swatches only (no text).
function ColorSelect({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
const [open, setOpen] = useState(false);
const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value)
? CAL_COLORS
: [["Custom", value], ...CAL_COLORS];
return (
<div className="relative inline-block">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2.5 py-2"
aria-label="Select colour"
>
<span className="inline-block h-5 w-5 rounded-full" style={{ background: value }} />
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-app-muted">
<path
fillRule="evenodd"
d="M5.2 7.5 10 12.3l4.8-4.8 1.2 1.2-6 6-6-6z"
clipRule="evenodd"
/>
</svg>
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 z-20 mt-1 grid w-48 grid-cols-4 gap-3 rounded-lg border border-app-border bg-app-surface p-3 shadow-lg">
{palette.map(([name, hex]) => (
<button
key={hex}
type="button"
title={name}
onClick={() => {
onChange(hex);
setOpen(false);
}}
className={`h-6 w-6 justify-self-center rounded-full ring-offset-2 ring-offset-app-surface ${
value === hex ? "ring-2 ring-app-fg" : ""
}`}
style={{ background: hex }}
/>
))}
</div>
</>
)}
</div>
);
}
function ConfirmDialog({
title,
message,
confirmLabel,
onConfirm,
onCancel,
}: {
title: string;
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const t = useT();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-lg border border-app-border bg-app-surface p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold">{title}</h3>
<p className="mt-2 text-sm text-app-muted">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<button
className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90"
onClick={onCancel}
>
{t("common.cancel")}
</button>
<button
className="rounded bg-red-500 px-4 py-1.5 text-sm font-medium text-white hover:opacity-90"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
function SettingsToggle({
checked,
onChange,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
checked ? "bg-app-accent" : "bg-app-hover"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
const TZ_RAW: string[] = (() => {
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
try {
return intl.supportedValuesOf ? intl.supportedValuesOf("timeZone") : [];
} catch {
return [];
}
})();
const TZ_ZONES = TZ_RAW.length
? TZ_RAW
: ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"];
// The GMT offset code (e.g. "GMT+2") for a zone, as shown next to its name.
function tzGmt(zone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "shortOffset",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
} catch {
return "";
}
}
function tzLongName(zone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "long",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? zone;
} catch {
return zone;
}
}
// [zone, "Long Name (GMT+2)"] sorted by offset then label, computed once.
const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => {
const gmt = tzGmt(z);
return [z, gmt ? `${tzLongName(z)} (${gmt})` : tzLongName(z)];
}).sort((a, b) => a[1].localeCompare(b[1]));
const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880];
function reminderText(m: number): string {
if (m === 0) return "At time of event";
if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`;
if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`;
return `${m} minutes before`;
}
function CalendarsSection() {
const t = useT();
const [searchParams] = useSearchParams();
const [tab, setTab] = useState<"calendars" | "options">("calendars");
const [view, setView] = useState<CalendarOut | "new" | null>(
searchParams.get("create") === "1" ? "new" : null,
);
const calendars = useQuery({ queryKey: ["calendars"], queryFn: api.calendars });
const list = calendars.data ?? [];
return (
<section className="max-w-4xl">
<div className="mb-6 flex gap-6 border-b border-app-border text-sm">
{(["calendars", "options"] as const).map((tb) => (
<button
key={tb}
onClick={() => {
setTab(tb);
setView(null);
}}
className={`-mb-px border-b-2 pb-2 ${
tab === tb
? "border-app-accent font-semibold text-app-fg"
: "border-transparent text-app-muted hover:text-app-fg"
}`}
>
{t(tb === "calendars" ? "settings.myCalendars" : "settings.calendarOptions")}
</button>
))}
</div>
{tab === "options" ? (
<CalendarOptions calendars={list} />
) : view ? (
<CalendarForm calendar={view === "new" ? null : view} onBack={() => setView(null)} />
) : (
<CalendarList calendars={list} onCreate={() => setView("new")} onEdit={setView} />
)}
</section>
);
}
function CalendarList({
calendars,
onCreate,
onEdit,
}: {
calendars: CalendarOut[];
onCreate: () => void;
onEdit: (c: CalendarOut) => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const [subUrl, setSubUrl] = useState("");
const subscribe = useMutation({
mutationFn: () => api.subscribeCalendar({ url: subUrl.trim() }),
onSuccess: () => {
setSubUrl("");
queryClient.invalidateQueries({ queryKey: ["calendars"] });
},
});
const [removeSub, setRemoveSub] = useState<CalendarOut | null>(null);
const removeCal = useMutation({
mutationFn: (id: string) => api.deleteCalendar(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }),
});
const subscriptions = calendars.filter((c) => c.subscription_url !== null);
const accounts: [string, string][] = [
["Google", "google"],
["iCloud", "icloud"],
["CalDAV", "caldav"],
];
// CalDAV account connect (iCloud / generic). Google needs OAuth — left disabled.
const davAccounts = useQuery({ queryKey: ["dav-accounts"], queryFn: api.davAccounts });
const [connecting, setConnecting] = useState<"icloud" | "caldav" | null>(null);
const [davUser, setDavUser] = useState("");
const [davPass, setDavPass] = useState("");
const [davUrl, setDavUrl] = useState("");
const refreshCalendars = () => {
queryClient.invalidateQueries({ queryKey: ["calendars"] });
queryClient.invalidateQueries({ queryKey: ["dav-accounts"] });
};
const closeConnect = () => {
setConnecting(null);
setDavUser("");
setDavPass("");
setDavUrl("");
};
const connect = useMutation({
mutationFn: () =>
api.davConnect({
provider: connecting ?? "caldav",
server_url: davUrl.trim(),
username: davUser.trim(),
password: davPass,
}),
onSuccess: () => {
closeConnect();
refreshCalendars();
},
});
const disconnect = useMutation({
mutationFn: (accountId: string) => api.davDeleteAccount(accountId),
onSuccess: refreshCalendars,
});
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">{t("settings.calendars")}</h2>
<button
className="flex items-center gap-2 rounded-lg bg-app-accent px-4 py-2 text-sm font-medium text-app-accent-fg hover:opacity-90"
onClick={onCreate}
>
<span className="text-lg leading-none">+</span> {t("settings.createCalendar")}
</button>
<div className="border-t border-app-border">
{calendars
.filter((c) => c.subscription_url === null)
.map((c) => (
<div key={c.id} className="flex items-center gap-3 border-b border-app-border py-3">
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ background: c.color }}
/>
<button className="min-w-0 flex-1 text-left" onClick={() => onEdit(c)}>
<div className="font-medium">{c.name}</div>
<div className="text-sm text-app-muted">
{t("settings.onlyYou")}{" "}
<a
href={api.exportCalendarUrl(c.id)}
onClick={(e) => e.stopPropagation()}
className="text-app-accent hover:underline"
>
{t("settings.export")}
</a>
</div>
</button>
<button
className="px-1 text-[42px] leading-none text-app-muted hover:text-app-fg"
onClick={() => onEdit(c)}
aria-label={t("settings.editCalendar")}
>
›
</button>
</div>
))}
</div>
<div className="pt-4">
<h3 className="text-base font-semibold">{t("settings.addAccount")}</h3>
<p className="mt-1 text-sm text-app-muted">{t("settings.addAccountHint")}</p>
{(davAccounts.data ?? []).length > 0 && (
<div className="mt-3 space-y-2">
{(davAccounts.data ?? []).map((a) => (
<div
key={a.account_id}
className="flex items-center gap-3 rounded-lg border border-app-border p-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{a.username || a.provider}</div>
<div className="text-xs text-app-muted">
{a.provider === "icloud" ? "iCloud" : "CalDAV"} ·{" "}
{t("settings.calendarCount", { count: a.calendars.length })}
</div>
</div>
<button
className="text-xs text-red-500"
disabled={disconnect.isPending}
onClick={() => disconnect.mutate(a.account_id)}
>
{t("common.remove")}
</button>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{accounts.map(([label, key]) => (
<button
key={label}
disabled={key === "google"}
title={key === "google" ? t("settings.comingSoon") : undefined}
onClick={() => key !== "google" && setConnecting(key as "icloud" | "caldav")}
className={`rounded-lg border border-app-border px-4 py-2 text-sm ${
key === "google" ? "opacity-60" : "hover:bg-app-hover"
}`}
>
{label}
</button>
))}
</div>
{connecting && (
<div className="mt-3 max-w-md space-y-2 rounded-lg border border-app-border p-4">
<div className="text-sm font-medium">
{t("settings.connectTitle", { provider: connecting === "icloud" ? "iCloud" : "CalDAV" })}
</div>
{connecting === "caldav" && (
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={t("settings.davServerUrl")}
value={davUrl}
onChange={(e) => setDavUrl(e.target.value)}
/>
)}
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={connecting === "icloud" ? t("settings.appleId") : t("settings.username")}
value={davUser}
onChange={(e) => setDavUser(e.target.value)}
autoFocus
/>
<input
type="password"
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={
connecting === "icloud" ? t("settings.appPassword") : t("settings.password")
}
value={davPass}
onChange={(e) => setDavPass(e.target.value)}
/>
{connecting === "icloud" && (
<p className="text-xs text-app-faint">{t("settings.icloudHint")}</p>
)}
<div className="flex gap-2 pt-1">
<button
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg disabled:opacity-50"
disabled={
connect.isPending ||
!davUser.trim() ||
!davPass ||
(connecting === "caldav" && !davUrl.trim())
}
onClick={() => connect.mutate()}
>
{connect.isPending ? t("settings.connecting") : t("settings.connect")}
</button>
<button className="px-3 py-2 text-sm text-app-muted" onClick={closeConnect}>
{t("common.cancel")}
</button>
</div>
{connect.isError && (
<p className="text-sm text-red-500">
{(() => {
const code =
connect.error instanceof ApiError ? connect.error.message : "";
const keys = {
auth_failed: "settings.connectAuthFailed",
private_host: "settings.connectPrivateHost",
unreachable: "settings.connectUnreachable",
no_calendars: "settings.connectNoCalendars",
server_url_required: "settings.connectNeedsUrl",
} as const;
const key = keys[code as keyof typeof keys];
return t(key ?? "settings.connectError");
})()}
</p>
)}
</div>
)}
</div>
<div className="pt-4">
<h3 className="text-base font-semibold">{t("settings.subscriptions")}</h3>
<p className="mt-1 text-sm text-app-muted">{t("settings.subscriptionsHint")}</p>
{subscriptions.length > 0 && (
<div className="mt-3 border-t border-app-border">
{subscriptions.map((c) => (
<div key={c.id} className="flex items-center gap-3 border-b border-app-border py-3">
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ background: c.color }}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">{c.name}</div>
<div className="truncate text-sm text-app-muted" title={c.subscription_url ?? ""}>
{c.subscription_url}
</div>
</div>
<button
className="rounded px-3 py-1 text-sm text-red-500 hover:bg-app-hover"
onClick={() => setRemoveSub(c)}
>
{t("common.remove")}
</button>
</div>
))}
</div>
)}
<div className="mt-3 flex items-center gap-2">
<input
type="url"
placeholder={t("settings.subscribeUrl")}
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-3 py-2 text-sm outline-none focus:border-app-accent"
value={subUrl}
onChange={(e) => setSubUrl(e.target.value)}
/>
<button
className="shrink-0 rounded bg-app-accent px-4 py-2 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!subUrl.trim() || subscribe.isPending}
onClick={() => subscribe.mutate()}
>
{t("settings.subscribe")}
</button>
</div>
{subscribe.isError && (
<p className="mt-2 text-sm text-red-500">{t("settings.subscribeError")}</p>
)}
</div>
{removeSub && (
<ConfirmDialog
title={t("settings.removeSubscription")}
message={t("settings.removeSubscriptionConfirm", { name: removeSub.name })}
confirmLabel={t("common.remove")}
onCancel={() => setRemoveSub(null)}
onConfirm={() => {
removeCal.mutate(removeSub.id);
setRemoveSub(null);
}}
/>
)}
</div>
);
}
function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBack: () => void }) {
const t = useT();
const queryClient = useQueryClient();
const [calId, setCalId] = useState<string | null>(calendar?.id ?? null);
// Whether we opened an existing calendar to edit — fixed for the form's lifetime, so
// auto-creating on a Publish toggle doesn't flip the "Create calendar" heading.
const startedNew = calendar === null;
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]);
const [publishToken, setPublishToken] = useState<string | null>(calendar?.publish_token ?? null);
const [publishBusy, setPublishBusy] = useState(calendar?.publish_busy ?? false);
const fullDetails = publishToken !== null && !publishBusy;
const freeBusy = publishToken !== null && publishBusy;
const done = () => {
queryClient.invalidateQueries({ queryKey: ["calendars"] });
onBack();
};
const save = useMutation({
mutationFn: async () => {
if (calId) await api.patchCalendar(calId, { name: name.trim(), color });
else await api.createCalendar({ name: name.trim(), color });
},
onSuccess: done,
});
const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done });
const [confirmDel, setConfirmDel] = useState(false);
// Turning on a Publish option auto-creates the calendar first (create mode) so a
// shareable .ics link can be shown immediately, then flips the public feed on/off.
const setPublish = useMutation({
mutationFn: async (mode: "off" | "full" | "busy") => {
let id = calId;
if (!id) {
const created = await api.createCalendar({ name: name.trim(), color });
id = created.id;
setCalId(id);
}
const res = await api.patchCalendar(id, { publish: mode });
return { mode, token: res.publish_token };
},
onSuccess: ({ mode, token }) => {
setPublishToken(mode === "off" ? null : token);
setPublishBusy(mode === "busy");
queryClient.invalidateQueries({ queryKey: ["calendars"] });
},
});
const publicUrl = publishToken ? api.publicCalendarUrl(publishToken) : "";
const linkRow = (
<div className="mt-2 flex items-center gap-2 rounded bg-app-hover px-3 py-2 text-xs">
<a
href={publicUrl}
className="min-w-0 flex-1 truncate text-app-accent hover:underline"
title={publicUrl}
>
{publicUrl}
</a>
<button
type="button"
className="shrink-0 rounded border border-app-border px-2 py-0.5 hover:bg-app-surface"
onClick={() => void navigator.clipboard.writeText(publicUrl)}
>
{t("common.copy")}
</button>
</div>
);
return (
<div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 gap-y-6 border-b border-app-border pb-6">
<h3 className="text-base font-semibold">
{startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}
</h3>
<div className="flex items-end gap-4">
<div>
<span className="mb-1 block text-sm font-medium">{t("settings.color")}</span>
<ColorSelect value={color} onChange={setColor} />
</div>
<label className="block flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.name")}</span>
<input
autoFocus
className="w-full rounded border border-app-accent bg-app-surface px-3 py-2 text-sm outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
</div>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 py-6">
<div>
<h3 className="text-base font-semibold">{t("settings.publish")}</h3>
<p className="mt-1 text-sm text-app-muted">{t("settings.publishHint")}</p>
</div>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("settings.fullEventDetails")}</span>
<SettingsToggle
checked={fullDetails}
disabled={!name.trim() || setPublish.isPending}
onChange={(v) => setPublish.mutate(v ? "full" : "off")}
/>
</div>
{fullDetails && linkRow}
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("settings.freeBusy")}</span>
<SettingsToggle
checked={freeBusy}
disabled={!name.trim() || setPublish.isPending}
onChange={(v) => setPublish.mutate(v ? "busy" : "off")}
/>
</div>
{freeBusy && linkRow}
</div>
</div>
</div>
<div className="ml-[192px] flex items-center gap-2">
<button
className="rounded bg-app-accent px-4 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!name.trim() || save.isPending}
onClick={() => save.mutate()}
>
{t("common.save")}
</button>
<button className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onBack}>
{t("common.cancel")}
</button>
{!startedNew && (
<button
className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover"
onClick={() => setConfirmDel(true)}
>
{t("common.delete")}
</button>
)}
</div>
{confirmDel && (
<ConfirmDialog
title={t("settings.deleteCalendar")}
message={t("settings.deleteCalendarConfirm", { name: name || t("settings.thisCalendar") })}
confirmLabel={t("common.delete")}
onCancel={() => setConfirmDel(false)}
onConfirm={() => {
setConfirmDel(false);
del.mutate();
}}
/>
)}
</div>
);
}
const REM_SELECT_CLASS =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm text-app-fg outline-none focus:border-app-accent";
function ReminderList({
label,
value,
onChange,
}: {
label: string;
value: ReminderPref[];
onChange: (v: ReminderPref[]) => void;
}) {
const t = useT();
const update = (i: number, patch: Partial<ReminderPref>) =>
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
return (
<div>
<span className="mb-2 block text-sm font-medium">{label}</span>
<div className="space-y-3">
{value.map((r, i) => (
<div key={i} className="flex items-start gap-2">
<BellIcon className="mt-2.5 h-4 w-4 shrink-0 text-app-muted" />
<div className="flex-1 space-y-2 rounded-lg border border-app-border p-2">
<select
className={REM_SELECT_CLASS}
value={r.method}
onChange={(e) => update(i, { method: e.target.value as ReminderPref["method"] })}
>
<option value="alert" className="bg-app-surface text-app-fg">
{t("settings.showAlert")}
</option>
<option value="email" className="bg-app-surface text-app-fg">
{t("settings.emailMe")}
</option>
</select>
<select
className={`${REM_SELECT_CLASS} border-app-accent`}
value={r.minutes}
onChange={(e) => update(i, { minutes: Number(e.target.value) })}
>
{REMINDER_OPTS.map((o) => (
<option key={o} value={o} className="bg-app-surface text-app-fg">
{reminderText(o)}
</option>
))}
</select>
</div>
<button
className="mt-1.5 px-1 text-app-muted hover:text-app-fg"
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
aria-label={t("common.delete")}
>
✕
</button>
</div>
))}
</div>
<button
className="mt-3 inline-flex items-center gap-2 rounded bg-app-hover px-3 py-1.5 text-sm hover:opacity-90"
onClick={() => onChange([...value, { method: "alert", minutes: 60 }])}
>
<BellIcon className="h-4 w-4" /> {t("settings.addReminder")}
</button>
</div>
);
}
function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const s = settings.data;
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
// When no default is set, fall back to "Personal" (the bootstrapped calendar) so the
// dropdown shows Personal rather than a generic placeholder; else the first calendar.
const preferredDefault =
calendars.find((c) => c.name === "Personal") ?? calendars[0];
const defaultCalValue =
s?.default_calendar && calendars.some((c) => c.id === s.default_calendar)
? s.default_calendar
: (preferredDefault?.id ?? "");
// Detect and persist the browser time zone on first visit (backend default is UTC).
useEffect(() => {
if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [s?.tz]);
return (
<div className="space-y-8">
<div className="grid grid-cols-[160px_1fr] gap-x-8">
<h3 className="text-base font-semibold">{t("settings.calendarDisplay")}</h3>
<label className="block max-w-md">
<span className="mb-1 block text-sm font-medium">{t("settings.timezone")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.tz && s.tz !== "UTC" ? s.tz : browserTz}
onChange={(e) => patch.mutate({ tz: e.target.value })}
>
{TZ_LIST.map(([zone, tzLabel]) => (
<option key={zone} value={zone} className="bg-app-surface text-app-fg">
{tzLabel}
</option>
))}
</select>
</label>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 gap-y-6">
<h3 className="text-base font-semibold">{t("settings.default")}</h3>
<div className="max-w-md space-y-6">
<label className="block">
<span className="mb-1 block text-sm font-medium">{t("settings.defaultCalendar")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={defaultCalValue}
onChange={(e) => patch.mutate({ default_calendar: e.target.value })}
>
{calendars.map((c) => (
<option key={c.id} value={c.id} className="bg-app-surface text-app-fg">
{c.name}
</option>
))}
</select>
</label>
<ReminderList
label={t("settings.defaultRemindersTimed")}
value={s?.default_reminders_timed ?? []}
onChange={(v) => patch.mutate({ default_reminders_timed: v })}
/>
<ReminderList
label={t("settings.defaultRemindersAllday")}
value={s?.default_reminders_allday ?? []}
onChange={(v) => patch.mutate({ default_reminders_allday: v })}
/>
</div>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8">
<h3 className="text-base font-semibold">{t("settings.invitations")}</h3>
<div className="max-w-2xl space-y-5">
<fieldset>
<legend className="mb-2 text-sm font-medium">{t("settings.inviteWhen")}</legend>
<div className="space-y-2">
{(
[
["add_update", t("settings.inviteAddUpdate")],
["update_only", t("settings.inviteUpdateOnly")],
["none", t("settings.inviteNone")],
] as const
).map(([val, txt]) => (
<label key={val} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
name="invitation_policy"
className="accent-app-accent"
checked={(s?.invitation_policy ?? "add_update") === val}
onChange={() => patch.mutate({ invitation_policy: val })}
/>
{txt}
</label>
))}
</div>
</fieldset>
<div className="flex flex-wrap gap-4">
<label className="block min-w-[200px] flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.addNewFrom")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.invitation_from ?? "contacts"}
onChange={(e) =>
patch.mutate({ invitation_from: e.target.value as "contacts" | "anyone" })
}
>
<option value="contacts" className="bg-app-surface text-app-fg">
{t("settings.myContacts")}
</option>
<option value="anyone" className="bg-app-surface text-app-fg">
{t("settings.anyone")}
</option>
</select>
</label>
<label className="block min-w-[200px] flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.addToCalendar")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.invitation_calendar ?? ""}
onChange={(e) => patch.mutate({ invitation_calendar: e.target.value })}
>
<option value="" className="bg-app-surface text-app-fg">
{t("settings.firstCalendar")}
</option>
{calendars.map((c) => (
<option key={c.id} value={c.id} className="bg-app-surface text-app-fg">
{c.name}
</option>
))}
</select>
</label>
</div>
<div className="flex max-w-md items-center justify-between">
<span className="text-sm font-medium">{t("settings.alsoFile")}</span>
<SettingsToggle
checked={s?.invitation_file ?? false}
onChange={(v) => patch.mutate({ invitation_file: v })}
/>
</div>
</div>
</div>
</div>
);
}