Files
webmail-client/frontend/src/routes/settings/Settings.tsx
T
lukasandClaude Opus 4.8 57a1f1562f M4 Calendar: reminders form, invitations, short publish links, settings UX
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
2026-08-02 08:48:33 +02:00

1436 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { Link, 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>
);
}