2026-07-17 23:20:34 +02:00
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
|
import { QRCodeSVG } from "qrcode.react";
|
2026-07-18 15:27:09 +02:00
|
|
|
|
import { useEffect, useState } from "react";
|
2026-08-05 18:04:49 +02:00
|
|
|
|
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
import { useT } from "../../i18n";
|
|
|
|
|
|
import { api, ApiError } from "../../lib/api";
|
2026-08-02 08:48:33 +02:00
|
|
|
|
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
import { useMe } from "../../lib/auth";
|
|
|
|
|
|
import { useTheme } from "../../lib/theme";
|
2026-07-28 16:33:34 +02:00
|
|
|
|
import { setTimeFormat } from "../../lib/timeFormat";
|
2026-07-27 08:30:24 +02:00
|
|
|
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
2026-09-17 08:19:11 +02:00
|
|
|
|
import { usePluginSettingsSections } from "../../plugins/manifest";
|
2026-08-05 18:04:49 +02:00
|
|
|
|
import { AccountWizard } from "../wizard/AccountWizard";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-08-01 20:22:53 +02:00
|
|
|
|
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
type Section = (typeof SECTIONS)[number];
|
|
|
|
|
|
|
|
|
|
|
|
export function Settings() {
|
|
|
|
|
|
const t = useT();
|
|
|
|
|
|
const params = useParams();
|
2026-08-02 08:48:33 +02:00
|
|
|
|
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.
|
2026-08-05 18:04:49 +02:00
|
|
|
|
const navigate = useNavigate();
|
2026-08-02 08:48:33 +02:00
|
|
|
|
const rawFrom = searchParams.get("from") ?? "";
|
|
|
|
|
|
const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
|
|
|
|
|
|
const fromQuery = `?from=${encodeURIComponent(from)}`;
|
2026-08-05 18:04:49 +02:00
|
|
|
|
// The add-account wizard renders inside this layout (route /settings/accounts/new),
|
|
|
|
|
|
// with the Accounts nav item highlighted.
|
|
|
|
|
|
const addingAccount = useLocation().pathname.endsWith("/accounts/new");
|
2026-09-17 08:19:11 +02:00
|
|
|
|
// Plugins contributing `settings.page` each get their own nav entry, keyed by
|
|
|
|
|
|
// plugin name, rather than being stacked at the bottom of Accounts.
|
|
|
|
|
|
const pluginSections = usePluginSettingsSections();
|
|
|
|
|
|
const isPluginSection = pluginSections.some((b) => b.plugin === params.section);
|
2026-08-05 18:04:49 +02:00
|
|
|
|
const section = (
|
|
|
|
|
|
addingAccount
|
|
|
|
|
|
? "accounts"
|
2026-09-17 08:19:11 +02:00
|
|
|
|
: SECTIONS.includes(params.section as Section) || isPluginSection
|
2026-08-05 18:04:49 +02:00
|
|
|
|
? params.section
|
|
|
|
|
|
: "profile"
|
|
|
|
|
|
) as Section;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-screen flex-col">
|
|
|
|
|
|
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
|
2026-08-02 08:48:33 +02:00
|
|
|
|
<Link to={from} className="text-sm text-app-accent">
|
|
|
|
|
|
← {t("nav.back")}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</Link>
|
|
|
|
|
|
<span className="font-semibold">{t("settings.title")}</span>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
|
|
|
|
<nav className="w-52 shrink-0 border-r border-app-border p-2">
|
|
|
|
|
|
{SECTIONS.map((s) => (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={s}
|
2026-08-02 08:48:33 +02:00
|
|
|
|
to={`/settings/${s}${fromQuery}`}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
className={`block rounded px-3 py-2 text-sm ${
|
|
|
|
|
|
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t(`settings.${s}` as const)}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
))}
|
2026-09-17 08:19:11 +02:00
|
|
|
|
{pluginSections.map((b) => (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={b.plugin}
|
|
|
|
|
|
to={`/settings/${b.plugin}${fromQuery}`}
|
|
|
|
|
|
className={`block rounded px-3 py-2 text-sm ${
|
|
|
|
|
|
section === (b.plugin as Section)
|
|
|
|
|
|
? "bg-app-active font-medium text-app-fg"
|
|
|
|
|
|
: "hover:bg-app-hover"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{b.title ?? b.plugin}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
))}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</nav>
|
|
|
|
|
|
<main className="flex-1 overflow-y-auto p-6">
|
2026-08-05 18:04:49 +02:00
|
|
|
|
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
|
|
|
|
|
{!addingAccount && section === "profile" && <ProfileSection />}
|
|
|
|
|
|
{!addingAccount && section === "security" && <SecuritySection />}
|
|
|
|
|
|
{!addingAccount && section === "accounts" && <AccountsSection />}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
{section === "calendars" && <CalendarsSection />}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
{section === "appearance" && <AppearanceSection />}
|
2026-07-28 16:33:34 +02:00
|
|
|
|
{section === "integrations" && <IntegrationsSection />}
|
2026-09-17 08:19:11 +02:00
|
|
|
|
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
|
|
|
|
|
|
per plugin, selected from the nav like any built-in section. */}
|
|
|
|
|
|
{isPluginSection && (
|
|
|
|
|
|
<div className="max-w-5xl">
|
|
|
|
|
|
<PluginSlot name="settings.page" plugin={section} />
|
2026-07-27 08:30:24 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ProfileSection() {
|
|
|
|
|
|
const t = useT();
|
|
|
|
|
|
const { data } = useMe();
|
|
|
|
|
|
return (
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<section className="max-w-4xl space-y-3">
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
|
|
|
|
|
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
|
|
|
|
|
<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 (
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<section className="max-w-4xl space-y-6">
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
|
|
|
|
|
|
{enabled ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<p className="text-sm text-emerald-500">{t("settings.twoFactorOn")}</p>
|
|
|
|
|
|
{disabling ? (
|
|
|
|
|
|
<div className="space-y-2 rounded-lg border border-app-border p-3">
|
|
|
|
|
|
<p className="text-sm text-app-muted">{t("settings.disable2faPrompt")}</p>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
value={disablePw}
|
|
|
|
|
|
onChange={(e) => setDisablePw(e.target.value)}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button className="rounded bg-red-600 px-3 py-1 text-sm text-white" onClick={disable}>
|
|
|
|
|
|
{t("settings.disable")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="px-2 text-sm text-app-muted" onClick={() => setDisabling(false)}>
|
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
|
|
|
|
|
|
onClick={() => setDisabling(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("settings.disable2fa")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : setup ? (
|
|
|
|
|
|
<div className="space-y-3 rounded-lg border border-app-border p-3">
|
|
|
|
|
|
<p className="text-sm text-app-muted">{t("settings.scanQr")}</p>
|
|
|
|
|
|
<div className="flex gap-4">
|
|
|
|
|
|
<div className="shrink-0 rounded bg-white p-2 ring-1 ring-gray-200">
|
|
|
|
|
|
<QRCodeSVG value={setup.uri} size={160} level="M" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 space-y-1">
|
|
|
|
|
|
<p className="text-xs text-app-muted">
|
|
|
|
|
|
{t("settings.secret")}:
|
|
|
|
|
|
<br />
|
|
|
|
|
|
<code className="break-all">{setup.secret}</code>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<details className="text-xs text-app-faint">
|
|
|
|
|
|
<summary className="cursor-pointer">otpauth URI</summary>
|
|
|
|
|
|
<div className="mt-1 break-all rounded bg-app-surface px-2 py-1">{setup.uri}</div>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="w-32 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
|
|
|
|
|
placeholder="123456"
|
|
|
|
|
|
value={code}
|
|
|
|
|
|
onChange={(e) => setCode(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg" onClick={confirm}>
|
|
|
|
|
|
{t("settings.confirm")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg" onClick={startSetup}>
|
|
|
|
|
|
{t("settings.enable2fa")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<h2 className="text-lg font-semibold">{t("settings.sessions")}</h2>
|
|
|
|
|
|
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
|
|
|
|
|
{(sessions.data ?? []).map((s) => (
|
|
|
|
|
|
<li key={s.sid} className="flex items-center justify-between px-3 py-2 text-sm">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{s.ua || "session"}{" "}
|
|
|
|
|
|
{s.current && <span className="text-xs text-emerald-500">({t("settings.thisDevice")})</span>}
|
|
|
|
|
|
<span className="block text-xs text-app-faint">
|
|
|
|
|
|
{new Date(s.created).toLocaleString()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{!s.current && (
|
|
|
|
|
|
<button className="text-xs text-red-500" onClick={() => revoke.mutate(s.sid)}>
|
|
|
|
|
|
{t("settings.revoke")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AccountsSection() {
|
|
|
|
|
|
const t = useT();
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
|
|
|
|
|
|
null,
|
|
|
|
|
|
);
|
2026-08-07 07:59:26 +02:00
|
|
|
|
// Which account is being deleted — drives the blocking spinner overlay until the
|
|
|
|
|
|
// (slow) server cascade finishes and the refetched list no longer includes it.
|
|
|
|
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const remove = useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => api.deleteAccount(id),
|
2026-08-07 07:59:26 +02:00
|
|
|
|
onSettled: async () => {
|
|
|
|
|
|
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
|
|
|
|
|
setDeletingId(null);
|
|
|
|
|
|
},
|
2026-07-17 23:20:34 +02:00
|
|
|
|
});
|
2026-07-23 23:00:08 +02:00
|
|
|
|
const downloadAll = useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
|
|
|
|
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
|
|
|
|
|
});
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const close = () => {
|
|
|
|
|
|
setEditing(null);
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
return (
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<section className="max-w-5xl space-y-3">
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg"
|
|
|
|
|
|
onClick={() => navigate("/settings/accounts/new")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("settings.addAccount")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
|
|
|
|
|
{(accounts.data ?? []).map((a) => (
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<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>
|
2026-07-23 23:00:08 +02:00
|
|
|
|
{a.sync_window !== "all" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="text-xs text-app-faint">·</span>
|
|
|
|
|
|
<span className="text-xs text-app-faint">
|
|
|
|
|
|
{t("settings.syncedPartially")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
<span className="flex items-center gap-3">
|
2026-07-23 23:00:08 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<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={() => {
|
2026-08-07 07:59:26 +02:00
|
|
|
|
if (confirm(t("settings.confirmRemoveAccount"))) {
|
|
|
|
|
|
setDeletingId(a.id);
|
|
|
|
|
|
remove.mutate(a.id);
|
|
|
|
|
|
}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("common.remove")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
2026-08-07 07:59:26 +02:00
|
|
|
|
{deletingId && (
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
|
|
|
|
<div className="flex flex-col items-center gap-3 rounded-xl bg-app-elevated px-8 py-6 shadow-2xl">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="h-8 w-8 animate-spin rounded-full border-2 border-app-border border-t-app-accent"
|
|
|
|
|
|
role="status"
|
|
|
|
|
|
aria-label={t("settings.removingAccount")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm text-app-muted">{t("settings.removingAccount")}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
|
function AppearanceSection() {
|
|
|
|
|
|
const t = useT();
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
|
|
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
|
|
|
|
|
const patch = useMutation({
|
|
|
|
|
|
mutationFn: api.patchSettings,
|
|
|
|
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<section className="max-w-4xl space-y-3">
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
|
|
|
|
|
|
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
|
|
|
|
|
<span className="text-app-muted">{t("settings.theme")}</span>
|
|
|
|
|
|
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
|
|
|
|
|
{(["light", "dark", "system"] as const).map((value) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={value}
|
|
|
|
|
|
onClick={() => setTheme(value)}
|
|
|
|
|
|
className={`rounded px-3 py-1 ${
|
|
|
|
|
|
theme === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t(`theme.${value}` as const)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
|
|
|
|
|
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
|
|
|
|
|
value={settings.data?.thread_order ?? "asc"}
|
|
|
|
|
|
onChange={(e) => patch.mutate({ thread_order: e.target.value as "asc" | "desc" })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="asc">{t("mail.orderOldest")}</option>
|
|
|
|
|
|
<option value="desc">{t("mail.orderNewest")}</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
2026-07-28 16:33:34 +02:00
|
|
|
|
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
|
|
|
|
|
<span className="text-app-muted">{t("settings.timeFormat")}</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
|
|
|
|
|
value={settings.data?.time_format ?? "24"}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const fmt = e.target.value as "12" | "24";
|
|
|
|
|
|
setTimeFormat(fmt);
|
|
|
|
|
|
patch.mutate({ time_format: fmt });
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="24">{t("settings.timeFormat24")}</option>
|
|
|
|
|
|
<option value="12">{t("settings.timeFormat12")}</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-28 16:33:34 +02:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
|
|
|
|
|
|
const CAL_COLORS: [string, string][] = [
|
|
|
|
|
|
["Blue", "#3b82f6"],
|
|
|
|
|
|
["Green", "#22c55e"],
|
|
|
|
|
|
["Red", "#ef4444"],
|
|
|
|
|
|
["Orange", "#f97316"],
|
|
|
|
|
|
["Purple", "#a855f7"],
|
|
|
|
|
|
["Teal", "#14b8a6"],
|
|
|
|
|
|
["Pink", "#ec4899"],
|
|
|
|
|
|
["Gray", "#6b7280"],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-02 08:48:33 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-01 20:22:53 +02:00
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-08-02 08:48:33 +02:00
|
|
|
|
const TZ_ZONES = TZ_RAW.length
|
2026-08-01 20:22:53 +02:00
|
|
|
|
? TZ_RAW
|
|
|
|
|
|
: ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"];
|
2026-08-02 08:48:33 +02:00
|
|
|
|
|
|
|
|
|
|
// 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]));
|
|
|
|
|
|
|
2026-08-01 20:22:53 +02:00
|
|
|
|
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"] });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-08-02 08:48:33 +02:00
|
|
|
|
const [removeSub, setRemoveSub] = useState<CalendarOut | null>(null);
|
|
|
|
|
|
const removeCal = useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => api.deleteCalendar(id),
|
|
|
|
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }),
|
|
|
|
|
|
});
|
2026-08-01 20:22:53 +02:00
|
|
|
|
const subscriptions = calendars.filter((c) => c.subscription_url !== null);
|
|
|
|
|
|
const accounts: [string, string][] = [
|
|
|
|
|
|
["Google", "google"],
|
|
|
|
|
|
["iCloud", "icloud"],
|
|
|
|
|
|
["CalDAV", "caldav"],
|
|
|
|
|
|
];
|
2026-08-08 10:27:21 +02:00
|
|
|
|
// CalDAV account connect (iCloud / generic). Google needs OAuth — left disabled.
|
|
|
|
|
|
const davAccounts = useQuery({ queryKey: ["dav-accounts"], queryFn: api.davAccounts });
|
|
|
|
|
|
const [connecting, setConnecting] = useState<"icloud" | "caldav" | null>(null);
|
|
|
|
|
|
const [davUser, setDavUser] = useState("");
|
|
|
|
|
|
const [davPass, setDavPass] = useState("");
|
|
|
|
|
|
const [davUrl, setDavUrl] = useState("");
|
|
|
|
|
|
const refreshCalendars = () => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["calendars"] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["dav-accounts"] });
|
|
|
|
|
|
};
|
|
|
|
|
|
const closeConnect = () => {
|
|
|
|
|
|
setConnecting(null);
|
|
|
|
|
|
setDavUser("");
|
|
|
|
|
|
setDavPass("");
|
|
|
|
|
|
setDavUrl("");
|
|
|
|
|
|
};
|
|
|
|
|
|
const connect = useMutation({
|
|
|
|
|
|
mutationFn: () =>
|
|
|
|
|
|
api.davConnect({
|
|
|
|
|
|
provider: connecting ?? "caldav",
|
|
|
|
|
|
server_url: davUrl.trim(),
|
|
|
|
|
|
username: davUser.trim(),
|
|
|
|
|
|
password: davPass,
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
closeConnect();
|
|
|
|
|
|
refreshCalendars();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const disconnect = useMutation({
|
|
|
|
|
|
mutationFn: (accountId: string) => api.davDeleteAccount(accountId),
|
|
|
|
|
|
onSuccess: refreshCalendars,
|
|
|
|
|
|
});
|
2026-08-01 20:22:53 +02:00
|
|
|
|
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>
|
2026-08-08 10:27:21 +02:00
|
|
|
|
|
|
|
|
|
|
{(davAccounts.data ?? []).length > 0 && (
|
|
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
|
|
{(davAccounts.data ?? []).map((a) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={a.account_id}
|
|
|
|
|
|
className="flex items-center gap-3 rounded-lg border border-app-border p-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<div className="truncate text-sm font-medium">{a.username || a.provider}</div>
|
|
|
|
|
|
<div className="text-xs text-app-muted">
|
|
|
|
|
|
{a.provider === "icloud" ? "iCloud" : "CalDAV"} ·{" "}
|
|
|
|
|
|
{t("settings.calendarCount", { count: a.calendars.length })}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="text-xs text-red-500"
|
|
|
|
|
|
disabled={disconnect.isPending}
|
|
|
|
|
|
onClick={() => disconnect.mutate(a.account_id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("common.remove")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-01 20:22:53 +02:00
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
2026-08-08 10:27:21 +02:00
|
|
|
|
{accounts.map(([label, key]) => (
|
2026-08-01 20:22:53 +02:00
|
|
|
|
<button
|
|
|
|
|
|
key={label}
|
2026-08-08 10:27:21 +02:00
|
|
|
|
disabled={key === "google"}
|
|
|
|
|
|
title={key === "google" ? t("settings.comingSoon") : undefined}
|
|
|
|
|
|
onClick={() => key !== "google" && setConnecting(key as "icloud" | "caldav")}
|
|
|
|
|
|
className={`rounded-lg border border-app-border px-4 py-2 text-sm ${
|
|
|
|
|
|
key === "google" ? "opacity-60" : "hover:bg-app-hover"
|
|
|
|
|
|
}`}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-08 10:27:21 +02:00
|
|
|
|
|
|
|
|
|
|
{connecting && (
|
|
|
|
|
|
<div className="mt-3 max-w-md space-y-2 rounded-lg border border-app-border p-4">
|
|
|
|
|
|
<div className="text-sm font-medium">
|
|
|
|
|
|
{t("settings.connectTitle", { provider: connecting === "icloud" ? "iCloud" : "CalDAV" })}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{connecting === "caldav" && (
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
|
|
|
|
|
placeholder={t("settings.davServerUrl")}
|
|
|
|
|
|
value={davUrl}
|
|
|
|
|
|
onChange={(e) => setDavUrl(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
|
|
|
|
|
placeholder={connecting === "icloud" ? t("settings.appleId") : t("settings.username")}
|
|
|
|
|
|
value={davUser}
|
|
|
|
|
|
onChange={(e) => setDavUser(e.target.value)}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
|
|
|
|
|
placeholder={
|
|
|
|
|
|
connecting === "icloud" ? t("settings.appPassword") : t("settings.password")
|
|
|
|
|
|
}
|
|
|
|
|
|
value={davPass}
|
|
|
|
|
|
onChange={(e) => setDavPass(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{connecting === "icloud" && (
|
|
|
|
|
|
<p className="text-xs text-app-faint">{t("settings.icloudHint")}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg disabled:opacity-50"
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
connect.isPending ||
|
|
|
|
|
|
!davUser.trim() ||
|
|
|
|
|
|
!davPass ||
|
|
|
|
|
|
(connecting === "caldav" && !davUrl.trim())
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={() => connect.mutate()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{connect.isPending ? t("settings.connecting") : t("settings.connect")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="px-3 py-2 text-sm text-app-muted" onClick={closeConnect}>
|
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{connect.isError && (
|
|
|
|
|
|
<p className="text-sm text-red-500">
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const code =
|
|
|
|
|
|
connect.error instanceof ApiError ? connect.error.message : "";
|
|
|
|
|
|
const keys = {
|
|
|
|
|
|
auth_failed: "settings.connectAuthFailed",
|
|
|
|
|
|
private_host: "settings.connectPrivateHost",
|
|
|
|
|
|
unreachable: "settings.connectUnreachable",
|
|
|
|
|
|
no_calendars: "settings.connectNoCalendars",
|
|
|
|
|
|
server_url_required: "settings.connectNeedsUrl",
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
const key = keys[code as keyof typeof keys];
|
|
|
|
|
|
return t(key ?? "settings.connectError");
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</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"
|
2026-08-02 08:48:33 +02:00
|
|
|
|
onClick={() => setRemoveSub(c)}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
>
|
|
|
|
|
|
{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>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
|
|
|
|
|
|
{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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</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);
|
2026-08-02 08:48:33 +02:00
|
|
|
|
// 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;
|
2026-08-01 20:22:53 +02:00
|
|
|
|
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 });
|
2026-08-02 08:48:33 +02:00
|
|
|
|
const [confirmDel, setConfirmDel] = useState(false);
|
2026-08-01 20:22:53 +02:00
|
|
|
|
|
|
|
|
|
|
// 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">
|
2026-08-02 08:48:33 +02:00
|
|
|
|
{startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="flex items-end gap-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="mb-1 block text-sm font-medium">{t("settings.color")}</span>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
<ColorSelect value={color} onChange={setColor} />
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</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>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
{!startedNew && (
|
2026-08-01 20:22:53 +02:00
|
|
|
|
<button
|
|
|
|
|
|
className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover"
|
2026-08-02 08:48:33 +02:00
|
|
|
|
onClick={() => setConfirmDel(true)}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
>
|
|
|
|
|
|
{t("common.delete")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
|
|
|
|
|
|
{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();
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 08:48:33 +02:00
|
|
|
|
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";
|
|
|
|
|
|
|
2026-08-01 20:22:53 +02:00
|
|
|
|
function ReminderList({
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
2026-08-02 08:48:33 +02:00
|
|
|
|
value: ReminderPref[];
|
|
|
|
|
|
onChange: (v: ReminderPref[]) => void;
|
2026-08-01 20:22:53 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
const t = useT();
|
2026-08-02 08:48:33 +02:00
|
|
|
|
const update = (i: number, patch: Partial<ReminderPref>) =>
|
|
|
|
|
|
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
2026-08-01 20:22:53 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="mb-2 block text-sm font-medium">{label}</span>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
<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")}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</option>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
<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>
|
2026-08-01 20:22:53 +02:00
|
|
|
|
<button
|
2026-08-02 08:48:33 +02:00
|
|
|
|
className="mt-1.5 px-1 text-app-muted hover:text-app-fg"
|
2026-08-01 20:22:53 +02:00
|
|
|
|
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
|
|
|
|
|
aria-label={t("common.delete")}
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
<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>
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</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";
|
|
|
|
|
|
|
2026-08-02 08:48:33 +02:00
|
|
|
|
// 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 ?? "");
|
|
|
|
|
|
|
2026-08-01 20:22:53 +02:00
|
|
|
|
// 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 })}
|
|
|
|
|
|
>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
{TZ_LIST.map(([zone, tzLabel]) => (
|
|
|
|
|
|
<option key={zone} value={zone} className="bg-app-surface text-app-fg">
|
|
|
|
|
|
{tzLabel}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</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"
|
2026-08-02 08:48:33 +02:00
|
|
|
|
value={defaultCalValue}
|
2026-08-01 20:22:53 +02:00
|
|
|
|
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>
|
2026-08-02 08:48:33 +02:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-08-01 20:22:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|