Calendar settings (Settings > Calendars):
- Reminders are now {method, minutes}: Add-reminder opens a boxed form with a
method dropdown (Show an alert / Email me) + time dropdown, bell icons.
Backend normalizes the legacy list[int] transparently.
- New Invitations section (auto-add emailed events): policy radios, "Add new
events from" (My contacts / Anyone), "Add to calendar", mark-read+file toggle.
Persisted to user settings (inbound iMIP processing still deferred).
- Time zone options show GMT codes, e.g. "Central European Summer Time (GMT+2)".
- Default calendar now defaults to "Personal" instead of a generic placeholder.
- Colour picker is a circular-swatch-only dropdown (sized so swatches don't overlap).
- Delete calendar / remove subscription now confirm via a modal.
- Create form: dropped the breadcrumb; heading stays "Create calendar" even after
a Publish toggle auto-creates the calendar.
Publish links: replaced the 244-char vault-sealed token with a 16-char random
slug (secrets.token_urlsafe). New sdk.public_session() (privileged, non-RLS) lets
the public /public/{token} feed resolve the slug directly with explicit filters.
Settings shell: "<- Mail" is now a context-aware "<- Back" via ?from=; Calendar
opens Settings with from=/p/calendar so Back returns to the calendar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
1436 lines
52 KiB
TypeScript
1436 lines
52 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { QRCodeSVG } from "qrcode.react";
|
||
import { useEffect, useState } from "react";
|
||
import { Link, 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";
|
||
|
||
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 rawFrom = searchParams.get("from") ?? "";
|
||
const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
|
||
const fromQuery = `?from=${encodeURIComponent(from)}`;
|
||
const section = (SECTIONS.includes(params.section as Section) ? 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>
|
||
))}
|
||
</nav>
|
||
<main className="flex-1 overflow-y-auto p-6">
|
||
{section === "profile" && <ProfileSection />}
|
||
{section === "security" && <SecuritySection />}
|
||
{section === "accounts" && <AccountsSection />}
|
||
{section === "calendars" && <CalendarsSection />}
|
||
{section === "appearance" && <AppearanceSection />}
|
||
{section === "integrations" && <IntegrationsSection />}
|
||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
||
Matches the accounts section width (max-w-5xl) with a gap above it. */}
|
||
{section === "accounts" && (
|
||
<div className="mt-8 max-w-5xl">
|
||
<PluginSlot name="settings.page" />
|
||
</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,
|
||
);
|
||
const remove = useMutation({
|
||
mutationFn: (id: string) => api.deleteAccount(id),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||
});
|
||
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"))) remove.mutate(a.id);
|
||
}}
|
||
>
|
||
{t("common.remove")}
|
||
</button>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function AccountEditor({
|
||
accountId,
|
||
onCancel,
|
||
onSaved,
|
||
}: {
|
||
accountId: string;
|
||
onCancel: () => void;
|
||
onSaved: () => void;
|
||
}) {
|
||
const t = useT();
|
||
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: 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 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 }),
|
||
onSuccess: 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"],
|
||
];
|
||
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>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{accounts.map(([label]) => (
|
||
<button
|
||
key={label}
|
||
disabled
|
||
title={t("settings.comingSoon")}
|
||
className="rounded-lg border border-app-border px-4 py-2 text-sm opacity-60"
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|