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:
+40
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user