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
+12 -1
View File
@@ -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);
+92 -1
View File
@@ -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;
}