M4 Calendar: publish public .ics links, subscriptions, multi-calendar settings

Calendar (builtin plugin):
- Publish read-only public .ics feed per calendar via vault-sealed token
  (full-detail or free/busy-only); unauthenticated GET /public/{token},
  404 on bad/absent token. Migration calendar0004 adds publish_token,
  publish_busy, subscription_url + partial unique index.
- Subscribe to external iCal (.ics) URLs (SSRF-guarded) as read-only
  calendars; CSRF-guarded POST /subscriptions.
- Settings > Calendars: create/edit/delete, publish toggles with live
  shareable link + copy, Add-account stubs (Google/iCloud/CalDAV, deferred),
  Subscriptions section; default calendar + default reminders; auto-detected
  time zone; color-dropdown contrast fix; 3x-larger edit arrow.
- Left panel: shared Mail-parity sidebar template (.wm-side-*), calendar
  search panel, font/spacing tuning, per-section "+" create shortcut.

Frontend: CalendarOut publish fields, api publish/subscribe, settings keys.
Backend: settings default_calendar + reminder lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-01 20:22:53 +02:00
co-authored by Claude Opus 4.8
parent 8c634f7967
commit e2bef3c41d
20 changed files with 1329 additions and 88 deletions
+535 -2
View File
@@ -1,17 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
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 } 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", "appearance", "integrations"] as const;
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
type Section = (typeof SECTIONS)[number];
export function Settings() {
@@ -45,6 +46,7 @@ export function Settings() {
{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.
@@ -648,3 +650,534 @@ function IntegrationsSection() {
</section>
);
}
const CAL_COLORS: [string, string][] = [
["Blue", "#3b82f6"],
["Green", "#22c55e"],
["Red", "#ef4444"],
["Orange", "#f97316"],
["Purple", "#a855f7"],
["Teal", "#14b8a6"],
["Pink", "#ec4899"],
["Gray", "#6b7280"],
];
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_LIST = TZ_RAW.length
? TZ_RAW
: ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"];
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 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={() => {
void api.deleteCalendar(c.id).then(() =>
queryClient.invalidateQueries({ queryKey: ["calendars"] }),
);
}}
>
{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>
</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);
const editing = calId !== 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 });
// 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>
);
const colorOptions: [string, string][] = CAL_COLORS.some(([, hex]) => hex === color)
? CAL_COLORS
: [["Custom", color], ...CAL_COLORS];
return (
<div>
<div className="mb-5 flex items-center gap-2 border-b border-app-border pb-3 text-sm text-app-muted">
<button className="text-app-accent hover:underline" onClick={onBack}>
{t("settings.myCalendars")}
</button>
<span>›</span>
<span className="text-app-fg">
{editing ? t("settings.editCalendar") : t("settings.createCalendar")}
</span>
</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">
{editing ? t("settings.editCalendar") : t("settings.createCalendar")}
</h3>
<div className="flex items-end gap-4">
<div>
<span className="mb-1 block text-sm font-medium">{t("settings.color")}</span>
<div className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2 py-1.5">
<span className="inline-block h-4 w-4 rounded-full" style={{ background: color }} />
<select
className="bg-app-surface text-sm text-app-fg outline-none"
value={color}
onChange={(e) => setColor(e.target.value)}
>
{colorOptions.map(([label, hex]) => (
<option key={hex} value={hex} className="bg-app-surface text-app-fg">
{label}
</option>
))}
</select>
</div>
</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>
{editing && (
<button
className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover"
onClick={() => del.mutate()}
>
{t("common.delete")}
</button>
)}
</div>
</div>
);
}
function ReminderList({
label,
value,
onChange,
}: {
label: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useT();
return (
<div>
<span className="mb-2 block text-sm font-medium">{label}</span>
<div className="space-y-2">
{value.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<select
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm text-app-fg outline-none"
value={m}
onChange={(e) => onChange(value.map((x, idx) => (idx === i ? Number(e.target.value) : x)))}
>
{REMINDER_OPTS.map((o) => (
<option key={o} value={o} className="bg-app-surface text-app-fg">
{reminderText(o)}
</option>
))}
</select>
<button
className="px-1 text-app-muted hover:text-app-fg"
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
aria-label={t("common.delete")}
>
✕
</button>
</div>
))}
<button
className="rounded bg-app-hover px-3 py-1.5 text-sm hover:opacity-90"
onClick={() => onChange([...value, 30])}
>
🔔 {t("settings.addReminder")}
</button>
</div>
</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";
// 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((z) => (
<option key={z} value={z} className="bg-app-surface text-app-fg">
{z}
</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={s?.default_calendar ?? ""}
onChange={(e) => patch.mutate({ default_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>
<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>
);
}