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:
@@ -13,6 +13,7 @@ export const en = {
|
||||
"common.edit": "Edit",
|
||||
"common.close": "Close",
|
||||
"common.clear": "Clear",
|
||||
"common.copy": "Copy",
|
||||
"common.back": "Back",
|
||||
"common.next": "Next",
|
||||
"common.signOut": "Sign out",
|
||||
@@ -226,6 +227,33 @@ export const en = {
|
||||
"settings.timeFormat": "Time format",
|
||||
"settings.timeFormat24": "24-hour (14:30)",
|
||||
"settings.timeFormat12": "12-hour (2:30 PM)",
|
||||
"settings.calendars": "Calendars",
|
||||
"settings.myCalendars": "My calendars",
|
||||
"settings.calendarOptions": "Calendar options",
|
||||
"settings.createCalendar": "Create calendar",
|
||||
"settings.editCalendar": "Edit calendar",
|
||||
"settings.export": "Export",
|
||||
"settings.calendarDisplay": "Calendar display",
|
||||
"settings.default": "Default",
|
||||
"settings.defaultCalendar": "Default calendar",
|
||||
"settings.firstCalendar": "First calendar",
|
||||
"settings.defaultRemindersTimed": "Default reminders for timed events",
|
||||
"settings.defaultRemindersAllday": "Default reminders for all-day events",
|
||||
"settings.addReminder": "Add reminder",
|
||||
"settings.name": "Name",
|
||||
"settings.onlyYou": "Only you can see this calendar.",
|
||||
"settings.publish": "Publish",
|
||||
"settings.publishHint": "Share this calendar (read-only) with anyone.",
|
||||
"settings.fullEventDetails": "Full event details",
|
||||
"settings.freeBusy": "Free/busy information",
|
||||
"settings.addAccountHint": "Connect an external calendar account.",
|
||||
"settings.subscriptions": "Subscriptions",
|
||||
"settings.subscriptionsHint": "Add a read-only calendar from an iCal (.ics) URL.",
|
||||
"settings.subscribe": "Subscribe",
|
||||
"settings.subscribeUrl": "iCal (.ics) URL",
|
||||
"settings.subscribed": "Subscribed",
|
||||
"settings.comingSoon": "Coming soon",
|
||||
"settings.subscribeError": "Couldn't subscribe to that URL.",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
|
||||
+39
-1
@@ -5,6 +5,7 @@ import type {
|
||||
AccountDetail,
|
||||
AccountOut,
|
||||
AttachmentOut,
|
||||
CalendarOut,
|
||||
DiscoverResponse,
|
||||
DraftInput,
|
||||
DraftOut,
|
||||
@@ -167,12 +168,49 @@ export const api = {
|
||||
// --- settings ---
|
||||
settings: () => apiFetch<UserSettings>("/settings"),
|
||||
patchSettings: (
|
||||
patch: Partial<Pick<UserSettings, "thread_order" | "tz" | "time_format" | "jitsi_server">>,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
UserSettings,
|
||||
| "thread_order"
|
||||
| "tz"
|
||||
| "time_format"
|
||||
| "jitsi_server"
|
||||
| "default_calendar"
|
||||
| "default_reminders_timed"
|
||||
| "default_reminders_allday"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||
|
||||
// --- calendars (builtin calendar plugin) ---
|
||||
calendars: () => apiFetch<CalendarOut[]>("/plugins/calendar/calendars"),
|
||||
createCalendar: (body: { name: string; color: string }) =>
|
||||
apiFetch<{ id: string }>("/plugins/calendar/calendars", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
patchCalendar: (
|
||||
id: string,
|
||||
body: { name?: string; color?: string; publish?: "off" | "full" | "busy" },
|
||||
) =>
|
||||
apiFetch<{ status: string; publish_token: string | null }>(
|
||||
`/plugins/calendar/calendars/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(body) },
|
||||
),
|
||||
deleteCalendar: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/plugins/calendar/calendars/${id}`, { method: "DELETE" }),
|
||||
exportCalendarUrl: (id: string) => `/api/plugins/calendar/calendars/${id}/export`,
|
||||
publicCalendarUrl: (token: string) =>
|
||||
`${window.location.origin}/api/plugins/calendar/public/${token}`,
|
||||
subscribeCalendar: (body: { url: string; name?: string; color?: string }) =>
|
||||
apiFetch<{ id: string; imported: number; name: string }>("/plugins/calendar/subscriptions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// --- saved searches (virtual mailboxes) ---
|
||||
savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"),
|
||||
createSavedSearch: (name: string, query: string) =>
|
||||
|
||||
@@ -148,11 +148,25 @@ export interface SessionInfo {
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarOut {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
kind: string;
|
||||
read_only: boolean;
|
||||
publish_token: string | null;
|
||||
publish_busy: boolean;
|
||||
subscription_url: string | null;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
thread_order: "asc" | "desc";
|
||||
tz: string;
|
||||
time_format: "12" | "24";
|
||||
jitsi_server: string;
|
||||
default_calendar: string;
|
||||
default_reminders_timed: number[];
|
||||
default_reminders_allday: number[];
|
||||
allowed_image_senders: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,19 @@ type Importer = (url: string) => Promise<unknown>;
|
||||
|
||||
const _cache = new Map<string, Promise<PluginMount>>();
|
||||
|
||||
// In dev, bust the browser's HTTP/module cache so an edited plugin bundle is picked
|
||||
// up on reload. The backend sends `no-cache`, but a copy the browser cached *before*
|
||||
// that header existed can still be served as "fresh" without revalidating — a unique
|
||||
// per-page-load query sidesteps it. Computed once per module load so a bundle is still
|
||||
// imported only once within a session (and the cache key stays the clean URL). Prod
|
||||
// keeps the clean, cacheable URL.
|
||||
const DEV_CACHE_BUST = import.meta.env.DEV ? `v=${Date.now()}` : "";
|
||||
|
||||
// @vite-ignore keeps Vite from trying to resolve/transform the runtime URL.
|
||||
const defaultImporter: Importer = (url) => import(/* @vite-ignore */ url);
|
||||
const defaultImporter: Importer = (url) => {
|
||||
const bustable = DEV_CACHE_BUST ? `${url}${url.includes("?") ? "&" : "?"}${DEV_CACHE_BUST}` : url;
|
||||
return import(/* @vite-ignore */ bustable);
|
||||
};
|
||||
|
||||
export function loadBundle(url: string, importer: Importer = defaultImporter): Promise<PluginMount> {
|
||||
let pending = _cache.get(url);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
*/
|
||||
|
||||
.wm-root {
|
||||
font: 15px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(var(--app-fg));
|
||||
}
|
||||
|
||||
@@ -126,3 +127,93 @@
|
||||
font-size: 14px;
|
||||
color: rgb(var(--app-muted));
|
||||
}
|
||||
|
||||
/* ── Sidebar shell ─────────────────────────────────────────────────────────
|
||||
* Shared left-panel template. Each class @applies the EXACT Tailwind utilities
|
||||
* Mail's sidebar uses (frontend/src/routes/MailLayout.tsx), so the compiled CSS
|
||||
* is byte-identical to Mail's — no hand-transcribed values that can drift.
|
||||
* `.wm-side` also pins the base font context (Lato, 16px, line-height 1.5) to
|
||||
* Mail's <body>, because a plugin bundle's own root can change the inherited
|
||||
* font size/family for anything that doesn't set its own (e.g. the theme glyph).
|
||||
* An app supplies only: switcher label + menu, primary button, the scrollable
|
||||
* body content (.wm-side-body), and the foot links. */
|
||||
.wm-side {
|
||||
@apply flex flex-col border-r border-app-border bg-app-bg;
|
||||
height: 100%;
|
||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wm-side-head {
|
||||
@apply flex items-center justify-between pt-3 px-3 pb-1;
|
||||
}
|
||||
.wm-side-switch {
|
||||
@apply relative;
|
||||
}
|
||||
.wm-side-switch > button {
|
||||
@apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wm-side-menu {
|
||||
@apply absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-app-border bg-app-elevated py-1 shadow-lg;
|
||||
}
|
||||
.wm-side-menu button {
|
||||
@apply flex w-full items-center px-3 py-1.5 text-left text-sm text-app-muted hover:bg-app-hover;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wm-side-headicons {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
.wm-side-iconbtn {
|
||||
@apply inline-flex rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wm-side-themebtn {
|
||||
@apply p-2;
|
||||
}
|
||||
.wm-side-primary {
|
||||
@apply px-3 pb-2;
|
||||
}
|
||||
.wm-side-primary > button {
|
||||
@apply flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wm-side-body {
|
||||
@apply flex-1 overflow-y-auto px-2 py-1 text-[15px];
|
||||
min-height: 0;
|
||||
}
|
||||
.wm-side-section {
|
||||
@apply px-3 pt-3 pb-1 text-[12px] font-semibold uppercase tracking-wide text-app-faint;
|
||||
}
|
||||
.wm-side-nav {
|
||||
@apply flex flex-col;
|
||||
gap: 2px;
|
||||
}
|
||||
.wm-side-item {
|
||||
@apply flex w-full items-center gap-1.5 rounded px-2.5 py-1 text-left text-app-fg hover:bg-app-hover hover:text-app-fg;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wm-side-foot {
|
||||
@apply border-t border-app-border p-2 text-[15px];
|
||||
}
|
||||
.wm-side-foot button,
|
||||
.wm-side-foot a {
|
||||
@apply flex w-full items-center gap-2.5 rounded-lg py-2 px-3 text-app-muted hover:bg-app-hover hover:text-app-fg;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
}
|
||||
.wm-side-quota {
|
||||
@apply mt-1 border-t border-app-border px-3 pt-2 pb-1 text-[13px] text-app-faint;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user