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"
>
{open && (
<>
setOpen(false)} />
{palette.map(([name, hex]) => (
{
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 }}
/>
))}
>
)}
);
}
function ConfirmDialog({
title,
message,
confirmLabel,
onConfirm,
onCancel,
}: {
title: string;
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const t = useT();
return (
e.stopPropagation()}
>
{title}
{message}
{t("common.cancel")}
{confirmLabel}
);
}
function SettingsToggle({
checked,
onChange,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
onChange(!checked)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
checked ? "bg-app-accent" : "bg-app-hover"
}`}
>
);
}
const TZ_RAW: string[] = (() => {
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
try {
return intl.supportedValuesOf ? intl.supportedValuesOf("timeZone") : [];
} catch {
return [];
}
})();
const TZ_ZONES = TZ_RAW.length
? TZ_RAW
: ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"];
// The GMT offset code (e.g. "GMT+2") for a zone, as shown next to its name.
function tzGmt(zone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "shortOffset",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
} catch {
return "";
}
}
function tzLongName(zone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "long",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? zone;
} catch {
return zone;
}
}
// [zone, "Long Name (GMT+2)"] sorted by offset then label, computed once.
const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => {
const gmt = tzGmt(z);
return [z, gmt ? `${tzLongName(z)} (${gmt})` : tzLongName(z)];
}).sort((a, b) => a[1].localeCompare(b[1]));
const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880];
function reminderText(m: number): string {
if (m === 0) return "At time of event";
if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`;
if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`;
return `${m} minutes before`;
}
function CalendarsSection() {
const t = useT();
const [searchParams] = useSearchParams();
const [tab, setTab] = useState<"calendars" | "options">("calendars");
const [view, setView] = useState
(
searchParams.get("create") === "1" ? "new" : null,
);
const calendars = useQuery({ queryKey: ["calendars"], queryFn: api.calendars });
const list = calendars.data ?? [];
return (
{(["calendars", "options"] as const).map((tb) => (
{
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")}
))}
{tab === "options" ? (
) : view ? (
setView(null)} />
) : (
setView("new")} onEdit={setView} />
)}
);
}
function CalendarList({
calendars,
onCreate,
onEdit,
}: {
calendars: CalendarOut[];
onCreate: () => void;
onEdit: (c: CalendarOut) => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const [subUrl, setSubUrl] = useState("");
const subscribe = useMutation({
mutationFn: () => api.subscribeCalendar({ url: subUrl.trim() }),
onSuccess: () => {
setSubUrl("");
queryClient.invalidateQueries({ queryKey: ["calendars"] });
},
});
const [removeSub, setRemoveSub] = useState(null);
const removeCal = useMutation({
mutationFn: (id: string) => api.deleteCalendar(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }),
});
const subscriptions = calendars.filter((c) => c.subscription_url !== null);
const accounts: [string, string][] = [
["Google", "google"],
["iCloud", "icloud"],
["CalDAV", "caldav"],
];
// 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,
});
return (
{t("settings.calendars")}
+ {t("settings.createCalendar")}
{calendars
.filter((c) => c.subscription_url === null)
.map((c) => (
onEdit(c)}>
{c.name}
onEdit(c)}
aria-label={t("settings.editCalendar")}
>
›
))}
{t("settings.addAccount")}
{t("settings.addAccountHint")}
{(davAccounts.data ?? []).length > 0 && (
{(davAccounts.data ?? []).map((a) => (
{a.username || a.provider}
{a.provider === "icloud" ? "iCloud" : "CalDAV"} ·{" "}
{t("settings.calendarCount", { count: a.calendars.length })}
disconnect.mutate(a.account_id)}
>
{t("common.remove")}
))}
)}
{accounts.map(([label, key]) => (
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"
}`}
>
{label}
))}
{connecting && (
{t("settings.connectTitle", { provider: connecting === "icloud" ? "iCloud" : "CalDAV" })}
{connecting === "caldav" && (
setDavUrl(e.target.value)}
/>
)}
setDavUser(e.target.value)}
autoFocus
/>
setDavPass(e.target.value)}
/>
{connecting === "icloud" && (
{t("settings.icloudHint")}
)}
connect.mutate()}
>
{connect.isPending ? t("settings.connecting") : t("settings.connect")}
{t("common.cancel")}
{connect.isError && (
{(() => {
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");
})()}
)}
)}
{t("settings.subscriptions")}
{t("settings.subscriptionsHint")}
{subscriptions.length > 0 && (
{subscriptions.map((c) => (
{c.name}
{c.subscription_url}
setRemoveSub(c)}
>
{t("common.remove")}
))}
)}
setSubUrl(e.target.value)}
/>
subscribe.mutate()}
>
{t("settings.subscribe")}
{subscribe.isError && (
{t("settings.subscribeError")}
)}
{removeSub && (
setRemoveSub(null)}
onConfirm={() => {
removeCal.mutate(removeSub.id);
setRemoveSub(null);
}}
/>
)}
);
}
function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBack: () => void }) {
const t = useT();
const queryClient = useQueryClient();
const [calId, setCalId] = useState(calendar?.id ?? null);
// Whether we opened an existing calendar to edit — fixed for the form's lifetime, so
// auto-creating on a Publish toggle doesn't flip the "Create calendar" heading.
const startedNew = calendar === null;
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]);
const [publishToken, setPublishToken] = useState(calendar?.publish_token ?? null);
const [publishBusy, setPublishBusy] = useState(calendar?.publish_busy ?? false);
const fullDetails = publishToken !== null && !publishBusy;
const freeBusy = publishToken !== null && publishBusy;
const done = () => {
queryClient.invalidateQueries({ queryKey: ["calendars"] });
onBack();
};
const save = useMutation({
mutationFn: async () => {
if (calId) await api.patchCalendar(calId, { name: name.trim(), color });
else await api.createCalendar({ name: name.trim(), color });
},
onSuccess: done,
});
const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done });
const [confirmDel, setConfirmDel] = useState(false);
// Turning on a Publish option auto-creates the calendar first (create mode) so a
// shareable .ics link can be shown immediately, then flips the public feed on/off.
const setPublish = useMutation({
mutationFn: async (mode: "off" | "full" | "busy") => {
let id = calId;
if (!id) {
const created = await api.createCalendar({ name: name.trim(), color });
id = created.id;
setCalId(id);
}
const res = await api.patchCalendar(id, { publish: mode });
return { mode, token: res.publish_token };
},
onSuccess: ({ mode, token }) => {
setPublishToken(mode === "off" ? null : token);
setPublishBusy(mode === "busy");
queryClient.invalidateQueries({ queryKey: ["calendars"] });
},
});
const publicUrl = publishToken ? api.publicCalendarUrl(publishToken) : "";
const linkRow = (
{publicUrl}
void navigator.clipboard.writeText(publicUrl)}
>
{t("common.copy")}
);
return (
{startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}
{t("settings.publish")}
{t("settings.publishHint")}
{t("settings.fullEventDetails")}
setPublish.mutate(v ? "full" : "off")}
/>
{fullDetails && linkRow}
{t("settings.freeBusy")}
setPublish.mutate(v ? "busy" : "off")}
/>
{freeBusy && linkRow}
save.mutate()}
>
{t("common.save")}
{t("common.cancel")}
{!startedNew && (
setConfirmDel(true)}
>
{t("common.delete")}
)}
{confirmDel && (
setConfirmDel(false)}
onConfirm={() => {
setConfirmDel(false);
del.mutate();
}}
/>
)}
);
}
const REM_SELECT_CLASS =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm text-app-fg outline-none focus:border-app-accent";
function ReminderList({
label,
value,
onChange,
}: {
label: string;
value: ReminderPref[];
onChange: (v: ReminderPref[]) => void;
}) {
const t = useT();
const update = (i: number, patch: Partial) =>
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
return (
{label}
{value.map((r, i) => (
update(i, { method: e.target.value as ReminderPref["method"] })}
>
{t("settings.showAlert")}
{t("settings.emailMe")}
update(i, { minutes: Number(e.target.value) })}
>
{REMINDER_OPTS.map((o) => (
{reminderText(o)}
))}
onChange(value.filter((_, idx) => idx !== i))}
aria-label={t("common.delete")}
>
✕
))}
onChange([...value, { method: "alert", minutes: 60 }])}
>
{t("settings.addReminder")}
);
}
function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const s = settings.data;
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
// When no default is set, fall back to "Personal" (the bootstrapped calendar) so the
// dropdown shows Personal rather than a generic placeholder; else the first calendar.
const preferredDefault =
calendars.find((c) => c.name === "Personal") ?? calendars[0];
const defaultCalValue =
s?.default_calendar && calendars.some((c) => c.id === s.default_calendar)
? s.default_calendar
: (preferredDefault?.id ?? "");
// Detect and persist the browser time zone on first visit (backend default is UTC).
useEffect(() => {
if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [s?.tz]);
return (
{t("settings.calendarDisplay")}
{t("settings.timezone")}
patch.mutate({ tz: e.target.value })}
>
{TZ_LIST.map(([zone, tzLabel]) => (
{tzLabel}
))}
{t("settings.default")}
{t("settings.defaultCalendar")}
patch.mutate({ default_calendar: e.target.value })}
>
{calendars.map((c) => (
{c.name}
))}
patch.mutate({ default_reminders_timed: v })}
/>
patch.mutate({ default_reminders_allday: v })}
/>
{t("settings.invitations")}
{t("settings.inviteWhen")}
{(
[
["add_update", t("settings.inviteAddUpdate")],
["update_only", t("settings.inviteUpdateOnly")],
["none", t("settings.inviteNone")],
] as const
).map(([val, txt]) => (
patch.mutate({ invitation_policy: val })}
/>
{txt}
))}
{t("settings.addNewFrom")}
patch.mutate({ invitation_from: e.target.value as "contacts" | "anyone" })
}
>
{t("settings.myContacts")}
{t("settings.anyone")}
{t("settings.addToCalendar")}
patch.mutate({ invitation_calendar: e.target.value })}
>
{t("settings.firstCalendar")}
{calendars.map((c) => (
{c.name}
))}
{t("settings.alsoFile")}
patch.mutate({ invitation_file: v })}
/>
);
}