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
@@ -26,6 +26,9 @@ class SettingsOut(BaseModel):
|
|||||||
tz: str = "UTC"
|
tz: str = "UTC"
|
||||||
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
|
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
|
||||||
jitsi_server: str = "" # blank => https://meet.jit.si/
|
jitsi_server: str = "" # blank => https://meet.jit.si/
|
||||||
|
default_calendar: str = "" # calendar id new events default to (blank => first)
|
||||||
|
default_reminders_timed: list[int] = [] # minutes-before defaults for timed events
|
||||||
|
default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events
|
||||||
allowed_image_senders: list[str] = []
|
allowed_image_senders: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ class SettingsPatch(BaseModel):
|
|||||||
tz: str | None = None
|
tz: str | None = None
|
||||||
time_format: str | None = None
|
time_format: str | None = None
|
||||||
jitsi_server: str | None = None
|
jitsi_server: str | None = None
|
||||||
|
default_calendar: str | None = None
|
||||||
|
default_reminders_timed: list[int] | None = None
|
||||||
|
default_reminders_allday: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
class QuotaOut(BaseModel):
|
class QuotaOut(BaseModel):
|
||||||
@@ -56,6 +62,10 @@ def add_allowed_image_sender(user: User, email: str) -> None:
|
|||||||
user.settings = {**user.settings, "allowed_image_senders": current}
|
user.settings = {**user.settings, "allowed_image_senders": current}
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: object) -> list[int]:
|
||||||
|
return [int(x) for x in value] if isinstance(value, list) else []
|
||||||
|
|
||||||
|
|
||||||
def _settings_out(user: User) -> SettingsOut:
|
def _settings_out(user: User) -> SettingsOut:
|
||||||
s = user.settings
|
s = user.settings
|
||||||
return SettingsOut(
|
return SettingsOut(
|
||||||
@@ -63,6 +73,9 @@ def _settings_out(user: User) -> SettingsOut:
|
|||||||
tz=str(s.get("tz", "UTC")),
|
tz=str(s.get("tz", "UTC")),
|
||||||
time_format=str(s.get("time_format", "24")),
|
time_format=str(s.get("time_format", "24")),
|
||||||
jitsi_server=str(s.get("jitsi_server", "")),
|
jitsi_server=str(s.get("jitsi_server", "")),
|
||||||
|
default_calendar=str(s.get("default_calendar", "")),
|
||||||
|
default_reminders_timed=_int_list(s.get("default_reminders_timed")),
|
||||||
|
default_reminders_allday=_int_list(s.get("default_reminders_allday")),
|
||||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,6 +105,12 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
|
|||||||
updates["time_format"] = body.time_format
|
updates["time_format"] = body.time_format
|
||||||
if body.jitsi_server is not None:
|
if body.jitsi_server is not None:
|
||||||
updates["jitsi_server"] = body.jitsi_server.strip()
|
updates["jitsi_server"] = body.jitsi_server.strip()
|
||||||
|
if body.default_calendar is not None:
|
||||||
|
updates["default_calendar"] = body.default_calendar
|
||||||
|
if body.default_reminders_timed is not None:
|
||||||
|
updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed]
|
||||||
|
if body.default_reminders_allday is not None:
|
||||||
|
updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday]
|
||||||
user.settings = updates
|
user.settings = updates
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return _settings_out(user)
|
return _settings_out(user)
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ cd backend && alembic upgrade head && cd .. # migrations against 5433
|
|||||||
# Terminal 1 — API
|
# Terminal 1 — API
|
||||||
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
|
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
|
||||||
|
|
||||||
# Terminal 2 — worker
|
# Terminal 2 — worker (sync supervisor + arq job consumers in one process)
|
||||||
arq workers.worker.WorkerSettings # from backend/
|
cd backend && WORKER_ROLE=all python -m workers.run
|
||||||
|
|
||||||
# Terminal 3 — frontend
|
# Terminal 3 — frontend
|
||||||
cd frontend && npm run dev # Vite on 5173, proxies /api -> 8000
|
cd frontend && npm run dev # Vite on 5173, proxies /api -> 8000
|
||||||
@@ -194,8 +194,12 @@ reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
|
|||||||
cd backend && alembic upgrade head
|
cd backend && alembic upgrade head
|
||||||
api:
|
api:
|
||||||
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
|
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
|
||||||
worker:
|
worker: ## run both sync + jobs in one process (dev)
|
||||||
cd backend && arq workers.worker.WorkerSettings
|
cd backend && WORKER_ROLE=all python -m workers.run
|
||||||
|
worker-sync: ## run only the account-sync supervisor
|
||||||
|
cd backend && WORKER_ROLE=sync python -m workers.run
|
||||||
|
worker-jobs: ## run only the arq job consumers (interactive + bulk)
|
||||||
|
cd backend && WORKER_ROLE=jobs python -m workers.run
|
||||||
web:
|
web:
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const en = {
|
|||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
"common.clear": "Clear",
|
"common.clear": "Clear",
|
||||||
|
"common.copy": "Copy",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
"common.signOut": "Sign out",
|
"common.signOut": "Sign out",
|
||||||
@@ -226,6 +227,33 @@ export const en = {
|
|||||||
"settings.timeFormat": "Time format",
|
"settings.timeFormat": "Time format",
|
||||||
"settings.timeFormat24": "24-hour (14:30)",
|
"settings.timeFormat24": "24-hour (14:30)",
|
||||||
"settings.timeFormat12": "12-hour (2:30 PM)",
|
"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.integrations": "Integrations",
|
||||||
"settings.jitsi": "Jitsi Meet",
|
"settings.jitsi": "Jitsi Meet",
|
||||||
"settings.jitsiHint":
|
"settings.jitsiHint":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
AccountDetail,
|
AccountDetail,
|
||||||
AccountOut,
|
AccountOut,
|
||||||
AttachmentOut,
|
AttachmentOut,
|
||||||
|
CalendarOut,
|
||||||
DiscoverResponse,
|
DiscoverResponse,
|
||||||
DraftInput,
|
DraftInput,
|
||||||
DraftOut,
|
DraftOut,
|
||||||
@@ -167,12 +168,49 @@ export const api = {
|
|||||||
// --- settings ---
|
// --- settings ---
|
||||||
settings: () => apiFetch<UserSettings>("/settings"),
|
settings: () => apiFetch<UserSettings>("/settings"),
|
||||||
patchSettings: (
|
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) }),
|
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
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) ---
|
// --- saved searches (virtual mailboxes) ---
|
||||||
savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"),
|
savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"),
|
||||||
createSavedSearch: (name: string, query: string) =>
|
createSavedSearch: (name: string, query: string) =>
|
||||||
|
|||||||
@@ -148,11 +148,25 @@ export interface SessionInfo {
|
|||||||
current: boolean;
|
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 {
|
export interface UserSettings {
|
||||||
thread_order: "asc" | "desc";
|
thread_order: "asc" | "desc";
|
||||||
tz: string;
|
tz: string;
|
||||||
time_format: "12" | "24";
|
time_format: "12" | "24";
|
||||||
jitsi_server: string;
|
jitsi_server: string;
|
||||||
|
default_calendar: string;
|
||||||
|
default_reminders_timed: number[];
|
||||||
|
default_reminders_allday: number[];
|
||||||
allowed_image_senders: string[];
|
allowed_image_senders: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,19 @@ type Importer = (url: string) => Promise<unknown>;
|
|||||||
|
|
||||||
const _cache = new Map<string, Promise<PluginMount>>();
|
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.
|
// @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> {
|
export function loadBundle(url: string, importer: Importer = defaultImporter): Promise<PluginMount> {
|
||||||
let pending = _cache.get(url);
|
let pending = _cache.get(url);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.wm-root {
|
.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));
|
color: rgb(var(--app-fg));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +127,93 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgb(var(--app-muted));
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { useEffect, useState } from "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 { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import type { CalendarOut } from "../../lib/types";
|
||||||
import { useMe } from "../../lib/auth";
|
import { useMe } from "../../lib/auth";
|
||||||
import { useTheme } from "../../lib/theme";
|
import { useTheme } from "../../lib/theme";
|
||||||
import { setTimeFormat } from "../../lib/timeFormat";
|
import { setTimeFormat } from "../../lib/timeFormat";
|
||||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
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];
|
type Section = (typeof SECTIONS)[number];
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
@@ -45,6 +46,7 @@ export function Settings() {
|
|||||||
{section === "profile" && <ProfileSection />}
|
{section === "profile" && <ProfileSection />}
|
||||||
{section === "security" && <SecuritySection />}
|
{section === "security" && <SecuritySection />}
|
||||||
{section === "accounts" && <AccountsSection />}
|
{section === "accounts" && <AccountsSection />}
|
||||||
|
{section === "calendars" && <CalendarsSection />}
|
||||||
{section === "appearance" && <AppearanceSection />}
|
{section === "appearance" && <AppearanceSection />}
|
||||||
{section === "integrations" && <IntegrationsSection />}
|
{section === "integrations" && <IntegrationsSection />}
|
||||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
||||||
@@ -648,3 +650,534 @@ function IntegrationsSection() {
|
|||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""calendar: per-calendar publish token + free/busy flag + subscription url (SPEC §A)
|
||||||
|
|
||||||
|
Revision ID: calendar0004
|
||||||
|
Revises: calendar0003
|
||||||
|
Create Date: 2026-08-01
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "calendar0004"
|
||||||
|
down_revision: str | None = "calendar0003"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# publish_token: unguessable slug for the public read-only .ics feed (NULL = private).
|
||||||
|
# publish_busy: True => the public feed exposes free/busy blocks only, not details.
|
||||||
|
# subscription_url: source .ics URL for a subscribed (read-only) external calendar.
|
||||||
|
op.execute("ALTER TABLE plg_calendar_calendars ADD COLUMN publish_token text")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE plg_calendar_calendars "
|
||||||
|
"ADD COLUMN publish_busy boolean NOT NULL DEFAULT false"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE plg_calendar_calendars ADD COLUMN subscription_url text")
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX plg_calendar_publish_token_idx "
|
||||||
|
"ON plg_calendar_calendars (publish_token) WHERE publish_token IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS plg_calendar_publish_token_idx")
|
||||||
|
op.execute("ALTER TABLE plg_calendar_calendars DROP COLUMN IF EXISTS subscription_url")
|
||||||
|
op.execute("ALTER TABLE plg_calendar_calendars DROP COLUMN IF EXISTS publish_busy")
|
||||||
|
op.execute("ALTER TABLE plg_calendar_calendars DROP COLUMN IF EXISTS publish_token")
|
||||||
@@ -8,6 +8,7 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -15,7 +16,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -29,6 +30,7 @@ from webmail_plugin_calendar.schemas import (
|
|||||||
EditFollowingIn,
|
EditFollowingIn,
|
||||||
EventIn,
|
EventIn,
|
||||||
EventPatch,
|
EventPatch,
|
||||||
|
SubscribeIn,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -66,6 +68,69 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
|
|||||||
|
|
||||||
# --- calendars ---------------------------------------------------------------
|
# --- calendars ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_publish_token(user_id: uuid.UUID, calendar_id: uuid.UUID) -> str:
|
||||||
|
"""Vault-seal {user_id, calendar_id} so the public /public/{token} feed can open
|
||||||
|
the right RLS session without exposing the ids or needing auth."""
|
||||||
|
payload = json.dumps({"u": str(user_id), "c": str(calendar_id)}).encode()
|
||||||
|
return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_publish_token(token: str) -> tuple[uuid.UUID, uuid.UUID] | None:
|
||||||
|
try:
|
||||||
|
pad = "=" * (-len(token) % 4)
|
||||||
|
data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token + pad)))
|
||||||
|
return uuid.UUID(data["u"]), uuid.UUID(data["c"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes:
|
||||||
|
"""Build a VCALENDAR .ics for a calendar's base events (busy_only strips detail)."""
|
||||||
|
from icalendar import Calendar as ICalendar
|
||||||
|
from icalendar import Event as IEvent
|
||||||
|
from icalendar.prop import vRecur
|
||||||
|
|
||||||
|
cal = ICalendar()
|
||||||
|
cal.add("prodid", "-//Webmail//Calendar//EN")
|
||||||
|
cal.add("version", "2.0")
|
||||||
|
cal.add("x-wr-calname", name)
|
||||||
|
for r in rows:
|
||||||
|
ev = IEvent()
|
||||||
|
ev.add("uid", r["ical_uid"])
|
||||||
|
ev.add("dtstamp", datetime.now(UTC))
|
||||||
|
ev.add("dtstart", _aware(r["dtstart"])) # type: ignore[arg-type]
|
||||||
|
if r["dtend"]:
|
||||||
|
ev.add("dtend", _aware(r["dtend"])) # type: ignore[arg-type]
|
||||||
|
if busy_only:
|
||||||
|
ev.add("summary", "Busy")
|
||||||
|
else:
|
||||||
|
ev.add("summary", r["summary"] or "")
|
||||||
|
if r.get("description"):
|
||||||
|
ev.add("description", r["description"])
|
||||||
|
if r.get("location"):
|
||||||
|
ev.add("location", r["location"])
|
||||||
|
if r["rrule"]:
|
||||||
|
ev.add("rrule", vRecur.from_ical(str(r["rrule"])))
|
||||||
|
cal.add_component(ev)
|
||||||
|
return cal.to_ical()
|
||||||
|
|
||||||
|
|
||||||
|
async def _calendar_event_rows(s: AsyncSession, calendar_id: uuid.UUID) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
dict(r)
|
||||||
|
for r in (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"SELECT ical_uid, summary, description, location, dtstart, dtend, all_day, "
|
||||||
|
"rrule FROM plg_calendar_events "
|
||||||
|
"WHERE calendar_id = :id AND recurrence_id IS NULL"
|
||||||
|
),
|
||||||
|
{"id": str(calendar_id)},
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendars")
|
@router.get("/calendars")
|
||||||
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
|
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
|
||||||
async with _SDK.session(user_id) as s:
|
async with _SDK.session(user_id) as s:
|
||||||
@@ -73,8 +138,8 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
|
|||||||
rows = (
|
rows = (
|
||||||
await s.execute(
|
await s.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, name, color, kind, read_only FROM plg_calendar_calendars "
|
"SELECT id, name, color, kind, read_only, publish_token, publish_busy, "
|
||||||
"ORDER BY name"
|
"subscription_url FROM plg_calendar_calendars ORDER BY name"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
@@ -85,6 +150,9 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
|
|||||||
"color": r["color"],
|
"color": r["color"],
|
||||||
"kind": r["kind"],
|
"kind": r["kind"],
|
||||||
"read_only": r["read_only"],
|
"read_only": r["read_only"],
|
||||||
|
"publish_token": r["publish_token"],
|
||||||
|
"publish_busy": r["publish_busy"],
|
||||||
|
"subscription_url": r["subscription_url"],
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -111,19 +179,39 @@ async def create_calendar(body: CalendarIn, user_id: CurrentUserId) -> dict[str,
|
|||||||
@router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
|
@router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
|
||||||
async def patch_calendar(
|
async def patch_calendar(
|
||||||
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
|
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str | None]:
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
if not fields:
|
# `publish` is an action, not a column: off => unpublish, full/busy => publish.
|
||||||
|
publish = fields.pop("publish", None)
|
||||||
|
if not fields and publish is None:
|
||||||
return {"status": "noop"}
|
return {"status": "noop"}
|
||||||
set_sql = ", ".join(f"{k} = :{k}" for k in fields)
|
|
||||||
async with _SDK.session(user_id) as s:
|
async with _SDK.session(user_id) as s:
|
||||||
|
set_parts = [f"{k} = :{k}" for k in fields]
|
||||||
|
params: dict[str, object | None] = {**fields, "id": str(calendar_id)}
|
||||||
|
token: str | None = None
|
||||||
|
if publish is not None:
|
||||||
|
if publish == "off":
|
||||||
|
set_parts += ["publish_token = NULL"]
|
||||||
|
elif publish in ("full", "busy"):
|
||||||
|
existing = (
|
||||||
|
await s.execute(
|
||||||
|
text("SELECT publish_token FROM plg_calendar_calendars WHERE id = :id"),
|
||||||
|
{"id": str(calendar_id)},
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
token = existing or _make_publish_token(user_id, calendar_id)
|
||||||
|
set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"]
|
||||||
|
params["ptok"] = token
|
||||||
|
params["pbusy"] = publish == "busy"
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="bad_publish")
|
||||||
result = await s.execute(
|
result = await s.execute(
|
||||||
text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"),
|
text(f"UPDATE plg_calendar_calendars SET {', '.join(set_parts)} WHERE id = :id"),
|
||||||
{**fields, "id": str(calendar_id)},
|
params,
|
||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
raise HTTPException(status_code=404, detail="calendar_not_found")
|
raise HTTPException(status_code=404, detail="calendar_not_found")
|
||||||
return {"status": "ok"}
|
return {"status": "ok", "publish_token": token}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
|
@router.delete("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
|
||||||
@@ -137,6 +225,103 @@ async def delete_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> dic
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendars/{calendar_id}/export")
|
||||||
|
async def export_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> Response:
|
||||||
|
"""Download a calendar's events as a standard .ics file (VCALENDAR)."""
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
name = (
|
||||||
|
await s.execute(
|
||||||
|
text("SELECT name FROM plg_calendar_calendars WHERE id = :id"),
|
||||||
|
{"id": str(calendar_id)},
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if name is None:
|
||||||
|
raise HTTPException(status_code=404, detail="calendar_not_found")
|
||||||
|
rows = await _calendar_event_rows(s, calendar_id)
|
||||||
|
|
||||||
|
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(name)) or "calendar"
|
||||||
|
return Response(
|
||||||
|
content=_calendar_ics(name, rows, busy_only=False),
|
||||||
|
media_type="text/calendar",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe}.ics"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscriptions", dependencies=[Depends(require_csrf)])
|
||||||
|
async def subscribe_calendar(body: SubscribeIn, user_id: CurrentUserId) -> dict[str, str | int]:
|
||||||
|
"""Subscribe to an external .ics URL: fetch it (SSRF-guarded), then import its
|
||||||
|
events into a new read-only calendar. One-time import (re-sync is A3)."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from icalendar import Calendar as ICalendar
|
||||||
|
|
||||||
|
raw = await _SDK.http_get(body.url)
|
||||||
|
if raw is None:
|
||||||
|
raise HTTPException(status_code=400, detail="fetch_failed")
|
||||||
|
try:
|
||||||
|
ics = ICalendar.from_ical(raw)
|
||||||
|
except Exception as exc: # noqa: BLE001 - malformed feed
|
||||||
|
raise HTTPException(status_code=400, detail="bad_ics") from exc
|
||||||
|
|
||||||
|
name = body.name.strip() or str(ics.get("x-wr-calname") or "") or urlparse(body.url).netloc
|
||||||
|
events = list(ics.walk("VEVENT"))
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
try:
|
||||||
|
cal_id = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_calendar_calendars "
|
||||||
|
"(user_id, name, color, kind, read_only, subscription_url) "
|
||||||
|
"VALUES (:u, :n, :c, 'ics', true, :url) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": str(user_id), "n": name, "c": body.color, "url": body.url},
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail="calendar_exists") from exc
|
||||||
|
count = 0
|
||||||
|
for comp in events:
|
||||||
|
start = comp.get("dtstart")
|
||||||
|
if start is None:
|
||||||
|
continue
|
||||||
|
dt = start.dt
|
||||||
|
all_day = not isinstance(dt, datetime)
|
||||||
|
if not isinstance(dt, datetime):
|
||||||
|
dt = datetime(dt.year, dt.month, dt.day)
|
||||||
|
dtstart = _aware(dt)
|
||||||
|
end = comp.get("dtend")
|
||||||
|
dtend = None
|
||||||
|
if end is not None:
|
||||||
|
ed = end.dt
|
||||||
|
if not isinstance(ed, datetime):
|
||||||
|
ed = datetime(ed.year, ed.month, ed.day)
|
||||||
|
dtend = _aware(ed)
|
||||||
|
rrule = comp.get("rrule")
|
||||||
|
rrule_str = rrule.to_ical().decode() if rrule is not None else None
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_calendar_events "
|
||||||
|
"(user_id, calendar_id, ical_uid, summary, description, location, dtstart, "
|
||||||
|
" dtend, all_day, rrule) VALUES "
|
||||||
|
"(:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :rrule)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"u": str(user_id),
|
||||||
|
"cal": str(cal_id),
|
||||||
|
"uid": str(comp.get("uid") or f"{uuid.uuid4()}@import"),
|
||||||
|
"sum": str(comp.get("summary") or ""),
|
||||||
|
"desc": str(comp.get("description") or ""),
|
||||||
|
"loc": str(comp.get("location") or ""),
|
||||||
|
"start": dtstart,
|
||||||
|
"end": dtend,
|
||||||
|
"allday": all_day,
|
||||||
|
"rrule": rrule_str,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
return {"id": str(cal_id), "imported": count, "name": name}
|
||||||
|
|
||||||
|
|
||||||
# --- events ------------------------------------------------------------------
|
# --- events ------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/events")
|
@router.get("/events")
|
||||||
@@ -144,8 +329,21 @@ async def list_events(
|
|||||||
user_id: CurrentUserId,
|
user_id: CurrentUserId,
|
||||||
start: datetime = Query(...),
|
start: datetime = Query(...),
|
||||||
end: datetime = Query(...),
|
end: datetime = Query(...),
|
||||||
|
q: str = Query(""),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
"""List events overlapping [start, end). With ``q`` set, only events whose
|
||||||
|
summary/location/description match (case-insensitive) are returned — used by
|
||||||
|
the calendar search panel over a wide date window."""
|
||||||
range_start, range_end = _aware(start), _aware(end)
|
range_start, range_end = _aware(start), _aware(end)
|
||||||
|
params: dict[str, object] = {"end": range_end}
|
||||||
|
where = "WHERE e.dtstart < :end"
|
||||||
|
term = q.strip()
|
||||||
|
if term:
|
||||||
|
where += (
|
||||||
|
" AND (e.summary ILIKE :q OR e.location ILIKE :q "
|
||||||
|
"OR COALESCE(e.description, '') ILIKE :q)"
|
||||||
|
)
|
||||||
|
params["q"] = f"%{term}%"
|
||||||
async with _SDK.session(user_id) as s:
|
async with _SDK.session(user_id) as s:
|
||||||
rows = (
|
rows = (
|
||||||
await s.execute(
|
await s.execute(
|
||||||
@@ -158,9 +356,9 @@ async def list_events(
|
|||||||
"COALESCE(e.color, c.color) AS color "
|
"COALESCE(e.color, c.color) AS color "
|
||||||
"FROM plg_calendar_events e "
|
"FROM plg_calendar_events e "
|
||||||
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||||
"WHERE e.dtstart < :end"
|
f"{where}"
|
||||||
),
|
),
|
||||||
{"end": range_end},
|
params,
|
||||||
)
|
)
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
base = [
|
base = [
|
||||||
@@ -498,6 +696,33 @@ async def delete_event(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- public read-only .ics feed (published calendars; no login) ---------------
|
||||||
|
|
||||||
|
@router.get("/public/{token}")
|
||||||
|
async def public_calendar(token: str) -> Response:
|
||||||
|
parsed = _read_publish_token(token)
|
||||||
|
if parsed is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
user_id, cal_id = parsed
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
row = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"SELECT name, publish_token, publish_busy "
|
||||||
|
"FROM plg_calendar_calendars WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"id": str(cal_id)},
|
||||||
|
)
|
||||||
|
).mappings().first()
|
||||||
|
if row is None or row["publish_token"] is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
rows = await _calendar_event_rows(s, cal_id)
|
||||||
|
return Response(
|
||||||
|
content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]),
|
||||||
|
media_type="text/calendar",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- RSVP (public: guests respond from the invitation email, no login) --------
|
# --- RSVP (public: guests respond from the invitation email, no login) --------
|
||||||
|
|
||||||
def _rsvp_page(message: str) -> str:
|
def _rsvp_page(message: str) -> str:
|
||||||
@@ -553,4 +778,10 @@ async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
|
|||||||
async def bundle(filename: str) -> FileResponse:
|
async def bundle(filename: str) -> FileResponse:
|
||||||
if filename != "page.js":
|
if filename != "page.js":
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
return FileResponse(_UI / filename, media_type="text/javascript")
|
# `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap),
|
||||||
|
# so an edited bundle is picked up on the next page reload instead of a stale copy.
|
||||||
|
return FileResponse(
|
||||||
|
_UI / filename,
|
||||||
|
media_type="text/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ class CalendarIn(BaseModel):
|
|||||||
class CalendarPatch(BaseModel):
|
class CalendarPatch(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
color: str | None = None
|
color: str | None = None
|
||||||
|
publish: str | None = None # "off" | "full" | "busy" — public .ics feed control
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeIn(BaseModel):
|
||||||
|
url: str # source .ics URL to subscribe to (read-only external calendar)
|
||||||
|
name: str = "" # optional calendar name (defaults to the feed's title / host)
|
||||||
|
color: str = "#6b7280"
|
||||||
|
|
||||||
|
|
||||||
class Attendee(BaseModel):
|
class Attendee(BaseModel):
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ let HOUR12 = false;
|
|||||||
// Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US.
|
// Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US.
|
||||||
const dateLocale = () => (HOUR12 ? "en-US" : "en-GB");
|
const dateLocale = () => (HOUR12 ? "en-US" : "en-GB");
|
||||||
const pad2 = (n) => String(n).padStart(2, "0");
|
const pad2 = (n) => String(n).padStart(2, "0");
|
||||||
const SIDEBAR_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>`;
|
// Icons copied from Mail (frontend/icons/*.svg) so the two apps match exactly.
|
||||||
|
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
|
||||||
|
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
|
||||||
|
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
|
||||||
|
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
|
||||||
|
const COMPOSE_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="m14.515.456l.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.55 1.55 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474a.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.54 1.54 0 0 1 1.19 0c.189.078.361.192.505.337m-3.322 3.008l-3.67 3.669a.2.2 0 0 0-.057.096L6.97 8.965l1.736-.496a.2.2 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28l1.122-1.122a.22.22 0 0 0 .065-.157a.22.22 0 0 0-.065-.157l-.965-.966a.22.22 0 0 0-.157-.065a.23.23 0 0 0-.157.065"/><path d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.75 1.75 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25"/></svg>`;
|
||||||
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
|
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
|
||||||
const hhmm = (d) => d.toLocaleTimeString([], { hour: HOUR12 ? "numeric" : "2-digit", minute: "2-digit", hour12: HOUR12 });
|
const hhmm = (d) => d.toLocaleTimeString([], { hour: HOUR12 ? "numeric" : "2-digit", minute: "2-digit", hour12: HOUR12 });
|
||||||
// A clock label for "HH"/"MM" honouring the 12h/24h preference (form time dropdown).
|
// A clock label for "HH"/"MM" honouring the 12h/24h preference (form time dropdown).
|
||||||
@@ -186,61 +191,82 @@ function tzEntries(current) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CSS = `
|
const CSS = `
|
||||||
.cal-app{display:flex;flex-direction:column;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font:14px system-ui,-apple-system,sans-serif}
|
.cal-app{display:flex;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
|
||||||
|
.cal-maincol{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||||
|
.cal-mainarea{flex:1;min-height:0;display:flex}
|
||||||
.cal-topbar{display:flex;align-items:center;gap:10px;height:48px;padding:0 12px;flex:0 0 auto}
|
.cal-topbar{display:flex;align-items:center;gap:10px;height:48px;padding:0 12px;flex:0 0 auto}
|
||||||
.cal-body{display:flex;flex:1;min-height:0}
|
/* Left panel styling lives in the shared sidebar shell (.wm-side-*, plugin-ui.css),
|
||||||
.cal-left{flex:0 0 auto;border-right:1px solid rgb(var(--app-border));display:flex;flex-direction:column;overflow:auto}
|
which @applies Mail's exact Tailwind utilities + pins the 16px/Lato base context. */
|
||||||
.cal-lefthead{display:flex;align-items:center;gap:4px;padding:8px 8px 2px}
|
.cal-secrow{display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
|
||||||
|
.cal-secrow:hover .cal-secadd{opacity:1}
|
||||||
|
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
||||||
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
|
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
|
||||||
.cal-resizer:hover::after{background:#3b82f6}
|
.cal-resizer:hover::after{background:#3b82f6}
|
||||||
.cal-themebtn{font-size:15px;line-height:1}
|
|
||||||
.cal-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
.cal-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||||
.cal-right{width:320px;flex:0 0 auto;border-left:1px solid rgb(var(--app-border));overflow:auto}
|
.cal-right{width:320px;flex:0 0 auto;border-left:1px solid rgb(var(--app-border));overflow:auto}
|
||||||
|
/* search results panel */
|
||||||
|
.cal-srch{font-size:13px;padding-bottom:24px}
|
||||||
|
.cal-srch-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 6px;color:rgb(var(--app-muted))}
|
||||||
|
.cal-srch-more{padding:4px 14px 14px;border-bottom:1px solid rgb(var(--app-border))}
|
||||||
|
.cal-srch-more .cal-btn{background:rgb(var(--app-surface))}
|
||||||
|
.cal-srch-month{padding:16px 14px 8px;font-size:19px;font-weight:600}
|
||||||
|
.cal-srch-month span{color:rgb(var(--app-muted));font-weight:400}
|
||||||
|
.cal-srch-day{display:flex;gap:12px;padding:8px 14px}
|
||||||
|
.cal-srch-dt{width:40px;flex:0 0 auto;text-align:left;position:relative}
|
||||||
|
.cal-srch-dt .dow{display:block;font-size:11px;color:rgb(var(--app-faint));letter-spacing:.03em}
|
||||||
|
.cal-srch-dt .dnum{display:block;font-size:18px;font-weight:500}
|
||||||
|
.cal-srch-dt.today .dnum,.cal-srch-dt.today .dow{color:#3b82f6}
|
||||||
|
.cal-srch-evs{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;justify-content:center}
|
||||||
|
.cal-srch-ev{background:rgb(var(--app-hover));border-left:3px solid #3b82f6;border-radius:5px;padding:6px 9px;cursor:pointer}
|
||||||
|
.cal-srch-ev:hover{background:rgb(var(--app-active))}
|
||||||
|
.cal-srch-ev .t{display:flex;justify-content:space-between;gap:8px}
|
||||||
|
.cal-srch-ev .t b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.cal-srch-ev .t span{color:rgb(var(--app-muted));flex:0 0 auto}
|
||||||
|
.cal-srch-ev .loc{color:rgb(var(--app-muted));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.cal-srch-none{color:rgb(var(--app-faint));padding:6px 0}
|
||||||
|
.cal-srch-today{color:#3b82f6;font-size:11px;font-weight:600;letter-spacing:.04em;text-align:right;padding:0 14px}
|
||||||
.cal-mainhead{display:flex;align-items:center;gap:14px;padding:10px 16px;flex:0 0 auto}
|
.cal-mainhead{display:flex;align-items:center;gap:14px;padding:10px 16px;flex:0 0 auto}
|
||||||
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
|
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
|
||||||
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
|
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
|
||||||
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:6px;display:inline-flex}
|
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex}
|
||||||
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
.cal-switch{position:relative}
|
.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
|
||||||
.cal-switch>button{display:flex;align-items:center;gap:6px;border:0;background:none;color:rgb(var(--app-fg));font-weight:600;font-size:15px;cursor:pointer;padding:4px 6px;border-radius:6px}
|
.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
|
||||||
.cal-switch>button:hover{background:rgb(var(--app-hover))}
|
.cal-search{flex:1;min-width:0;padding:7px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
|
||||||
.cal-menu{position:absolute;left:0;top:100%;margin-top:4px;min-width:160px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:30;box-shadow:0 6px 20px rgba(0,0,0,.3)}
|
|
||||||
.cal-menu button{display:block;width:100%;text-align:left;border:0;background:none;color:rgb(var(--app-muted));padding:6px 10px;border-radius:6px;cursor:pointer;font:inherit}
|
|
||||||
.cal-menu button:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
|
||||||
.cal-search{flex:1;max-width:520px;margin:0 auto;padding:7px 12px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg))}
|
|
||||||
.cal-seg{display:inline-flex;background:rgb(var(--app-surface));border-radius:9px;padding:3px;gap:2px}
|
.cal-seg{display:inline-flex;background:rgb(var(--app-surface));border-radius:9px;padding:3px;gap:2px}
|
||||||
.cal-seg button{border:0;background:none;color:rgb(var(--app-muted));padding:5px 16px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px}
|
.cal-seg button{border:0;background:none;color:rgb(var(--app-muted));padding:5px 26px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px}
|
||||||
.cal-seg button.on{background:rgb(var(--app-elevated));color:rgb(var(--app-fg));box-shadow:0 1px 3px rgba(0,0,0,.35)}
|
.cal-seg button.on{background:rgb(var(--app-elevated));color:rgb(var(--app-fg));box-shadow:0 1px 3px rgba(0,0,0,.35)}
|
||||||
.cal-title{font-size:18px;font-weight:600}
|
.cal-title{font-size:18px;font-weight:600}
|
||||||
.cal-title span{color:rgb(var(--app-muted));font-weight:400}
|
.cal-title span{color:rgb(var(--app-muted));font-weight:400}
|
||||||
.cal-mini{padding:10px}
|
.cal-mini{padding:6px 4px 12px}
|
||||||
.cal-mini-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;font-size:12px;text-align:center}
|
.cal-mini-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;font-size:12px;text-align:center}
|
||||||
.cal-mini-grid .h{color:rgb(var(--app-faint));padding:3px 0}
|
.cal-mini-grid .h{color:rgb(var(--app-faint));padding:3px 0}
|
||||||
.cal-mini-grid button{border:0;background:none;color:rgb(var(--app-fg));padding:4px 0;border-radius:50%;cursor:pointer;font:inherit}
|
.cal-mini-grid button{width:26px;height:26px;margin:1px auto;display:flex;align-items:center;justify-content:center;border:0;background:none;color:rgb(var(--app-fg));border-radius:50%;cursor:pointer;font:inherit;font-size:14px}
|
||||||
.cal-mini-grid button:hover{background:rgb(var(--app-hover))}
|
.cal-mini-grid button:hover{background:rgb(var(--app-hover))}
|
||||||
.cal-mini-grid button.out{color:rgb(var(--app-faint))}
|
.cal-mini-grid button.out{color:rgb(var(--app-faint))}
|
||||||
.cal-mini-grid button.today{background:#3b82f6;color:#fff}
|
.cal-mini-grid button.today{background:#3b82f6;color:#fff}
|
||||||
.cal-section{padding:10px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:rgb(var(--app-faint))}
|
|
||||||
.cal-callist label{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer}
|
|
||||||
.cal-foot{margin-top:auto;border-top:1px solid rgb(var(--app-border));padding:8px}
|
|
||||||
.cal-foot button{display:flex;align-items:center;gap:10px;width:100%;border:0;background:none;color:rgb(var(--app-muted));padding:8px 12px;border-radius:8px;cursor:pointer;font:inherit;font-size:15px;text-align:left}
|
|
||||||
.cal-foot button:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
|
||||||
/* month */
|
/* month */
|
||||||
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-auto-rows:minmax(0,1fr);height:100%}
|
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%}
|
||||||
.cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:11px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))}
|
.cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:13px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))}
|
||||||
.cal-cell{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px;min-height:0;overflow:hidden;cursor:pointer}
|
.cal-cell{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px;min-height:0;overflow:hidden;cursor:pointer}
|
||||||
.cal-cell .n{font-size:15px;color:rgb(var(--app-fg));font-weight:500}
|
.cal-cell .n{display:flex;align-items:center;gap:4px;font-size:15px;color:rgb(var(--app-fg));font-weight:500}
|
||||||
|
.cal-cell .num{display:inline-flex;align-items:center;justify-content:center;min-width:25px;height:25px;border-radius:50%;box-sizing:border-box}
|
||||||
|
.cal-cell .mtag{color:rgb(var(--app-muted));font-weight:400;font-size:14px}
|
||||||
.cal-cell.out .n{color:rgb(var(--app-faint))}
|
.cal-cell.out .n{color:rgb(var(--app-faint))}
|
||||||
|
.cal-cell.out .mtag{color:rgb(var(--app-faint))}
|
||||||
|
.cal-cell:not(.out){background:rgba(255,255,255,.03)}
|
||||||
.cal-cell.today{background:rgba(59,130,246,.06)}
|
.cal-cell.today{background:rgba(59,130,246,.06)}
|
||||||
.cal-cell.today .n{background:#3b82f6;color:#fff;border-radius:50%;width:25px;height:25px;display:inline-flex;align-items:center;justify-content:center}
|
.cal-cell.today .num{background:#3b82f6;color:#fff}
|
||||||
.cal-ev{font-size:12px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))}
|
.cal-ev{font-size:14px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))}
|
||||||
|
.cal-ev.srch-hl,.cal-tev.srch-hl{outline:2px solid #3b82f6;outline-offset:-1px;background:rgba(59,130,246,.28)}
|
||||||
/* timegrid */
|
/* timegrid */
|
||||||
.cal-tg{display:grid;grid-template-columns:56px 1fr}
|
.cal-tg{display:grid;grid-template-columns:56px 1fr}
|
||||||
.cal-tg-head{position:sticky;top:0;background:rgb(var(--app-bg));z-index:5;display:grid;grid-template-columns:56px 1fr;border-bottom:1px solid rgb(var(--app-border))}
|
.cal-tg-head{position:sticky;top:0;background:rgb(var(--app-bg));z-index:5;display:grid;grid-template-columns:56px 1fr;border-bottom:1px solid rgb(var(--app-border))}
|
||||||
.cal-tg-days{display:grid}
|
.cal-tg-days{display:grid}
|
||||||
.cal-tg-days .d{padding:6px 8px;border-left:1px solid rgb(var(--app-border));font-size:12px;color:rgb(var(--app-muted))}
|
.cal-tg-days .d{padding:6px 8px;border-left:1px solid rgb(var(--app-border));font-size:14px;color:rgb(var(--app-muted))}
|
||||||
.cal-tg-days .d b{font-size:18px;color:rgb(var(--app-fg));display:block;font-weight:600}
|
.cal-tg-days .d b{font-size:18px;color:rgb(var(--app-fg));display:block;font-weight:600}
|
||||||
.cal-tg-days .d.today{color:#3b82f6;border-bottom:2px solid #3b82f6;margin-bottom:-1px}
|
.cal-tg-days .d.today{color:#3b82f6;border-bottom:2px solid #3b82f6;margin-bottom:-1px}
|
||||||
.cal-tg-days .d.today .num{background:#3b82f6;color:#fff;border-radius:50%;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:16px}
|
.cal-tg-days .d.today .num{background:#3b82f6;color:#fff;border-radius:50%;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:16px}
|
||||||
@@ -251,7 +277,7 @@ const CSS = `
|
|||||||
.cal-col{border-left:1px solid rgb(var(--app-border));position:relative}
|
.cal-col{border-left:1px solid rgb(var(--app-border));position:relative}
|
||||||
.cal-col.today{background:rgba(59,130,246,.06)}
|
.cal-col.today{background:rgba(59,130,246,.06)}
|
||||||
.cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))}
|
.cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))}
|
||||||
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:12px;overflow:hidden;cursor:pointer;box-sizing:border-box}
|
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box}
|
||||||
.cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
|
.cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
|
||||||
/* right agenda */
|
/* right agenda */
|
||||||
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))}
|
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))}
|
||||||
@@ -356,9 +382,13 @@ export default function mount(el, ctx) {
|
|||||||
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
|
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
|
||||||
eventPopover: null, // null | { ev, rect }
|
eventPopover: null, // null | { ev, rect }
|
||||||
confirm: null, // null | { ev } — delete confirmation modal
|
confirm: null, // null | { ev } — delete confirmation modal
|
||||||
|
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
|
||||||
me: "",
|
me: "",
|
||||||
quota: "",
|
quota: "",
|
||||||
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
||||||
|
defaultCalendar: "", // preferred calendar for new events (from settings)
|
||||||
|
defaultRemTimed: [], // default reminder minutes for timed events
|
||||||
|
defaultRemAllday: [], // default reminder minutes for all-day events
|
||||||
apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }],
|
apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,10 +436,32 @@ export default function mount(el, ctx) {
|
|||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
if (editEv) prefillForm(editEv);
|
if (editEv) prefillForm(editEv);
|
||||||
|
else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults
|
||||||
const first = el.querySelector("[data-f=summary]");
|
const first = el.querySelector("[data-f=summary]");
|
||||||
if (first) first.focus();
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate the reminders section with a list of minutes-before values.
|
||||||
|
function applyDefaultReminders(mins) {
|
||||||
|
const box = el.querySelector("[data-reminders]");
|
||||||
|
if (!box || !mins || !mins.length) return;
|
||||||
|
mins.forEach((m) => {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.innerHTML = reminderRow();
|
||||||
|
const row = wrap.firstElementChild;
|
||||||
|
const sel = row.querySelector("[data-rem]");
|
||||||
|
if (![...sel.options].some((o) => o.value === String(m))) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = String(m);
|
||||||
|
o.textContent = m === 0 ? "At time of event" : `${m} minutes before`;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.value = String(m);
|
||||||
|
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function prefillForm(ev) {
|
function prefillForm(ev) {
|
||||||
const q = (s) => el.querySelector(s);
|
const q = (s) => el.querySelector(s);
|
||||||
q("[data-f=summary]").value = ev.summary || "";
|
q("[data-f=summary]").value = ev.summary || "";
|
||||||
@@ -547,6 +599,131 @@ export default function mount(el, ctx) {
|
|||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- search ----------------------------------------------------------------
|
||||||
|
const SEARCH_WINDOW_DAYS = 540; // ± window; "further back/forward" extends by this
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
async function fetchSearch() {
|
||||||
|
const s = state.search;
|
||||||
|
if (!s || !s.q.trim()) return;
|
||||||
|
s.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.pluginFetch(
|
||||||
|
`/events?start=${encodeURIComponent(iso(s.from))}&end=${encodeURIComponent(iso(s.to))}&q=${encodeURIComponent(s.q.trim())}`);
|
||||||
|
s.results = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) }));
|
||||||
|
} catch { s.results = []; }
|
||||||
|
s.loading = false;
|
||||||
|
renderSearchPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchPanel() {
|
||||||
|
const panel = el.querySelector(".cal-right");
|
||||||
|
if (!panel) { render(); return; } // panel not mounted yet → full render
|
||||||
|
panel.innerHTML = searchPanelHtml();
|
||||||
|
wireSearchPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
state.search = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle a highlight on the calendar block(s) matching a hovered search result,
|
||||||
|
// only if that occurrence is drawn in the currently displayed view.
|
||||||
|
function highlightCalendarEvent(id, start, on) {
|
||||||
|
el.querySelectorAll(`.cal-view [data-ev="${id}"][data-start="${start}"]`)
|
||||||
|
.forEach((n) => n.classList.toggle("srch-hl", on));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSearchResult(ev) {
|
||||||
|
const gs = startOfWeek(startOfMonth(state.anchor));
|
||||||
|
const visibleNow = state.view === "month" && ev._s >= gs && ev._s < addDays(gs, 42);
|
||||||
|
if (!visibleNow) {
|
||||||
|
state.view = "month";
|
||||||
|
state.anchor = startOfMonth(ev._s);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
const block = el.querySelector(`.cal-view [data-ev="${ev.id}"][data-start="${ev.start}"]`);
|
||||||
|
if (block) { openEvent(ev, block); return; }
|
||||||
|
// Not drawn (e.g. its calendar is hidden) — still show details near the view.
|
||||||
|
const view = el.querySelector(".cal-view");
|
||||||
|
const r = view ? view.getBoundingClientRect() : { top: 100, left: 300 };
|
||||||
|
state.eventPopover = { ev, rect: { top: r.top + 24, left: r.left + 40, right: r.left + 90, bottom: r.top + 44 } };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function srchEventHtml(ev) {
|
||||||
|
return `<div class="cal-srch-ev" data-srch-ev="${ev.id}" data-srch-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">
|
||||||
|
<div class="t"><b>${esc(ev.summary || "(no title)")}</b><span>${ev.all_day ? "All day" : hhmm(ev._s)}</span></div>
|
||||||
|
${ev.location ? `<div class="loc">${esc(ev.location)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchPanelHtml() {
|
||||||
|
const s = state.search;
|
||||||
|
const fromStr = s.from.toLocaleDateString(dateLocale());
|
||||||
|
const toStr = s.to.toLocaleDateString(dateLocale());
|
||||||
|
if (s.loading && !s.results.length) {
|
||||||
|
return `<div class="cal-srch"><div class="cal-srch-hd"><span>Searching…</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div></div>`;
|
||||||
|
}
|
||||||
|
// Group results by day; always include today's row (marked TODAY), like the mockup.
|
||||||
|
const byDay = new Map();
|
||||||
|
s.results.forEach((ev) => {
|
||||||
|
const key = iso(startOfDay(ev._s));
|
||||||
|
if (!byDay.has(key)) byDay.set(key, { date: startOfDay(ev._s), events: [] });
|
||||||
|
byDay.get(key).events.push(ev);
|
||||||
|
});
|
||||||
|
const todayKey = iso(startOfDay(new Date()));
|
||||||
|
if (!byDay.has(todayKey)) byDay.set(todayKey, { date: startOfDay(new Date()), events: [] });
|
||||||
|
const days = [...byDay.values()].sort((a, b) => a.date - b.date);
|
||||||
|
let rows = "";
|
||||||
|
let curMonth = null;
|
||||||
|
days.forEach((day) => {
|
||||||
|
const mk = day.date.getFullYear() * 12 + day.date.getMonth();
|
||||||
|
if (mk !== curMonth) {
|
||||||
|
curMonth = mk;
|
||||||
|
rows += `<div class="cal-srch-month">${MONTHS[day.date.getMonth()]} <span>${day.date.getFullYear()}</span></div>`;
|
||||||
|
}
|
||||||
|
const isToday = sameDay(day.date, new Date());
|
||||||
|
const evs = day.events.slice().sort((a, b) => a._s - b._s);
|
||||||
|
rows += `${isToday ? `<div class="cal-srch-today">TODAY</div>` : ""}
|
||||||
|
<div class="cal-srch-day">
|
||||||
|
<div class="cal-srch-dt${isToday ? " today" : ""}"><span class="dow">${DAYNAMES[(day.date.getDay() + 6) % 7].toUpperCase()}</span><span class="dnum">${day.date.getDate()}</span></div>
|
||||||
|
<div class="cal-srch-evs">${evs.length ? evs.map(srchEventHtml).join("") : `<div class="cal-srch-none">No events</div>`}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
return `<div class="cal-srch">
|
||||||
|
<div class="cal-srch-hd"><span>Showing results from ${esc(fromStr)}</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div>
|
||||||
|
<div class="cal-srch-more"><button class="cal-btn" data-act="search-back">Search further back</button></div>
|
||||||
|
${rows}
|
||||||
|
<div class="cal-srch-hd" style="border:0"><span>Showing results up to ${esc(toStr)}</span></div>
|
||||||
|
<div class="cal-srch-more" style="border:0"><button class="cal-btn" data-act="search-fwd">Search further forward</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireSearchPanel() {
|
||||||
|
const panel = el.querySelector(".cal-right");
|
||||||
|
if (!panel || !state.search) return;
|
||||||
|
panel.querySelectorAll("[data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch));
|
||||||
|
panel.querySelector("[data-act=search-back]")?.addEventListener("click", () => {
|
||||||
|
state.search.from = addDays(state.search.from, -SEARCH_WINDOW_DAYS); fetchSearch();
|
||||||
|
});
|
||||||
|
panel.querySelector("[data-act=search-fwd]")?.addEventListener("click", () => {
|
||||||
|
state.search.to = addDays(state.search.to, SEARCH_WINDOW_DAYS); fetchSearch();
|
||||||
|
});
|
||||||
|
panel.querySelectorAll("[data-srch-ev]").forEach((node) => {
|
||||||
|
const id = node.getAttribute("data-srch-ev");
|
||||||
|
const start = node.getAttribute("data-srch-start");
|
||||||
|
node.addEventListener("mouseenter", () => highlightCalendarEvent(id, start, true));
|
||||||
|
node.addEventListener("mouseleave", () => highlightCalendarEvent(id, start, false));
|
||||||
|
node.addEventListener("click", () => {
|
||||||
|
const ev = (state.search.results || []).find((x) => x.id === id && x.start === start);
|
||||||
|
if (ev) openSearchResult(ev);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Replace the Video-call button/link in place (no full re-render → keeps form input).
|
// Replace the Video-call button/link in place (no full re-render → keeps form input).
|
||||||
function fillVideo(link) {
|
function fillVideo(link) {
|
||||||
if (!state.composing) return;
|
if (!state.composing) return;
|
||||||
@@ -636,11 +813,13 @@ export default function mount(el, ctx) {
|
|||||||
// ---- render ----------------------------------------------------------------
|
// ---- render ----------------------------------------------------------------
|
||||||
function render() {
|
function render() {
|
||||||
el.innerHTML = `<div class="cal-app">
|
el.innerHTML = `<div class="cal-app">
|
||||||
${topbar()}
|
${state.leftCollapsed ? "" : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
|
||||||
<div class="cal-body">
|
<div class="cal-maincol">
|
||||||
${state.leftCollapsed ? "" : `<div class="cal-left" style="width:${state.leftW}px">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
|
${topbar()}
|
||||||
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
|
<div class="cal-mainarea">
|
||||||
${state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : ""}
|
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
|
||||||
|
${state.search ? `<div class="cal-right">${searchPanelHtml()}</div>` : (state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : "")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${state.composing ? formHtml() : ""}
|
${state.composing ? formHtml() : ""}
|
||||||
${state.eventPopover ? eventPopoverHtml() : ""}
|
${state.eventPopover ? eventPopoverHtml() : ""}
|
||||||
@@ -649,6 +828,16 @@ export default function mount(el, ctx) {
|
|||||||
wire();
|
wire();
|
||||||
if (state.view !== "month") scrollTimegrid();
|
if (state.view !== "month") scrollTimegrid();
|
||||||
if (state.eventPopover) positionPopover();
|
if (state.eventPopover) positionPopover();
|
||||||
|
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearch() {
|
||||||
|
const si = el.querySelector(".cal-search");
|
||||||
|
if (si && document.activeElement !== si) {
|
||||||
|
si.focus();
|
||||||
|
const v = si.value;
|
||||||
|
si.setSelectionRange(v.length, v.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp the details popover fully inside the viewport (measure actual size after
|
// Clamp the details popover fully inside the viewport (measure actual size after
|
||||||
@@ -668,36 +857,45 @@ export default function mount(el, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function topbar() {
|
function topbar() {
|
||||||
const title = MONTHS[state.anchor.getMonth()] + " " + state.anchor.getFullYear();
|
|
||||||
return `<div class="cal-topbar">
|
return `<div class="cal-topbar">
|
||||||
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-expand" title="Show sidebar">${SIDEBAR_ICON}</button>` : ""}
|
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
|
||||||
<div class="cal-switch">
|
<div class="cal-searchbox">
|
||||||
<button data-act="menu">Calendar <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></button>
|
<svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
${state.menuOpen ? `<div class="cal-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
|
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
|
||||||
|
${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear search">✕</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<input class="cal-search" placeholder="Search calendar"/>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
|
||||||
|
// switcher + collapse/theme, primary button, an app-specific scrollable body
|
||||||
|
// (mini-month + calendars here), and the shared foot. Identical to Mail.
|
||||||
function leftPanel() {
|
function leftPanel() {
|
||||||
return `
|
return `
|
||||||
<div class="cal-lefthead">
|
<div class="wm-side-head">
|
||||||
<div style="flex:1"></div>
|
<div class="wm-side-switch">
|
||||||
<button class="cal-iconbtn" data-act="left-collapse" title="Collapse sidebar">${SIDEBAR_ICON}</button>
|
<button data-act="menu">Calendar <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||||
<button class="cal-iconbtn cal-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="wm-side-headicons">
|
||||||
|
<button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
|
||||||
|
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:4px 10px 10px;display:flex;gap:6px">
|
<div class="wm-side-primary">
|
||||||
<button class="cal-btn cal-btn-primary" data-act="new">+ New event</button>
|
<button data-act="new">${COMPOSE_ICON}New event</button>
|
||||||
</div>
|
</div>
|
||||||
${miniMonth()}
|
<div class="wm-side-body">
|
||||||
<div class="cal-section">Calendars</div>
|
${miniMonth()}
|
||||||
<div class="cal-callist">${state.calendars.map((c) => `
|
<div class="wm-side-section cal-secrow"><span>Calendars</span><button class="cal-secadd" data-act="add-calendar" title="Create calendar">+</button></div>
|
||||||
<label><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
|
<div class="wm-side-nav">${state.calendars.map((c) => `
|
||||||
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
|
<label class="wm-side-item"><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
|
||||||
<div class="cal-foot">
|
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
|
||||||
<button data-act="settings"><svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>
|
</div>
|
||||||
<button data-act="signout"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Sign out</button>
|
<div class="wm-side-foot">
|
||||||
${state.quota ? `<div style="border-top:1px solid rgb(var(--app-border));margin-top:4px;padding:8px 12px 2px;font-size:13px;color:rgb(var(--app-faint))">${esc(state.quota)}</div>` : ""}
|
<button data-act="settings">${SETTINGS_ICON}Settings</button>
|
||||||
|
<button data-act="signout">${LOGOUT_ICON}Sign out</button>
|
||||||
|
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +911,7 @@ export default function mount(el, ctx) {
|
|||||||
return `<div class="cal-mini">
|
return `<div class="cal-mini">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||||||
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
|
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
|
||||||
<span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mininext">›</button></span>
|
<span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext">›</button></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
|
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -733,9 +931,9 @@ export default function mount(el, ctx) {
|
|||||||
<button class="cal-iconbtn" data-act="prev">←</button>
|
<button class="cal-iconbtn" data-act="prev">←</button>
|
||||||
<button class="cal-btn" data-act="today">Today</button>
|
<button class="cal-btn" data-act="today">Today</button>
|
||||||
<button class="cal-iconbtn" data-act="next">→</button>
|
<button class="cal-iconbtn" data-act="next">→</button>
|
||||||
<button class="cal-iconbtn" data-act="panel" title="Toggle side panel">
|
${(state.rightOpen || state.search) ? "" : `<button class="cal-iconbtn" data-act="panel" title="Open side panel">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>
|
||||||
</button>
|
</button>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,11 +944,13 @@ export default function mount(el, ctx) {
|
|||||||
function formHtml() {
|
function formHtml() {
|
||||||
const c = state.composing;
|
const c = state.composing;
|
||||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||||
const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).join("");
|
const defCal = c.editId ? null : state.defaultCalendar; // new events honor the default calendar
|
||||||
|
const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => `<option value="${x.id}"${x.id === defCal ? " selected" : ""}>${esc(x.name)}</option>`).join("");
|
||||||
const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`;
|
const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`;
|
||||||
const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join("");
|
const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join("");
|
||||||
const tz0 = c.tz || browserTz;
|
const tz0 = c.tz || browserTz;
|
||||||
return `<div class="cal-form">
|
const formLeft = state.leftCollapsed ? 12 : state.leftW + 12; // clear the full-height sidebar
|
||||||
|
return `<div class="cal-form" style="left:${formLeft}px">
|
||||||
<div class="cal-form-body">
|
<div class="cal-form-body">
|
||||||
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
|
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
|
||||||
<h4>Where</h4><input type="text" data-f="location"/>
|
<h4>Where</h4><input type="text" data-f="location"/>
|
||||||
@@ -912,9 +1112,9 @@ export default function mount(el, ctx) {
|
|||||||
const d = addDays(gs, i);
|
const d = addDays(gs, i);
|
||||||
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
||||||
const cls = [d.getMonth() !== state.anchor.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
|
const cls = [d.getMonth() !== state.anchor.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
|
||||||
const label = d.getDate() === 1 ? `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}` : d.getDate();
|
const mtag = d.getDate() === 1 ? `<span class="mtag">${MONTHS[d.getMonth()].slice(0, 3)} ${d.getFullYear()}</span>` : "";
|
||||||
html += `<div class="cal-cell ${cls}" data-day="${iso(d)}">
|
html += `<div class="cal-cell ${cls}" data-day="${iso(d)}">
|
||||||
<div class="n">${label}</div>
|
<div class="n"><span class="num">${d.getDate()}</span>${mtag}</div>
|
||||||
${dayEvs.slice(0, 4).map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("")}
|
${dayEvs.slice(0, 4).map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("")}
|
||||||
${dayEvs.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""}
|
${dayEvs.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -976,12 +1176,31 @@ export default function mount(el, ctx) {
|
|||||||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||||
|
|
||||||
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
|
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
|
||||||
|
// --- search ---
|
||||||
|
on(".cal-search", "input", (e) => {
|
||||||
|
const q = e.currentTarget.value;
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
if (!q.trim()) { closeSearch(); return; }
|
||||||
|
if (!state.search) {
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
state.search = { q, from: addDays(today, -SEARCH_WINDOW_DAYS), to: addDays(today, SEARCH_WINDOW_DAYS), results: [], loading: true };
|
||||||
|
render(); // mounts the results panel (input re-focused in render)
|
||||||
|
} else {
|
||||||
|
state.search.q = q;
|
||||||
|
}
|
||||||
|
searchTimer = setTimeout(fetchSearch, 250);
|
||||||
|
});
|
||||||
|
on(".cal-search", "keydown", (e) => { if (e.key === "Escape") closeSearch(); });
|
||||||
|
// Topbar clear (the ✕ in the search box); the panel's own ✕ is wired in wireSearchPanel.
|
||||||
|
el.querySelectorAll(".cal-searchbox [data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch));
|
||||||
|
if (state.search) wireSearchPanel();
|
||||||
on("[data-app]", "click", (e) => {
|
on("[data-app]", "click", (e) => {
|
||||||
const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))];
|
const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))];
|
||||||
state.menuOpen = false;
|
state.menuOpen = false;
|
||||||
if (a.to) ctx.navigate(a.to); else render();
|
if (a.to) ctx.navigate(a.to); else render();
|
||||||
});
|
});
|
||||||
act("settings", () => ctx.navigate("/settings"));
|
act("settings", () => ctx.navigate("/settings"));
|
||||||
|
act("add-calendar", () => ctx.navigate("/settings/calendars?create=1"));
|
||||||
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||||||
act("new", () => openForm(null));
|
act("new", () => openForm(null));
|
||||||
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||||||
@@ -990,10 +1209,13 @@ export default function mount(el, ctx) {
|
|||||||
act("panel", () => { state.rightOpen = !state.rightOpen; render(); });
|
act("panel", () => { state.rightOpen = !state.rightOpen; render(); });
|
||||||
act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); });
|
act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); });
|
||||||
act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); });
|
act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); });
|
||||||
|
act("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||||||
// --- left panel: collapse, theme, resize (like Mail) ---
|
// --- left panel: collapse, theme, resize (like Mail) ---
|
||||||
const setLeft = (v) => { state.leftCollapsed = v; try { localStorage.setItem("cal.leftCollapsed", v ? "1" : "0"); } catch { /* ignore */ } render(); };
|
act("left-toggle", () => {
|
||||||
act("left-collapse", () => setLeft(true));
|
state.leftCollapsed = !state.leftCollapsed;
|
||||||
act("left-expand", () => setLeft(false));
|
try { localStorage.setItem("cal.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ }
|
||||||
|
render();
|
||||||
|
});
|
||||||
act("theme", () => {
|
act("theme", () => {
|
||||||
const dark = document.documentElement.classList.toggle("dark");
|
const dark = document.documentElement.classList.toggle("dark");
|
||||||
try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ }
|
try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ }
|
||||||
@@ -1150,6 +1372,9 @@ export default function mount(el, ctx) {
|
|||||||
const s = await api.fetch("/settings");
|
const s = await api.fetch("/settings");
|
||||||
HOUR12 = s && s.time_format === "12"; // 24h is the default
|
HOUR12 = s && s.time_format === "12"; // 24h is the default
|
||||||
state.jitsi = (s && s.jitsi_server) || "";
|
state.jitsi = (s && s.jitsi_server) || "";
|
||||||
|
state.defaultCalendar = (s && s.default_calendar) || "";
|
||||||
|
state.defaultRemTimed = (s && s.default_reminders_timed) || [];
|
||||||
|
state.defaultRemAllday = (s && s.default_reminders_allday) || [];
|
||||||
} catch { /* keep defaults (24h, public Jitsi) */ }
|
} catch { /* keep defaults (24h, public Jitsi) */ }
|
||||||
try {
|
try {
|
||||||
const q = await api.fetch("/settings/quota");
|
const q = await api.fetch("/settings/quota");
|
||||||
|
|||||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 125 KiB |
@@ -3,8 +3,8 @@ How the worker functions (multi-user / multi-account)
|
|||||||
|
|
||||||
The worker wears two hats (in one process, one event loop)
|
The worker wears two hats (in one process, one event loop)
|
||||||
----------------------------------------------------------
|
----------------------------------------------------------
|
||||||
It is a single arq process (`arq workers.worker.WorkerSettings`) running one
|
It is a single worker process (`python -m workers.run`, WORKER_ROLE=all) running
|
||||||
asyncio event loop. Inside it, two very different things run:
|
one asyncio event loop. Inside it, two very different things run:
|
||||||
|
|
||||||
A) A job runner (the Redis queue side). The API enqueues discrete jobs; the
|
A) A job runner (the Redis queue side). The API enqueues discrete jobs; the
|
||||||
worker pulls and runs them, up to `max_jobs = 10` concurrently,
|
worker pulls and runs them, up to `max_jobs = 10` concurrently,
|
||||||
|
|||||||