Files
webmail-client/frontend/src/routes/settings/Settings.tsx
T

1722 lines
63 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
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";
2026-07-17 23:20:34 +02:00
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
2026-07-17 23:20:34 +02:00
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { CalendarOut, ReminderPref } from "../../lib/types";
2026-07-17 23:20:34 +02:00
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";
2026-07-17 23:20:34 +02:00
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
2026-07-17 23:20:34 +02:00
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;
2026-07-17 23:20:34 +02:00
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")}
2026-07-17 23:20:34 +02:00
</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}`}
2026-07-17 23:20:34 +02:00
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>
))}
2026-07-17 23:20:34 +02:00
</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 />}
2026-07-17 23:20:34 +02:00
{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>
)}
2026-07-17 23:20:34 +02:00
</main>
</div>
</div>
);
}
function ProfileSection() {
const t = useT();
const { data } = useMe();
return (
<section className="max-w-4xl space-y-3">
2026-07-17 23:20:34 +02:00
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
<RecoveryEmailRow current={data?.recovery_email ?? null} />
2026-07-17 23:20:34 +02:00
</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>
);
}
2026-07-17 23:20:34 +02:00
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">
2026-07-17 23:20:34 +02:00
<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);
2026-07-17 23:20:34 +02:00
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setDeletingId(null);
},
2026-07-17 23:20:34 +02:00
});
const downloadAll = useMutation({
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
2026-07-17 23:20:34 +02:00
const close = () => {
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["accounts"] });
};
2026-07-17 23:20:34 +02:00
return (
<section className="max-w-5xl space-y-3">
2026-07-17 23:20:34 +02:00
<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>
)}
2026-07-17 23:20:34 +02:00
</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>
)}
2026-07-17 23:20:34 +02:00
</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>
);
}
2026-07-17 23:20:34 +02:00
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">
2026-07-17 23:20:34 +02:00
<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>
<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>
2026-07-17 23:20:34 +02:00
<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>
);
}