Saving invalidated only the ["accounts"] list, not the ["account", id] detail the editors read. Reopening rendered the stale cached detail, and the rich-text editor seeds its content only once on mount, so the refetch that followed never reached the screen. Both the signature and the account editors now write the PATCH response into the detail cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1629 lines
60 KiB
TypeScript
1629 lines
60 KiB
TypeScript
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 { 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 ?? ""} />
|
||
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<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>
|
||
);
|
||
}
|