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
This commit is contained in:
2026-08-02 08:48:33 +02:00
co-authored by Claude Opus 4.8
parent e2bef3c41d
commit 57a1f1562f
10 changed files with 447 additions and 113 deletions
+323 -71
View File
@@ -6,7 +6,7 @@ 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 type { CalendarOut, ReminderPref } from "../../lib/types";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
@@ -18,13 +18,19 @@ 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="/mail" className="text-sm text-app-accent">
← {t("nav.mail")}
<Link to={from} className="text-sm text-app-accent">
← {t("nav.back")}
</Link>
<span className="font-semibold">{t("settings.title")}</span>
</header>
@@ -33,7 +39,7 @@ export function Settings() {
{SECTIONS.map((s) => (
<Link
key={s}
to={`/settings/${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"
}`}
@@ -662,6 +668,117 @@ const CAL_COLORS: [string, string][] = [
["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,
@@ -699,9 +816,41 @@ const TZ_RAW: string[] = (() => {
return [];
}
})();
const TZ_LIST = TZ_RAW.length
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 {
@@ -772,6 +921,11 @@ function CalendarList({
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"],
@@ -856,11 +1010,7 @@ function CalendarList({
</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"] }),
);
}}
onClick={() => setRemoveSub(c)}
>
{t("common.remove")}
</button>
@@ -888,6 +1038,19 @@ function CalendarList({
<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>
);
}
@@ -896,7 +1059,9 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
const t = useT();
const queryClient = useQueryClient();
const [calId, setCalId] = useState<string | null>(calendar?.id ?? null);
const editing = calId !== 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);
@@ -916,6 +1081,7 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
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.
@@ -957,43 +1123,16 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
</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")}
{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>
<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>
<ColorSelect value={color} onChange={setColor} />
</div>
<label className="block flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.name")}</span>
@@ -1049,48 +1188,81 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
<button className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onBack}>
{t("common.cancel")}
</button>
{editing && (
{!startedNew && (
<button
className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover"
onClick={() => del.mutate()}
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: number[];
onChange: (v: number[]) => void;
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-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)}
<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>
))}
</select>
<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="px-1 text-app-muted hover:text-app-fg"
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")}
>
@@ -1098,13 +1270,13 @@ function ReminderList({
</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>
<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>
);
}
@@ -1120,6 +1292,15 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
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 });
@@ -1137,9 +1318,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
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}
{TZ_LIST.map(([zone, tzLabel]) => (
<option key={zone} value={zone} className="bg-app-surface text-app-fg">
{tzLabel}
</option>
))}
</select>
@@ -1153,12 +1334,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
<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 ?? ""}
value={defaultCalValue}
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}
@@ -1178,6 +1356,80 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
/>
</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>
);
}