M4 Calendar: publish public .ics links, subscriptions, multi-calendar settings

Calendar (builtin plugin):
- Publish read-only public .ics feed per calendar via vault-sealed token
  (full-detail or free/busy-only); unauthenticated GET /public/{token},
  404 on bad/absent token. Migration calendar0004 adds publish_token,
  publish_busy, subscription_url + partial unique index.
- Subscribe to external iCal (.ics) URLs (SSRF-guarded) as read-only
  calendars; CSRF-guarded POST /subscriptions.
- Settings > Calendars: create/edit/delete, publish toggles with live
  shareable link + copy, Add-account stubs (Google/iCloud/CalDAV, deferred),
  Subscriptions section; default calendar + default reminders; auto-detected
  time zone; color-dropdown contrast fix; 3x-larger edit arrow.
- Left panel: shared Mail-parity sidebar template (.wm-side-*), calendar
  search panel, font/spacing tuning, per-section "+" create shortcut.

Frontend: CalendarOut publish fields, api publish/subscribe, settings keys.
Backend: settings default_calendar + reminder lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-01 20:22:53 +02:00
co-authored by Claude Opus 4.8
parent 8c634f7967
commit e2bef3c41d
20 changed files with 1329 additions and 88 deletions
@@ -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
import base64
import json
import logging
import uuid
@@ -15,7 +16,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
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.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -29,6 +30,7 @@ from webmail_plugin_calendar.schemas import (
EditFollowingIn,
EventIn,
EventPatch,
SubscribeIn,
)
router = APIRouter()
@@ -66,6 +68,69 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
# --- 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")
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
async with _SDK.session(user_id) as s:
@@ -73,8 +138,8 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
rows = (
await s.execute(
text(
"SELECT id, name, color, kind, read_only FROM plg_calendar_calendars "
"ORDER BY name"
"SELECT id, name, color, kind, read_only, publish_token, publish_busy, "
"subscription_url FROM plg_calendar_calendars ORDER BY name"
)
)
).mappings().all()
@@ -85,6 +150,9 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
"color": r["color"],
"kind": r["kind"],
"read_only": r["read_only"],
"publish_token": r["publish_token"],
"publish_busy": r["publish_busy"],
"subscription_url": r["subscription_url"],
}
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)])
async def patch_calendar(
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
) -> dict[str, str]:
) -> dict[str, str | None]:
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"}
set_sql = ", ".join(f"{k} = :{k}" for k in fields)
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(
text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"),
{**fields, "id": str(calendar_id)},
text(f"UPDATE plg_calendar_calendars SET {', '.join(set_parts)} WHERE id = :id"),
params,
)
if result.rowcount == 0:
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)])
@@ -137,6 +225,103 @@ async def delete_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> dic
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 ------------------------------------------------------------------
@router.get("/events")
@@ -144,8 +329,21 @@ async def list_events(
user_id: CurrentUserId,
start: datetime = Query(...),
end: datetime = Query(...),
q: str = Query(""),
) -> 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)
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:
rows = (
await s.execute(
@@ -158,9 +356,9 @@ async def list_events(
"COALESCE(e.color, c.color) AS color "
"FROM plg_calendar_events e "
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
"WHERE e.dtstart < :end"
f"{where}"
),
{"end": range_end},
params,
)
).mappings().all()
base = [
@@ -498,6 +696,33 @@ async def delete_event(
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) --------
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:
if filename != "page.js":
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):
name: 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):
@@ -20,7 +20,12 @@ let HOUR12 = false;
// Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US.
const dateLocale = () => (HOUR12 ? "en-US" : "en-GB");
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 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).
@@ -186,61 +191,82 @@ function tzEntries(current) {
}
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-body{display:flex;flex:1;min-height:0}
.cal-left{flex:0 0 auto;border-right:1px solid rgb(var(--app-border));display:flex;flex-direction:column;overflow:auto}
.cal-lefthead{display:flex;align-items:center;gap:4px;padding:8px 8px 2px}
/* Left panel styling lives in the shared sidebar shell (.wm-side-*, plugin-ui.css),
which @applies Mail's exact Tailwind utilities + pins the 16px/Lato base context. */
.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::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.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-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-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-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-switch{position:relative}
.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-switch>button:hover{background:rgb(var(--app-hover))}
.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-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-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
.cal-search{flex:1;min-width:0;padding:7px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
.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-title{font-size:18px;font-weight:600}
.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 .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.out{color:rgb(var(--app-faint))}
.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 */
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);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{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: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 .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 .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 .n{background:#3b82f6;color:#fff;border-radius:50%;width:25px;height:25px;display:inline-flex;align-items:center;justify-content:center}
.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-cell.today .num{background:#3b82f6;color:#fff}
.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 */
.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-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.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}
@@ -251,7 +277,7 @@ const CSS = `
.cal-col{border-left:1px solid rgb(var(--app-border));position:relative}
.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-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}
/* right agenda */
.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 }
eventPopover: null, // null | { ev, rect }
confirm: null, // null | { ev } — delete confirmation modal
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
me: "",
quota: "",
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 }],
};
@@ -406,10 +436,32 @@ export default function mount(el, ctx) {
};
render();
if (editEv) prefillForm(editEv);
else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults
const first = el.querySelector("[data-f=summary]");
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) {
const q = (s) => el.querySelector(s);
q("[data-f=summary]").value = ev.summary || "";
@@ -547,6 +599,131 @@ export default function mount(el, ctx) {
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).
function fillVideo(link) {
if (!state.composing) return;
@@ -636,11 +813,13 @@ export default function mount(el, ctx) {
// ---- render ----------------------------------------------------------------
function render() {
el.innerHTML = `<div class="cal-app">
${topbar()}
<div class="cal-body">
${state.leftCollapsed ? "" : `<div class="cal-left" style="width:${state.leftW}px">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
${state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : ""}
${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-maincol">
${topbar()}
<div class="cal-mainarea">
<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>
${state.composing ? formHtml() : ""}
${state.eventPopover ? eventPopoverHtml() : ""}
@@ -649,6 +828,16 @@ export default function mount(el, ctx) {
wire();
if (state.view !== "month") scrollTimegrid();
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
@@ -668,36 +857,45 @@ export default function mount(el, ctx) {
}
function topbar() {
const title = MONTHS[state.anchor.getMonth()] + " " + state.anchor.getFullYear();
return `<div class="cal-topbar">
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-expand" title="Show sidebar">${SIDEBAR_ICON}</button>` : ""}
<div class="cal-switch">
<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>
${state.menuOpen ? `<div class="cal-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
<div class="cal-searchbox">
<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>
<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>
<input class="cal-search" placeholder="Search calendar"/>
</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() {
return `
<div class="cal-lefthead">
<div style="flex:1"></div>
<button class="cal-iconbtn" data-act="left-collapse" title="Collapse sidebar">${SIDEBAR_ICON}</button>
<button class="cal-iconbtn cal-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
<div class="wm-side-head">
<div class="wm-side-switch">
<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>
${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 style="padding:4px 10px 10px;display:flex;gap:6px">
<button class="cal-btn cal-btn-primary" data-act="new">+ New event</button>
<div class="wm-side-primary">
<button data-act="new">${COMPOSE_ICON}New event</button>
</div>
${miniMonth()}
<div class="cal-section">Calendars</div>
<div class="cal-callist">${state.calendars.map((c) => `
<label><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
<div class="cal-foot">
<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>
<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>
${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>` : ""}
<div class="wm-side-body">
${miniMonth()}
<div class="wm-side-section cal-secrow"><span>Calendars</span><button class="cal-secadd" data-act="add-calendar" title="Create calendar">+</button></div>
<div class="wm-side-nav">${state.calendars.map((c) => `
<label class="wm-side-item"><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
</div>
<div class="wm-side-foot">
<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>`;
}
@@ -713,7 +911,7 @@ export default function mount(el, ctx) {
return `<div class="cal-mini">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<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 class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
</div>`;
@@ -733,9 +931,9 @@ export default function mount(el, ctx) {
<button class="cal-iconbtn" data-act="prev">←</button>
<button class="cal-btn" data-act="today">Today</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>
</button>
</button>`}
</div>`;
}
@@ -746,11 +944,13 @@ export default function mount(el, ctx) {
function formHtml() {
const c = state.composing;
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 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;
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">
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
<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 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 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)}">
<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.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""}
</div>`;
@@ -976,12 +1176,31 @@ export default function mount(el, ctx) {
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
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) => {
const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))];
state.menuOpen = false;
if (a.to) ctx.navigate(a.to); else render();
});
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("new", () => openForm(null));
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("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("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
// --- 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-collapse", () => setLeft(true));
act("left-expand", () => setLeft(false));
act("left-toggle", () => {
state.leftCollapsed = !state.leftCollapsed;
try { localStorage.setItem("cal.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ }
render();
});
act("theme", () => {
const dark = document.documentElement.classList.toggle("dark");
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");
HOUR12 = s && s.time_format === "12"; // 24h is the default
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) */ }
try {
const q = await api.fetch("/settings/quota");