diff --git a/.gitignore b/.gitignore index 42cd1f5..91caee1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ frontend/dist/ .DS_Store .idea/ .vscode/ + +# Editor backups +*~ + +# Local session helper (Claude Code resume command) +/claude-session.txt diff --git a/backend/app/accounts/ssrf.py b/backend/app/accounts/ssrf.py index a0198ea..f8add73 100644 --- a/backend/app/accounts/ssrf.py +++ b/backend/app/accounts/ssrf.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import ipaddress import socket +from dataclasses import dataclass from urllib.parse import urlsplit import httpx @@ -87,3 +88,51 @@ async def guarded_get(url: str) -> str | None: raise SsrfError("response exceeds 256 KB cap") chunks.append(chunk) return b"".join(chunks).decode("utf-8", "replace") + + +@dataclass(frozen=True) +class GuardedResponse: + status: int + headers: dict[str, str] + text: str + + +async def guarded_request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + content: bytes | str | None = None, + auth: tuple[str, str] | None = None, + timeout: float = 15.0, # noqa: ASYNC109 - passed straight to httpx's own timeout + max_bytes: int = 25 * 1024 * 1024, +) -> GuardedResponse: + """Arbitrary-method HTTPS request under the same SSRF policy as :func:`guarded_get`. + + Used by plugins that speak WebDAV/CalDAV/CardDAV (PROPFIND/REPORT/PUT/DELETE) or + fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body + capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns + status + headers + body (all statuses; the caller decides what's an error).""" + parts = urlsplit(url) + if parts.scheme != "https": + raise SsrfError("only https is permitted") + if not parts.hostname: + raise SsrfError("missing host") + await assert_host_is_public(parts.hostname) + + async with ( + httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client, + client.stream(method, url, headers=headers, content=content) as resp, + ): + chunks: list[bytes] = [] + total = 0 + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > max_bytes: + raise SsrfError("response exceeds size cap") + chunks.append(chunk) + return GuardedResponse( + status=resp.status_code, + headers=dict(resp.headers), + text=b"".join(chunks).decode("utf-8", "replace"), + ) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 49f618f..79060b9 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -18,17 +18,22 @@ router = APIRouter(prefix="/settings", tags=["settings"]) DbSession = Annotated[AsyncSession, Depends(get_session)] _ALLOWED_ORDER = {"asc", "desc"} +_ALLOWED_TIME_FORMAT = {"12", "24"} class SettingsOut(BaseModel): thread_order: str = "asc" tz: str = "UTC" + time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide + jitsi_server: str = "" # blank => https://meet.jit.si/ allowed_image_senders: list[str] = [] class SettingsPatch(BaseModel): thread_order: str | None = None tz: str | None = None + time_format: str | None = None + jitsi_server: str | None = None class QuotaOut(BaseModel): @@ -56,6 +61,8 @@ def _settings_out(user: User) -> SettingsOut: return SettingsOut( thread_order=str(s.get("thread_order", "asc")), tz=str(s.get("tz", "UTC")), + time_format=str(s.get("time_format", "24")), + jitsi_server=str(s.get("jitsi_server", "")), allowed_image_senders=sorted(allowed_image_senders(user)), ) @@ -81,6 +88,10 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) updates["thread_order"] = body.thread_order if body.tz is not None: updates["tz"] = body.tz + if body.time_format is not None and body.time_format in _ALLOWED_TIME_FORMAT: + updates["time_format"] = body.time_format + if body.jitsi_server is not None: + updates["jitsi_server"] = body.jitsi_server.strip() user.settings = updates await db.commit() return _settings_out(user) diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py index 52ede62..43db8c4 100644 --- a/backend/app/indexer/mime.py +++ b/backend/app/indexer/mime.py @@ -98,16 +98,23 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]: continue cid = part.get("Content-ID") filename = part.get_filename() - if not (filename or cid or part.get_content_disposition() == "attachment"): + disposition = part.get_content_disposition() + if not (filename or cid or disposition == "attachment"): continue payload = part.get_payload(decode=True) if not isinstance(payload, bytes): continue + # A part is an INLINE resource (cid: image rendered in-body, hidden from the + # attachment list) only when it carries a Content-ID AND is not explicitly an + # attachment. A part marked `Content-Disposition: attachment` is a real + # attachment even when it also has a Content-ID (e.g. Fastmail's invite.ics), + # so we drop the cid for it and it shows as a downloadable chip. + inline = cid is not None and disposition != "attachment" out.append( ParsedAttachment( filename=filename or "attachment", mime_type=part.get_content_type(), - content_id=cid.strip("<>") if cid else None, + content_id=cid.strip("<>") if (inline and cid is not None) else None, payload=payload, ) ) diff --git a/backend/app/indexer/sanitize.py b/backend/app/indexer/sanitize.py index e7406af..0b9526d 100644 --- a/backend/app/indexer/sanitize.py +++ b/backend/app/indexer/sanitize.py @@ -17,7 +17,7 @@ import nh3 # Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a # render re-sanitizes stale content from the raw blob (SPEC §10.4). -SANITIZER_VERSION = 2 +SANITIZER_VERSION = 3 _ALLOWED_TAGS: set[str] = { # structure @@ -76,6 +76,7 @@ _ALLOWED_TAGS: set[str] = { "span", "strike", "strong", + "style", "sub", "summary", "sup", @@ -97,6 +98,7 @@ _ALLOWED_TAGS: set[str] = { # Attributes allowed on any tag (presentational email formatting). _GENERIC_ATTRS: set[str] = { "style", + "class", "align", "valign", "dir", @@ -119,6 +121,7 @@ _TAG_SPECIFIC: dict[str, set[str]] = { "th": {"colspan", "rowspan", "scope"}, "table": {"cellpadding", "cellspacing", "border"}, "font": {"face", "color", "size"}, + "style": {"type", "media"}, } # nh3 has no "generic attributes" option, so expand the generic set onto every tag. @@ -132,6 +135,12 @@ _STYLE_DANGEROUS = re.compile( r"(expression\s*\(|javascript:|vbscript:|@import|-moz-binding|behavior\s*:)", re.IGNORECASE ) _STYLE_URL = re.compile(r"url\s*\([^)]*\)", re.IGNORECASE) +# Script-like CSS that voids a whole )", re.IGNORECASE | re.DOTALL) @dataclass(frozen=True) @@ -150,6 +159,28 @@ def _sanitize_style(value: str, allow_remote: bool) -> str | None: return value +def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, bool]) -> str: + """Sanitize CSS inside ``' + result = sanitize_html(html, MID) + assert " None: + html = '

hi

' + result = sanitize_html(html, MID) + assert "expression(" not in result.html.lower() + # the block is nuked but the element/class survive + assert 'class="x"' in result.html + + +def test_style_block_import_and_url_stripped_when_remote_blocked() -> None: + html = "" + result = sanitize_html(html, MID) + assert "@import" not in result.html.lower() + assert "evil" not in result.html.lower() + assert result.has_remote_images + + +def test_script_content_still_stripped_with_style_allowed() -> None: + html = "" + result = sanitize_html(html, MID) + assert "alert(1)" not in result.html + assert ".a{color:red}" in result.html diff --git a/backend/tests/plugins/test_calendar_plugin.py b/backend/tests/plugins/test_calendar_plugin.py new file mode 100644 index 0000000..2831a50 --- /dev/null +++ b/backend/tests/plugins/test_calendar_plugin.py @@ -0,0 +1,170 @@ +"""Calendar plugin A1 API: calendars/events CRUD + range expansion (SPEC §A). + +Exercises the real HTTP surface: default-calendar bootstrap, event create/list with +recurrence expansion, edit, delete, read-only rejection, and per-user isolation. +Requires the calendar plugin installed + migrated. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.main import create_app +from app.models import User + +CAL = "/api/plugins/calendar" + + +class _Ctx: + user_id: uuid.UUID + sm: async_sessionmaker # type: ignore[type-arg] + client: httpx.AsyncClient + client_b: httpx.AsyncClient + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[_Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + c = _Ctx() + c.sm = sm + ids: list[uuid.UUID] = [] + async with sm() as s: + for _ in range(2): + handle = f"cal{uuid.uuid4().hex[:8]}@test.local" + u = User(handle=handle, password_hash=hash_password("pw")) + s.add(u) + await s.flush() + ids.append(u.id) + await s.commit() + c.user_id = ids[0] + transport = ASGITransport(app=create_app()) + + async def client(uid: uuid.UUID) -> httpx.AsyncClient: + token = await create_session(uid, "pytest") + return httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + cookies={"session": token, "csrf": "csrf"}, + headers={"X-CSRF": "csrf"}, + ) + + c.client = await client(ids[0]) + c.client_b = await client(ids[1]) + try: + yield c + finally: + await c.client.aclose() + await c.client_b.aclose() + async with sm() as s: # cascade drops plg_calendar_* rows (user_id FK-less but scoped) + await s.execute( + text("DELETE FROM plg_calendar_calendars WHERE user_id = ANY(:ids)"), + {"ids": [str(i) for i in ids]}, + ) + await s.execute(delete(User).where(User.id.in_(ids))) + await s.commit() + await engine.dispose() + + +async def _events( + client: httpx.AsyncClient, + start: str = "2026-06-01T00:00:00Z", + end: str = "2026-06-02T00:00:00Z", +) -> list[dict[str, object]]: + r = await client.get(f"{CAL}/events", params={"start": start, "end": end}) + return r.json()["events"] + + +async def test_default_calendar_and_event_crud(ctx: _Ctx) -> None: + # First calendars call bootstraps a default "Personal" calendar. + cals = (await ctx.client.get(f"{CAL}/calendars")).json() + assert [c["name"] for c in cals] == ["Personal"] + cal_id = cals[0]["id"] + + # Create a timed event and read it back in a range. + r = await ctx.client.post( + f"{CAL}/events", + json={ + "calendar_id": cal_id, + "summary": "Standup", + "dtstart": "2026-06-01T09:00:00Z", + "dtend": "2026-06-01T09:30:00Z", + }, + ) + assert r.status_code == 200 + event_id = r.json()["id"] + + listing = await _events(ctx.client) + assert len(listing) == 1 + assert listing[0]["summary"] == "Standup" + assert listing[0]["color"] == "#3b82f6" + + # Edit, then delete. + patched = await ctx.client.patch(f"{CAL}/events/{event_id}", json={"summary": "Daily standup"}) + assert patched.status_code == 200 + again = await _events(ctx.client) + assert again[0]["summary"] == "Daily standup" + + assert (await ctx.client.delete(f"{CAL}/events/{event_id}")).status_code == 200 + empty = await _events(ctx.client) + assert empty == [] + + +async def test_recurring_event_expands_in_range(ctx: _Ctx) -> None: + cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"] + await ctx.client.post( + f"{CAL}/events", + json={ + "calendar_id": cal_id, + "summary": "Weekly sync", + "dtstart": "2026-06-01T14:00:00Z", # Monday + "dtend": "2026-06-01T15:00:00Z", + "rrule": "FREQ=WEEKLY;BYDAY=MO", + }, + ) + # A 3-week window contains 3 Monday occurrences of one recurring event. + listing = await _events(ctx.client, end="2026-06-22T00:00:00Z") + assert len(listing) == 3 + assert all(i["recurring"] for i in listing) + assert [i["start"][:10] for i in listing] == ["2026-06-01", "2026-06-08", "2026-06-15"] + + +async def test_read_only_calendar_rejects_writes(ctx: _Ctx) -> None: + cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"] + # Simulate an ICS subscription: flip the calendar read-only directly. + async with ctx.sm() as s: + await s.execute( + text("UPDATE plg_calendar_calendars SET read_only = true WHERE id = :id"), + {"id": cal_id}, + ) + await s.commit() + r = await ctx.client.post( + f"{CAL}/events", + json={"calendar_id": cal_id, "summary": "nope", "dtstart": "2026-06-01T09:00:00Z"}, + ) + assert r.status_code == 403 + body = r.json() + detail = body.get("error", {}).get("message") or body.get("detail") + assert detail == "read_only_calendar" + + +async def test_events_are_per_user_isolated(ctx: _Ctx) -> None: + cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"] + await ctx.client.post( + f"{CAL}/events", + json={"calendar_id": cal_id, "summary": "Private", "dtstart": "2026-06-05T10:00:00Z"}, + ) + # User B has their own default calendar and sees none of A's events. + b_events = await _events(ctx.client_b, end="2026-06-30T00:00:00Z") + assert b_events == [] diff --git a/backend/tests/plugins/test_calendar_recurrence.py b/backend/tests/plugins/test_calendar_recurrence.py new file mode 100644 index 0000000..62ba7c1 --- /dev/null +++ b/backend/tests/plugins/test_calendar_recurrence.py @@ -0,0 +1,84 @@ +"""Calendar recurrence expansion across a DST boundary (SPEC §A.3, acceptance A.7.2).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from webmail_plugin_calendar.recurrence import expand_occurrences # type: ignore[import-not-found] + +WARSAW = ZoneInfo("Europe/Warsaw") + + +def _local(occ: list[tuple[datetime, datetime]]) -> dict[str, datetime]: + # Map "YYYY-MM-DD" (Warsaw date) -> the UTC start of that occurrence. + return {s.astimezone(WARSAW).date().isoformat(): s for s, _ in occ} + + +def test_weekly_recurrence_keeps_local_time_across_dst() -> None: + # Monday 2026-03-23 10:00 Europe/Warsaw == 09:00 UTC (UTC+1, before DST). + # DST starts 2026-03-29, so from 03-30 the offset is UTC+2. + dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC) + occ = expand_occurrences( + dtstart=dtstart, + duration=timedelta(hours=1), + rrule="FREQ=WEEKLY;BYDAY=MO", + exdates=[], + range_start=datetime(2026, 3, 1, tzinfo=UTC), + range_end=datetime(2026, 4, 30, tzinfo=UTC), + tz="Europe/Warsaw", + ) + by_date = _local(occ) + + # Every occurrence is at 10:00 local wall-clock, on both sides of the DST change. + for start, _ in occ: + assert start.astimezone(WARSAW).strftime("%H:%M") == "10:00" + + # ...but the UTC offset moves across the boundary. + assert by_date["2026-03-23"].hour == 9 # UTC+1 + assert by_date["2026-03-30"].hour == 8 # UTC+2 (DST) + assert by_date["2026-04-06"].hour == 8 + + +def test_exdates_remove_instances() -> None: + dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC) + occ = expand_occurrences( + dtstart=dtstart, + duration=timedelta(hours=1), + rrule="FREQ=WEEKLY;BYDAY=MO", + # Exclude the 2026-03-30 occurrence (10:00 Warsaw == 08:00 UTC under DST). + exdates=[datetime(2026, 3, 30, 8, 0, tzinfo=UTC)], + range_start=datetime(2026, 3, 1, tzinfo=UTC), + range_end=datetime(2026, 4, 30, tzinfo=UTC), + tz="Europe/Warsaw", + ) + dates = set(_local(occ)) + assert "2026-03-23" in dates + assert "2026-03-30" not in dates # excluded + assert "2026-04-06" in dates + + +def test_single_event_no_rrule() -> None: + dtstart = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + occ = expand_occurrences( + dtstart=dtstart, + duration=timedelta(minutes=30), + rrule=None, + exdates=[], + range_start=datetime(2026, 6, 1, tzinfo=UTC), + range_end=datetime(2026, 6, 2, tzinfo=UTC), + tz="UTC", + ) + assert occ == [(dtstart, dtstart + timedelta(minutes=30))] + + # Outside the window -> nothing. + empty = expand_occurrences( + dtstart=dtstart, + duration=timedelta(minutes=30), + rrule=None, + exdates=[], + range_start=datetime(2026, 7, 1, tzinfo=UTC), + range_end=datetime(2026, 7, 2, tzinfo=UTC), + tz="UTC", + ) + assert empty == [] diff --git a/backend/tests/plugins/test_sdk_security.py b/backend/tests/plugins/test_sdk_security.py new file mode 100644 index 0000000..55647e5 --- /dev/null +++ b/backend/tests/plugins/test_sdk_security.py @@ -0,0 +1,33 @@ +"""SDK security surface for heavy plugins: vault + multi-method guarded HTTP.""" + +from __future__ import annotations + +import pytest + +from app.accounts.ssrf import SsrfError, guarded_request +from app.plugins.sdk import sdk + + +def test_vault_round_trips_secrets() -> None: + vault = sdk("calendar").vault + secret = b"caldav-app-password-123" + blob = vault.encrypt(secret) + assert blob != secret # encrypted at rest + assert vault.decrypt(blob) == secret + + +async def test_guarded_request_rejects_non_https() -> None: + with pytest.raises(SsrfError, match="https"): + await guarded_request("PROPFIND", "http://dav.example/cal/") + + +async def test_guarded_request_rejects_private_host() -> None: + # Resolves to a loopback/private address -> blocked before any connection. + with pytest.raises(SsrfError, match="non-public|resolve"): + await guarded_request("GET", "https://localhost/") + + +def test_http_facade_exposes_dav_verbs() -> None: + http = sdk("calendar").http + for verb in ("request", "get", "put", "delete", "propfind", "report"): + assert callable(getattr(http, verb)) diff --git a/frontend/src/components/AppSwitcher.tsx b/frontend/src/components/AppSwitcher.tsx new file mode 100644 index 0000000..62c63e7 --- /dev/null +++ b/frontend/src/components/AppSwitcher.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { usePluginPages } from "../plugins/manifest"; + +// Product switcher shown in the sidebar header: Webmail + any plugin "app.page" +// (Calendar, later Contacts). Plugin-agnostic — the app list comes from the +// manifest, so a new page-plugin appears here with no core change. +export function AppSwitcher() { + const navigate = useNavigate(); + const pages = usePluginPages(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + const apps = [ + { key: "mail", label: "Mail", to: "/mail" }, + ...pages.map((p) => ({ key: `${p.plugin}${p.route}`, label: p.nav.label, to: `/p${p.route}` })), + ]; + + return ( +
+ + {open && ( +
+ {apps.map((a) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx index 07f9819..6fcb95b 100644 --- a/frontend/src/components/list/DraftsList.tsx +++ b/frontend/src/components/list/DraftsList.tsx @@ -1,12 +1,12 @@ import { useT } from "../../i18n"; +import { formatClock, getDateLocale } from "../../lib/timeFormat"; import type { DraftOut, RemoteDraftOut } from "../../lib/types"; function timeLabel(iso: string): string { const d = new Date(iso); const now = new Date(); - if (d.toDateString() === now.toDateString()) - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - return d.toLocaleDateString([], { month: "short", day: "numeric" }); + if (d.toDateString() === now.toDateString()) return formatClock(d); + return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" }); } function DraftRow({ diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index f032d98..4adf0f5 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -2,17 +2,17 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef } from "react"; import { useT } from "../../i18n"; +import { formatClock, getDateLocale } from "../../lib/timeFormat"; import type { Address, ThreadSummary } from "../../lib/types"; import { Icon } from "../Icon"; function timeLabel(iso: string): string { const d = new Date(iso); const now = new Date(); - if (d.toDateString() === now.toDateString()) - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (d.toDateString() === now.toDateString()) return formatClock(d); if (d.getFullYear() === now.getFullYear()) - return d.toLocaleDateString([], { month: "short", day: "numeric" }); - return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" }); + return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" }); + return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" }); } // Stable per-sender hue so a sender's avatar keeps a consistent colour. diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index 124cc94..24f15a7 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; import { printMessages } from "../../lib/print"; +import { getDateLocale, getHour12 } from "../../lib/timeFormat"; import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import { PluginSlot } from "../../plugins/PluginSlot"; import { Composer, type ComposerInit } from "../compose/Composer"; @@ -39,13 +40,17 @@ function recipientSummary(addrs: { name: string; email: string }[]): string { function attributionFor(msg: MessageEnvelope): string { const d = new Date(msg.date); - const date = d.toLocaleDateString("en-US", { + const date = d.toLocaleDateString(getDateLocale(), { weekday: "short", month: "short", day: "numeric", year: "numeric", }); - const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + const time = d.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: getHour12(), + }); const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || ""; return `On ${date}, at ${time}, ${who} wrote:`; } @@ -110,13 +115,14 @@ function fmtRelative(iso: string): string { } function fmtFull(iso: string): string { - return new Date(iso).toLocaleString("en-US", { + return new Date(iso).toLocaleString(getDateLocale(), { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + hour12: getHour12(), }); } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 975dec9..4ed02ff 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -223,6 +223,13 @@ export const en = { "settings.revoke": "Revoke", "settings.threadOrder": "Conversation order", "settings.timezone": "Time zone", + "settings.timeFormat": "Time format", + "settings.timeFormat24": "24-hour (14:30)", + "settings.timeFormat12": "12-hour (2:30 PM)", + "settings.integrations": "Integrations", + "settings.jitsi": "Jitsi Meet", + "settings.jitsiHint": + "Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.", "settings.addAccount": "Add account", "settings.syncState": "State: {state}", "settings.confirmRemoveAccount": "Remove this account and all its mail?", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 13c33f2..683a5b0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -166,7 +166,9 @@ export const api = { // --- settings --- settings: () => apiFetch("/settings"), - patchSettings: (patch: Partial>) => + patchSettings: ( + patch: Partial>, + ) => apiFetch("/settings", { method: "PATCH", body: JSON.stringify(patch) }), quota: () => apiFetch("/settings/quota"), folderCounts: () => apiFetch("/mailboxes/counts"), diff --git a/frontend/src/lib/print.ts b/frontend/src/lib/print.ts index 3c63a42..d170cbb 100644 --- a/frontend/src/lib/print.ts +++ b/frontend/src/lib/print.ts @@ -3,6 +3,7 @@ // the browser's print dialog. Reuses the same sanitized HTML the reader shows. import { api } from "./api"; +import { getDateLocale, getHour12 } from "./timeFormat"; import type { Address, AttachmentOut, MessageEnvelope } from "./types"; function esc(s: string): string { @@ -24,13 +25,14 @@ function fmtList(addrs: Address[]): string { function fmtDate(iso: string): string { const d = new Date(iso); - return d.toLocaleString(undefined, { + return d.toLocaleString(getDateLocale(), { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + hour12: getHour12(), }); } diff --git a/frontend/src/lib/timeFormat.ts b/frontend/src/lib/timeFormat.ts new file mode 100644 index 0000000..34e6935 --- /dev/null +++ b/frontend/src/lib/timeFormat.ts @@ -0,0 +1,41 @@ +// Shared 12h/24h time preference (SPEC §9.1). The value lives in user settings +// (`time_format`), synced here so the module-level date formatters used across +// Mail can read it synchronously during render. 24-hour is the default. + +let hour12 = false; + +try { + if (typeof localStorage !== "undefined" && localStorage.getItem("time_format") === "12") { + hour12 = true; + } +} catch { + /* localStorage unavailable (SSR/tests) — keep the 24h default. */ +} + +/** Called when the settings query resolves/changes to sync the preference. */ +export function setTimeFormat(fmt: "12" | "24"): void { + hour12 = fmt === "12"; + try { + localStorage.setItem("time_format", fmt); + } catch { + /* ignore */ + } +} + +/** `hour12` option for Intl date/time formatting, reflecting the preference. */ +export function getHour12(): boolean { + return hour12; +} + +/** + * Locale for date formatting, tied to the time preference: 24-hour → en-GB + * (day/month/year, European), 12-hour → en-US (month/day/year, American). + */ +export function getDateLocale(): string { + return hour12 ? "en-US" : "en-GB"; +} + +/** Format just the clock portion (e.g. "14:30" or "2:30 PM") of a Date. */ +export function formatClock(d: Date): string { + return d.toLocaleTimeString([], { hour: hour12 ? "numeric" : "2-digit", minute: "2-digit", hour12 }); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f508054..be72adf 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -151,6 +151,8 @@ export interface SessionInfo { export interface UserSettings { thread_order: "asc" | "desc"; tz: string; + time_format: "12" | "24"; + jitsi_server: string; allowed_image_senders: string[]; } diff --git a/frontend/src/plugins/PluginPage.tsx b/frontend/src/plugins/PluginPage.tsx index df28fb1..d1a8418 100644 --- a/frontend/src/plugins/PluginPage.tsx +++ b/frontend/src/plugins/PluginPage.tsx @@ -5,7 +5,7 @@ // header keeps a way back to Mail. (Sidebar persistence is a later refinement.) import { useEffect, useMemo, useRef } from "react"; -import { Link, Navigate, useLocation } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; import { useT } from "../i18n"; import { loadBundle } from "./bundleLoader"; @@ -40,7 +40,7 @@ function PageMount({ }; }, [bundle, ctx, props, pluginName]); - return
; + return
; } export function PluginPage() { @@ -58,17 +58,10 @@ export function PluginPage() { } if (!match) return ; + // Full-bleed: app pages (like Calendar) own their entire chrome, including the + // product switcher for navigating back to Mail. return ( -
-
- - ← {t("nav.mail")} - - {match.nav.label} -
+
); diff --git a/frontend/src/plugins/context.ts b/frontend/src/plugins/context.ts index 6908f92..03d8413 100644 --- a/frontend/src/plugins/context.ts +++ b/frontend/src/plugins/context.ts @@ -1,6 +1,7 @@ // Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3). import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; import type { MessageKey } from "../i18n/en"; import { useT } from "../i18n"; @@ -11,6 +12,7 @@ import type { PluginContext } from "./types"; /** A memoised context bound to one plugin, usable from slots and pages. */ export function usePluginContext(pluginName: string): PluginContext { const t = useT(); + const navigate = useNavigate(); return useMemo( () => ({ pluginName, @@ -27,7 +29,8 @@ export function usePluginContext(pluginName: string): PluginContext { // Plugins mostly ship their own strings; app keys resolve, unknown keys // fall through as-is (the app translator returns the key when missing). t: (key, vars) => t(key as MessageKey, vars), + navigate: (path) => navigate(path), }), - [pluginName, t], + [pluginName, t, navigate], ); } diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts index 46ef407..090c6dc 100644 --- a/frontend/src/plugins/types.ts +++ b/frontend/src/plugins/types.ts @@ -54,6 +54,8 @@ export interface PluginContext { api: PluginApi; events: PluginEvents; t: (key: string, vars?: Record) => string; + /** Navigate the app's SPA router (e.g. back to "/mail" from a page bundle). */ + navigate: (path: string) => void; } export type PluginUnmount = () => void; diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 3fe6c91..424cfba 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta import { useEffect, useRef, useState } from "react"; import { Link, useMatch, useNavigate } from "react-router-dom"; +import { AppSwitcher } from "../components/AppSwitcher"; import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer"; import { DraftsList } from "../components/list/DraftsList"; import { Icon } from "../components/Icon"; @@ -14,6 +15,7 @@ import { ThreadView } from "../components/thread/ThreadView"; import { useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; import { api } from "../lib/api"; +import { setTimeFormat } from "../lib/timeFormat"; import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts"; import { usePluginPages } from "../plugins/manifest"; @@ -137,6 +139,10 @@ export function MailLayout() { const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches }); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc"; + const timeFormat = settingsQuery.data?.time_format; + useEffect(() => { + if (timeFormat) setTimeFormat(timeFormat); + }, [timeFormat]); const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota }); const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts }); @@ -430,11 +436,7 @@ export function MailLayout() { collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3" }`} > - {!collapsed && ( - - {t("common.appName")} - - )} + {!collapsed && }
+
`; + +const COLORS = [ + ["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"], + ["Purple", "#a855f7"], ["Teal", "#14b8a6"], ["Pink", "#ec4899"], ["Gray", "#6b7280"], +]; + +function tzList() { + try { + return Intl.supportedValuesOf("timeZone"); + } catch { + return ["UTC", "Europe/London", "Europe/Warsaw", "Europe/Berlin", "America/New_York", + "America/Chicago", "America/Los_Angeles", "Asia/Tokyo", "Asia/Shanghai", "Australia/Sydney"]; + } +} + +// The zone's short code/abbreviation (e.g. "CEST", "GMT+2") for the timegrid label. +function tzAbbr(tz) { + if (!tz || tz === "floating") return ""; + try { + const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "short" }).formatToParts(new Date()); + const p = parts.find((x) => x.type === "timeZoneName"); + return p ? p.value : tz; + } catch { + return tz; + } +} + +function tzOffset(tz) { + try { + const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date()); + const p = parts.find((x) => x.type === "timeZoneName"); + return p ? p.value : "GMT"; + } catch { + return "GMT"; + } +} + +// Long descriptive label + offset, e.g. "Central European Summer Time (GMT+2)". +function tzLongLabel(tz) { + if (!tz || tz === "floating") return "Floating time"; + let name = tz; + try { + const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "long" }).formatToParts(new Date()); + const p = parts.find((x) => x.type === "timeZoneName"); + if (p) name = p.value; + } catch { /* keep tz */ } + return `${name} (${tzOffset(tz)})`; +} + +const tzCity = (tz) => tz.split("/").pop().replace(/_/g, " "); + +// Full searchable list: current (checked) + Floating + all IANA cities (sorted by +// offset then name) + fixed GMT offsets + UTC. +function tzEntries(current) { + const zones = tzList(); + const withOffset = zones.map((z) => ({ tz: z, city: tzCity(z), abbr: tzAbbr(z), off: tzOffset(z) })); + const offNum = (o) => { const m = /GMT([+-]\d+)(?::(\d+))?/.exec(o); return m ? Number(m[1]) * 60 + (m[1][0] === "-" ? -1 : 1) * Number(m[2] || 0) : 0; }; + withOffset.sort((a, b) => offNum(a.off) - offNum(b.off) || a.city.localeCompare(b.city)); + const gmt = []; + for (let n = -12; n <= 14; n++) if (n !== 0) gmt.push({ tz: `Etc/GMT${n > 0 ? "-" : "+"}${Math.abs(n)}`, city: `GMT${n > 0 ? "+" : "-"}${Math.abs(n)}`, abbr: `GMT${n > 0 ? "+" : "-"}${Math.abs(n)}`, off: "" }); + const out = [{ tz: current, city: tzCity(current), abbr: tzAbbr(current), off: "", cur: true }, { tz: "floating", city: "Floating time", abbr: "", off: "" }]; + return out.concat(withOffset, gmt, [{ tz: "UTC", city: "UTC", abbr: "UTC", off: "" }]); +} + +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-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} +.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} +.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: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-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.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-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: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-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.out .n{color:rgb(var(--app-faint))} +.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))} +/* 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 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} +.cal-tz{font-size:11px;color:rgb(var(--app-faint));display:flex;align-items:flex-end;justify-content:center;padding-bottom:4px;text-transform:uppercase} +.cal-hours{color:rgb(var(--app-faint));font-size:11px} +.cal-hours div{height:${HOUR_PX}px;text-align:right;padding-right:6px;transform:translateY(-6px)} +.cal-cols{display:grid;position:relative} +.cal-col{border-left:1px solid rgb(var(--app-border));position:relative} +.cal-col.today{background:rgba(59,130,246,.06)} +.cal-col .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-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))} +.cal-agenda .day .dt{width:44px;flex:0 0 auto;font-size:12px;color:rgb(var(--app-muted))} +.cal-agenda .day .dt b{display:block;font-size:18px;color:rgb(var(--app-fg))} +.cal-agenda .none{color:rgb(var(--app-faint));font-size:13px} +/* new-event form (floating box) */ +/* Content-sized box: grows in height as sections are added, scrolls only past the + viewport cap; the Save/Cancel footer sticks to the bottom while scrolling. */ +.cal-form{position:absolute;left:10px;top:58px;width:364px;max-height:calc(100vh - 74px);background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 14px 44px rgba(0,0,0,.5);z-index:40;overflow:auto} +.cal-form-body{padding:16px} +.cal-form-foot{position:sticky;bottom:0;background:rgb(var(--app-elevated));border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;gap:8px} +.cal-acpop{position:absolute;left:0;right:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:55;max-height:220px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.4)} +.cal-acpop button{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:6px 8px;border-radius:6px;cursor:pointer;font:inherit;text-align:left} +.cal-acpop button:hover{background:rgb(var(--app-hover))} +.cal-acpop button span{color:rgb(var(--app-muted));font-size:12px} +.cal-guestlist .cap{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint));margin-bottom:8px} +.cal-guestlist{max-height:180px;overflow:auto} +.cal-guestlist .g{display:flex;align-items:center;gap:8px;margin-bottom:8px} +.cal-guestlist .av{position:relative;width:26px;height:26px;border-radius:50%;background:rgb(var(--app-hover));display:inline-flex;align-items:center;justify-content:center;font-size:12px;flex:0 0 auto} +.cal-badge{position:absolute;right:-3px;bottom:-3px;width:14px;height:14px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;border:2px solid rgb(var(--app-elevated));color:#fff;font-size:9px;font-weight:700;box-sizing:content-box} +.cal-badge svg{width:9px;height:9px;display:block} +.cal-badge.accepted{background:#22c55e} +.cal-badge.declined{background:#ef4444} +.cal-badge.tentative{background:#6b7280} +.cal-badge.needs-action{background:#9ca3af} +.cal-guestlist .gm{display:flex;flex-direction:column;min-width:0} +.cal-guestlist .gn{font-size:13px} +.cal-guestlist .gr{color:rgb(var(--app-faint));font-size:11px} +.cal-guestlist .ge{font-size:11px;color:rgb(var(--app-faint));overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.cal-videolink{display:flex;align-items:center;gap:8px;font-size:13px;word-break:break-all} +.cal-videolink a{color:#3b82f6;text-decoration:none} +.cal-videolink a:hover{text-decoration:underline} +.cal-videolink .cal-iconbtn{flex:0 0 auto} +.cal-iconbtn.copied{color:#22c55e} +.cal-form h4{margin:14px 0 5px;font-size:13px;font-weight:600} +.cal-swatches{display:flex;gap:7px;flex-wrap:wrap;align-items:center;height:34px} +.cal-sw{width:20px;height:20px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0} +.cal-sw.sel{border-color:rgb(var(--app-fg))} +.cal-form input[type=text],.cal-form input[type=email],.cal-form textarea,.cal-form select,.cal-form input[type=date],.cal-form input[type=time],.cal-form input[type=number]{width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));font:inherit;box-sizing:border-box} +.cal-form .row{display:flex;gap:8px;align-items:center} +.cal-form .muted{color:rgb(var(--app-muted));font-size:13px} +.cal-formbtns{display:flex;gap:8px;margin-top:16px} +.cal-guest{display:inline-flex;align-items:center;gap:4px;background:rgb(var(--app-hover));border-radius:10px;padding:2px 8px;font-size:12px;margin:2px 4px 2px 0} +.cal-guest button{border:0;background:none;color:inherit;cursor:pointer} +.cal-tog{width:34px;height:20px;border-radius:10px;background:rgb(var(--app-hover));border:0;cursor:pointer;position:relative;flex:0 0 auto} +.cal-tog.on{background:#3b82f6} +.cal-tog::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .1s} +.cal-tog.on::after{left:16px} +.cal-dd{position:relative} +.cal-dd>button{display:flex;align-items:center;gap:6px;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));cursor:pointer;font:inherit;justify-content:space-between} +.cal-dot{width:14px;height:14px;border-radius:50%;display:inline-block;flex:0 0 auto} +.cal-ddpop{position:absolute;left:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:50;min-width:150px;max-height:280px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.4)} +.cal-ddpop button{display:flex;align-items:center;gap:8px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:6px 8px;border-radius:6px;cursor:pointer;font:inherit;text-align:left} +.cal-ddpop button:hover{background:rgb(var(--app-hover))} +.cal-tzfield{position:relative} +.cal-tzfield>button{display:flex;align-items:center;justify-content:space-between;gap:6px;width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));cursor:pointer;font:inherit;box-sizing:border-box} +.cal-tzfield .off{color:rgb(var(--app-faint))} +.cal-tzpop{position:absolute;left:0;right:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;z-index:50;box-shadow:0 10px 30px rgba(0,0,0,.5);overflow:hidden} +.cal-tzpop .search{margin:8px;width:calc(100% - 16px);box-sizing:border-box} +.cal-tzlist{max-height:280px;overflow:auto;padding-bottom:4px} +.cal-tzlist button{display:flex;justify-content:space-between;align-items:center;gap:10px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:7px 12px;cursor:pointer;font:inherit;text-align:left} +.cal-tzlist button:hover{background:rgb(var(--app-hover))} +.cal-tzlist .abbr{color:rgb(var(--app-faint));font-size:12px;flex:0 0 auto} +.cal-pop{position:absolute;width:290px;max-height:calc(100vh - 16px);display:flex;flex-direction:column;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);z-index:60;overflow:hidden} +.cal-pop .body{padding:14px 16px;overflow:auto} +.cal-pop .cap{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))} +.cal-pop .ttl{font-size:17px;font-weight:600;margin:2px 0} +.cal-pop .loc{color:rgb(var(--app-muted))} +.cal-pop hr{border:0;border-top:1px solid rgb(var(--app-border));margin:12px 0} +.cal-pop .foot{flex:0 0 auto;border-top:1px solid rgb(var(--app-border));padding:10px 16px;display:flex;justify-content:space-between} +.cal-modal-ov{position:absolute;inset:0;z-index:70;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center} +.cal-modal{width:380px;max-width:calc(100vw - 32px);background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);overflow:hidden} +.cal-modal .body{padding:20px 22px} +.cal-modal h3{margin:0 0 8px;font-size:17px;font-weight:600} +.cal-modal p{margin:0;color:rgb(var(--app-muted));font-size:14px} +.cal-modal .opts{margin-top:14px;display:flex;flex-direction:column;gap:8px} +.cal-modal .opts button{display:block;width:100%;text-align:left;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg));padding:10px 12px;border-radius:8px;cursor:pointer;font:inherit} +.cal-modal .opts button:hover{background:rgb(var(--app-hover));border-color:#3b82f6} +.cal-modal .foot{border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;justify-content:flex-end;gap:8px} +`; + +export default function mount(el, ctx) { + if (!document.getElementById("cal-styles")) { + const style = document.createElement("style"); + style.id = "cal-styles"; + style.textContent = CSS; + document.head.appendChild(style); + } + + const state = { + view: "month", + anchor: startOfDay(new Date()), + mini: startOfMonth(new Date()), + calendars: [], + hidden: new Set(), + events: [], + rightOpen: false, + menuOpen: false, + leftW: Number(localStorage.getItem("cal.leftW")) || 240, + leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1", + composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId } + eventPopover: null, // null | { ev, rect } + confirm: null, // null | { ev } — delete confirmation modal + me: "", + quota: "", + jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/) + apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }], + }; + + const api = ctx.api; + + function rangeFor() { + if (state.view === "month") { + const gs = startOfWeek(startOfMonth(state.anchor)); + return [gs, addDays(gs, 42)]; + } + const span = state.view === "day" ? 1 : state.view === "3days" ? 3 : 7; + const s = state.view === "week" ? startOfWeek(state.anchor) : startOfDay(state.anchor); + return [s, addDays(s, span)]; + } + + async function loadCalendars() { + state.calendars = await api.pluginFetch("/calendars"); + } + async function loadEvents() { + const [s, e] = rangeFor(); + const res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`); + state.events = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) })); + } + function visibleEvents() { + return state.events.filter((ev) => !state.hidden.has(ev.calendar_id)); + } + + async function reload() { + await loadEvents(); + render(); + } + + function pad(n) { return String(n).padStart(2, "0"); } + function openForm(when, editEv) { + const d = when || new Date(); // default to the current time + state.eventPopover = null; + state.composing = { + date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}`, + tz: editEv ? editEv.tz : null, + conference: editEv ? (editEv.conference_url || "") : "", + editId: editEv ? editEv.id : null, + recurring: editEv ? !!editEv.recurring : false, + recurrenceStart: editEv ? editEv.start : null, // the clicked occurrence's start + }; + render(); + if (editEv) prefillForm(editEv); + const first = el.querySelector("[data-f=summary]"); + if (first) first.focus(); + } + + function prefillForm(ev) { + const q = (s) => el.querySelector(s); + q("[data-f=summary]").value = ev.summary || ""; + q("[data-f=location]").value = ev.location || ""; + if (q(`[data-f=calendar] option[value="${ev.calendar_id}"]`)) q("[data-f=calendar]").value = ev.calendar_id; + q("[data-f=showas]").value = ev.show_as || "busy"; + q("[data-f=notes]").value = ev.description || ""; + if (ev.all_day) { + q("[data-f=allday]").checked = true; + q("[data-f=time]").disabled = true; + q("[data-f=duration]").disabled = true; + } + const durMin = Math.round((ev._e - ev._s) / 60000); + if (q(`[data-f=duration] option[value="${durMin}"]`)) q("[data-f=duration]").value = String(durMin); + if (ev.color) { + const dd = q(".cal-dd"); + dd.setAttribute("data-color", ev.color); + dd.querySelector(".cal-dot").style.background = ev.color; + } + // Reminders — rebuild a row per stored minute value. + if (ev.reminders && ev.reminders.length) { + const box = q("[data-reminders]"); + box.innerHTML = ""; + ev.reminders.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} minutes before`; + sel.appendChild(o); + } + sel.value = String(m); + row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove()); + box.appendChild(row); + }); + } + // Repeat — restore the RRULE into the toggle + fields. + if (ev.rrule) { + const r = parseRrule(ev.rrule); + q("[data-act=repeat-toggle]").classList.add("on"); + q("[data-repeat]").style.display = ""; + if (r.freq) q("[data-f=freq]").value = r.freq; + q("[data-f=interval]").value = String(r.interval || 1); + if (r.count) { + q("[data-f=endmode]").value = "count"; + q("[data-f=count]").value = String(r.count); + q("[data-f=count]").style.display = ""; + } else if (r.until) { + q("[data-f=endmode]").value = "until"; + q("[data-f=until]").value = r.until; + q("[data-f=until]").style.display = ""; + } + } + // Guests — expand the Who section and restore chips (no auto video-call). + if (ev.attendees && ev.attendees.length) { + const addChip = expandGuests(); + ev.attendees.forEach((a) => addChip(a.email, a.name || "", false)); + } + } + + async function saveForm() { + const q = (sel) => el.querySelector(sel); + const calId = q("[data-f=calendar]").value; + if (!calId) return; + const allday = q("[data-f=allday]").checked; + const date = q("[data-f=date]").value; + const time = allday ? "00:00" : q("[data-f=time]").value; + const start = new Date(`${date}T${time}:00`); + const durMin = allday ? 1440 : Number(q("[data-f=duration]").value); + const end = new Date(start.getTime() + durMin * 60000); + let rrule = null; + if (q("[data-act=repeat-toggle]").classList.contains("on")) { + const freq = q("[data-f=freq]").value; + const interval = Number(q("[data-f=interval]").value) || 1; + let s = `FREQ=${freq}`; + if (interval > 1) s += `;INTERVAL=${interval}`; + if (freq === "WEEKLY") s += `;BYDAY=${["MO", "TU", "WE", "TH", "FR", "SA", "SU"][(start.getDay() + 6) % 7]}`; + const mode = q("[data-f=endmode]").value; + if (mode === "count") s += `;COUNT=${Number(q("[data-f=count]").value) || 1}`; + else if (mode === "until" && q("[data-f=until]").value) s += `;UNTIL=${q("[data-f=until]").value.replace(/-/g, "")}T000000Z`; + rrule = s; + } + const reminders = [...el.querySelectorAll("[data-rem]")].map((x) => Number(x.value)); + const attendees = [...el.querySelectorAll("[data-guest]")].map((x) => ({ email: x.getAttribute("data-guest"), name: x.getAttribute("data-name") || "" })); + let tz = el.querySelector(".cal-tzfield").getAttribute("data-tz"); + if (tz === "floating") tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const color = el.querySelector(".cal-dd").getAttribute("data-color"); + const summary = q("[data-f=summary]").value.trim(); + const location = q("[data-f=location]").value.trim(); + const description = q("[data-f=notes]").value; + const showAs = q("[data-f=showas]").value; + const conferenceUrl = (state.composing && state.composing.conference) || ""; + const editId = state.composing && state.composing.editId; + const organizer = state.me ? { email: state.me } : null; + const fields = { + summary, location, description, dtstart: start.toISOString(), dtend: end.toISOString(), + all_day: allday, tz, rrule, reminders, attendees, color, show_as: showAs, conference_url: conferenceUrl, + }; + if (editId && state.composing.recurring) { + // Recurring edit → "this and following": occurrences before the edited one + // keep the original series; this one onward becomes a new series with the edits. + await api.pluginFetch(`/events/${editId}/edit-following`, { + method: "POST", + body: JSON.stringify({ + recurrence_start: state.composing.recurrenceStart, + event: { calendar_id: calId, ...fields, organizer }, + }), + }); + } else if (editId) { + await api.pluginFetch(`/events/${editId}`, { method: "PATCH", body: JSON.stringify(fields) }); + } else { + await api.pluginFetch("/events", { + method: "POST", + body: JSON.stringify({ calendar_id: calId, ...fields, organizer }), + }); + } + state.composing = null; + await reload(); + } + + function openEvent(ev, target) { + state.eventPopover = { ev, rect: target.getBoundingClientRect() }; + render(); + } + + async function doDelete(scope) { + if (!state.confirm) return; + const ev = state.confirm.ev; + state.confirm = null; + const qs = scope === "all" ? "" : `?scope=${scope}&occurrence=${encodeURIComponent(ev.start)}`; + await api.pluginFetch(`/events/${ev.id}${qs}`, { method: "DELETE" }); + await reload(); + } + + // Replace the Video-call button/link in place (no full re-render → keeps form input). + function fillVideo(link) { + if (!state.composing) return; + state.composing.conference = link; + const box = el.querySelector("[data-video]"); + if (!box) return; + box.innerHTML = videoLinkHtml(link); + const copyBtn = box.querySelector("[data-act=copy-jitsi]"); + if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn)); + } + function copyLink(link, btn) { + try { navigator.clipboard.writeText(link); } catch { /* ignore */ } + btn.classList.add("copied"); + setTimeout(() => btn.classList.remove("copied"), 1200); + } + + // Expand the collapsed "Who" section into the organizer + guest-input + chips UI, + // wire the invite button / autocomplete, and return an addGuestChip(email, name, + // autoVideo) helper (shared by the Add-guest button and edit prefill). + function expandGuests() { + const who = el.querySelector("[data-who]"); + who.innerHTML = whoExpandedHtml(); + const guests = who.querySelector("[data-guests]"); + const inp = who.querySelector("[data-f=guest]"); + const acpop = who.querySelector("[data-guest-ac]"); + + function addGuestChip(email, name, autoVideo = true) { + email = (email || "").trim(); + if (!email) return; + const dup = [...guests.querySelectorAll("[data-guest]")].some( + (c) => c.getAttribute("data-guest").toLowerCase() === email.toLowerCase()); + if (!dup) { + const label = name && name !== email ? `${name} <${email}>` : email; + const span = document.createElement("span"); + span.className = "cal-guest"; + span.setAttribute("data-guest", email); + span.setAttribute("data-name", name || ""); + span.innerHTML = `${esc(label)} `; + span.querySelector("button").addEventListener("click", () => span.remove()); + guests.appendChild(span); + // Adding a NEW guest auto-provisions a video call if none exists yet + // (skipped when restoring an existing event's guests during edit). + if (autoVideo && !state.composing.conference) fillVideo(jitsiLink(state.jitsi)); + } + inp.value = ""; + acpop.style.display = "none"; + } + + who.querySelector("[data-act=invite]").addEventListener("click", () => { addGuestChip(inp.value); inp.focus(); }); + inp.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); addGuestChip(inp.value); } + else if (e.key === "Escape") acpop.style.display = "none"; + }); + inp.addEventListener("blur", () => setTimeout(() => { acpop.style.display = "none"; }, 150)); + + // Recipient autocomplete (same source as the mail composer's To field). + let acTimer; + inp.addEventListener("input", () => { + const query = inp.value.trim(); + clearTimeout(acTimer); + if (!query) { acpop.style.display = "none"; return; } + acTimer = setTimeout(async () => { + let results = []; + try { results = await api.fetch(`/recipients/suggest?q=${encodeURIComponent(query)}`); } catch { /* ignore */ } + if (!results || !results.length) { acpop.style.display = "none"; return; } + acpop.innerHTML = results.map((r) => + ``).join(""); + acpop.style.display = ""; + acpop.querySelectorAll("[data-ac-email]").forEach((b) => b.addEventListener("mousedown", (e) => { + e.preventDefault(); + addGuestChip(b.getAttribute("data-ac-email"), b.getAttribute("data-ac-name")); + inp.focus(); + })); + }, 160); + }); + return addGuestChip; + } + + function go(delta) { + const a = state.anchor; + if (state.view === "month") state.anchor = new Date(a.getFullYear(), a.getMonth() + delta, 1); + else state.anchor = addDays(a, delta * (state.view === "day" ? 1 : state.view === "3days" ? 3 : 7)); + state.mini = startOfMonth(state.anchor); + reload(); + } + + // ---- render ---------------------------------------------------------------- + function render() { + el.innerHTML = `
+ ${topbar()} +
+ ${state.leftCollapsed ? "" : `
${leftPanel()}
`} +
${mainHead()}
${viewBody()}
+ ${state.rightOpen ? `
${agenda()}
` : ""} +
+ ${state.composing ? formHtml() : ""} + ${state.eventPopover ? eventPopoverHtml() : ""} + ${state.confirm ? confirmHtml() : ""} +
`; + wire(); + if (state.view !== "month") scrollTimegrid(); + if (state.eventPopover) positionPopover(); + } + + // Clamp the details popover fully inside the viewport (measure actual size after + // paint) so a long guest list never pushes it off-screen / makes the page scroll. + function positionPopover() { + const pop = el.querySelector(".cal-pop"); + if (!pop) return; + const r = state.eventPopover.rect; + const w = pop.offsetWidth; + const h = pop.offsetHeight; + let left = r.right + 8; + if (left + w > window.innerWidth - 8) left = Math.max(8, r.left - w - 8); + if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8; + const top = Math.max(8, Math.min(r.top, window.innerHeight - h - 8)); + pop.style.left = `${Math.max(8, left)}px`; + pop.style.top = `${top}px`; + } + + function topbar() { + const title = MONTHS[state.anchor.getMonth()] + " " + state.anchor.getFullYear(); + return `
+ ${state.leftCollapsed ? `` : ""} +
+ + ${state.menuOpen ? `
${state.apps.map((a, i) => ``).join("")}
` : ""} +
+ +
`; + } + + function leftPanel() { + return ` +
+
+ + +
+
+ +
+ ${miniMonth()} +
Calendars
+
${state.calendars.map((c) => ` + `).join("")}
+
+ + + ${state.quota ? `
${esc(state.quota)}
` : ""} +
`; + } + + function miniMonth() { + const m = state.mini; + const gs = startOfWeek(startOfMonth(m)); + let cells = ""; + for (let i = 0; i < 42; i++) { + const d = addDays(gs, i); + const cls = [d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" "); + cells += ``; + } + return `
+
+ ${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()} + +
+
${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `
${h}
`).join("")}${cells}
+
`; + } + + function mainHead() { + const title = `${MONTHS[state.anchor.getMonth()]} ${state.anchor.getFullYear()}`; + const seg = ["day:Day", "3days:3 days", "week:Week", "month:Month"].map((v) => { + const [key, label] = v.split(":"); + return ``; + }).join(""); + return `
+ ${title} +
+
${seg}
+
+ + + + +
`; + } + + function viewBody() { + return state.view === "month" ? monthView() : timegridView(); + } + + function formHtml() { + const c = state.composing; + const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => ``).join(""); + const chev = ``; + const colorItems = COLORS.map((cc) => ``).join(""); + const tz0 = c.tz || browserTz; + return `
+
+

What

+

Where

+
+

Calendar

+

Color

+
+ + +
+
+

Show as

+
+

When

+ +
+
+
+ + +
+

Repeat

+
+
+ every + + +
+
+ + + +
+
+

Who

+
Guests will be emailed an invitation to the event.
+
+ +
+

Video call

+
${c.conference + ? videoLinkHtml(c.conference) + : ``}
+

Reminders

+
+ +

Description

+
+
+ + +
+
`; + } + + function eventPopoverHtml() { + const { ev, rect } = state.eventPopover; + const cal = state.calendars.find((c) => c.id === ev.calendar_id); + const dateStr = ev._s.toLocaleDateString(dateLocale(), { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const durMin = Math.round((ev._e - ev._s) / 60000); + const durLabel = durMin % 60 === 0 ? `${durMin / 60} hour${durMin / 60 > 1 ? "s" : ""}` : `${durMin} min`; + const when = ev.all_day ? "All day" : `${hhmm(ev._s)} – ${hhmm(ev._e)} ${tzAbbr(ev.tz)} (${durLabel})`; + const left = Math.min(rect.right + 8, window.innerWidth - 302); + const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230); + return `
+
+
+
${esc(cal ? cal.name : "")}
+
${esc(ev.summary || "(no title)")}
+ ${ev.location ? `
${esc(ev.location)}
` : ""} +
+
${esc(dateStr)}
${esc(when)}
+ ${ev.conference_url ? `` : ""} + ${guestListHtml(ev)} +
+
+ + +
+
`; + } + + function confirmHtml() { + const ev = state.confirm.ev; + const title = esc(ev.summary || "(no title)"); + if (ev.recurring) { + return `
+
+

Delete recurring event

+

“${title}” is a repeating event. What would you like to delete?

+
+ + +
+
+
+
`; + } + return `
+

Delete event

Delete “${title}”? This can’t be undone.

+
+ + +
+
`; + } + + function whoExpandedHtml() { + return ` +
+
${esc(state.me || "You")}
${esc(state.me || "")}
+
Organizer
+
+
+
+ + +
+ +
+
`; + } + + function reminderRow() { + return `
+ + +
`; + } + + function renderTzList(field, filter) { + const cur = field.getAttribute("data-tz"); + const list = field.querySelector(".cal-tzlist"); + const entries = tzEntries(cur).filter( + (e) => !filter || e.city.toLowerCase().includes(filter) || (e.abbr && e.abbr.toLowerCase().includes(filter)), + ); + list.innerHTML = entries.slice(0, 300).map( + (e) => ``, + ).join(""); + list.querySelectorAll("[data-tzpick]").forEach((b) => b.addEventListener("click", () => { + const tz = b.getAttribute("data-tzpick"); + field.setAttribute("data-tz", tz); + field.querySelector("[data-tz-label]").textContent = tzLongLabel(tz); + field.querySelector(".cal-tzpop").style.display = "none"; + })); + } + + function monthView() { + const gs = startOfWeek(startOfMonth(state.anchor)); + const evs = visibleEvents(); + let html = DAYNAMES.map((d) => `
${d}
`).join(""); + for (let i = 0; i < 42; i++) { + 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(); + html += `
+
${label}
+ ${dayEvs.slice(0, 4).map((ev) => `
${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}
`).join("")} + ${dayEvs.length > 4 ? `
+${dayEvs.length - 4} more
` : ""} +
`; + } + return `
${html}
`; + } + + function timegridView() { + const span = state.view === "day" ? 1 : state.view === "3days" ? 3 : 7; + const s0 = state.view === "week" ? startOfWeek(state.anchor) : startOfDay(state.anchor); + const days = Array.from({ length: span }, (_, i) => addDays(s0, i)); + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "Local"; + const evs = visibleEvents(); + const hours = Array.from({ length: 24 }, (_, h) => `
${h === 0 ? "" : HOUR12 ? (h % 12 || 12) + (h < 12 ? " AM" : " PM") : pad(h) + ":00"}
`).join(""); + + const headDays = days.map((d) => { + const today = sameDay(d, new Date()); + const num = `${d.getDate()}${d.getDate() === 1 ? " " + MONTHS[d.getMonth()].slice(0, 3) : ""}`; + return `
${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}${today ? `${d.getDate()}` : num}
`; + }).join(""); + const cols = days.map((d) => { + const today = sameDay(d, new Date()); + const lines = Array.from({ length: 24 }, () => `
`).join(""); + const dayEvs = evs.filter((ev) => !ev.all_day && sameDay(ev._s, d)); + const lay = layoutDay(dayEvs); + const blocks = dayEvs.map((ev) => { + const top = (ev._s.getHours() + ev._s.getMinutes() / 60) * HOUR_PX; + const h = Math.max(18, ((ev._e - ev._s) / 3600000) * HOUR_PX); + const pos = lay.get(ev) || { col: 0, total: 1 }; + const w = 100 / pos.total; + return `
${esc(ev.summary || "(no title)")}
${hhmm(ev._s)}
`; + }).join(""); + const now = today ? `
` : ""; + return `
${lines}${blocks}${now}
`; + }).join(""); + + return `
${esc(tzAbbr(tz))}
${headDays}
+
${hours}
${cols}
`; + } + + function agenda() { + const start = startOfMonth(state.anchor); + const evs = visibleEvents(); + let html = `
${MONTHS[start.getMonth()]} ${start.getFullYear()}
`; + for (let i = 0; i < 30; i++) { + const d = addDays(start, i); + if (d.getMonth() !== start.getMonth()) break; + const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s); + html += `
${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}${d.getDate()}
${ + dayEvs.length ? dayEvs.map((ev) => `
${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}
`).join("") : `No events` + }
`; + } + return html + "
"; + } + + // ---- events ---------------------------------------------------------------- + function wire() { + const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn)); + const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn); + + act("menu", () => { state.menuOpen = !state.menuOpen; render(); }); + 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("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(); }); + act("prev", () => go(-1)); + act("next", () => go(1)); + 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(); }); + // --- 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("theme", () => { + const dark = document.documentElement.classList.toggle("dark"); + try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ } + render(); + }); + const rz = el.querySelector("[data-resizer]"); + if (rz) rz.addEventListener("mousedown", (e) => { + e.preventDefault(); + const left = el.querySelector(".cal-left"); + const startX = e.clientX; + const startW = left.offsetWidth; + const onMove = (ev) => { + const w = Math.max(190, Math.min(440, startW + ev.clientX - startX)); + left.style.width = `${w}px`; + state.leftW = w; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + try { localStorage.setItem("cal.leftW", String(state.leftW)); } catch { /* ignore */ } + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }); + + on("[data-view]", "click", (e) => { state.view = e.currentTarget.getAttribute("data-view"); reload(); }); + on("[data-mini]", "click", (e) => { state.anchor = startOfDay(new Date(e.currentTarget.getAttribute("data-mini"))); if (state.view === "month") state.view = "day"; reload(); }); + on("[data-cal]", "change", (e) => { + const id = e.currentTarget.getAttribute("data-cal"); + if (e.currentTarget.checked) state.hidden.delete(id); else state.hidden.add(id); + render(); + }); + on("[data-ev]", "click", (e) => { + e.stopPropagation(); + const id = e.currentTarget.getAttribute("data-ev"); + const start = e.currentTarget.getAttribute("data-start"); + // Recurring events share one base id across occurrences — match the clicked + // occurrence by its start too, so the popover shows that instance's date. + const ev = state.events.find((x) => x.id === id && (!start || x.start === start)) + || state.events.find((x) => x.id === id); + if (ev) openEvent(ev, e.currentTarget); + }); + on("[data-day]", "click", (e) => { + const d = new Date(e.currentTarget.getAttribute("data-day")); + const now = new Date(); + d.setHours(now.getHours(), now.getMinutes(), 0, 0); // default to the current time + openForm(d); + }); + // --- event popover --- + act("pop-close", () => { state.eventPopover = null; render(); }); + act("ev-delete", () => { + const ev = state.eventPopover.ev; + state.eventPopover = null; + state.confirm = { ev }; + render(); + }); + act("ev-edit", () => { const ev = state.eventPopover.ev; openForm(ev._s, ev); }); + // --- delete confirmation modal --- + on("[data-stop]", "click", (e) => e.stopPropagation()); + act("confirm-cancel", () => { state.confirm = null; render(); }); + act("del-all", () => doDelete("all")); + act("del-this", () => doDelete("this")); + act("del-following", () => doDelete("following")); + on("[data-col]", "click", (e) => { + const rect = e.currentTarget.getBoundingClientRect(); + const hour = Math.max(0, Math.min(23, Math.floor((e.clientY - rect.top) / HOUR_PX))); + const d = new Date(e.currentTarget.getAttribute("data-col")); d.setHours(hour, 0, 0, 0); + openForm(d); + }); + + // --- new-event form --- + act("repeat-toggle", (e) => { + e.currentTarget.classList.toggle("on"); + const box = el.querySelector("[data-repeat]"); + if (box) box.style.display = e.currentTarget.classList.contains("on") ? "" : "none"; + }); + on("[data-f=endmode]", "change", (e) => { + const v = e.currentTarget.value; + el.querySelector("[data-f=count]").style.display = v === "count" ? "" : "none"; + el.querySelector("[data-f=until]").style.display = v === "until" ? "" : "none"; + }); + on("[data-f=allday]", "change", (e) => { + const off = e.currentTarget.checked; + el.querySelector("[data-f=time]").disabled = off; + el.querySelector("[data-f=duration]").disabled = off; + }); + // color dropdown + act("color-open", (e) => { + const pop = e.currentTarget.nextElementSibling; + pop.style.display = pop.style.display === "none" ? "" : "none"; + }); + on(".cal-ddpop [data-color]", "click", (e) => { + const color = e.currentTarget.getAttribute("data-color"); + const dd = e.currentTarget.closest(".cal-dd"); + dd.setAttribute("data-color", color); + dd.querySelector(".cal-dot").style.background = color; + dd.querySelector(".cal-ddpop").style.display = "none"; + }); + // timezone combobox + act("tz-open", (e) => { + const field = e.currentTarget.closest(".cal-tzfield"); + const pop = field.querySelector(".cal-tzpop"); + const open = pop.style.display === "none"; + pop.style.display = open ? "" : "none"; + if (open) { + renderTzList(field, ""); + field.querySelector("[data-tz-search]").focus(); + } + }); + on("[data-tz-search]", "input", (e) => { + renderTzList(e.currentTarget.closest(".cal-tzfield"), e.currentTarget.value.toLowerCase()); + }); + act("add-guest", () => { + expandGuests(); + const inp = el.querySelector("[data-f=guest]"); + if (inp) inp.focus(); + }); + act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi))); + // Copy handler for a link rendered on initial form paint (edit mode). + act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget)); + act("add-reminder", () => { + const box = el.querySelector("[data-reminders]"); + const wrap = document.createElement("div"); + wrap.innerHTML = reminderRow(); + const row = wrap.firstElementChild; + row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove()); + box.appendChild(row); + }); + act("save", () => saveForm()); + act("cancel", () => { state.composing = null; render(); }); + } + + function scrollTimegrid() { + const view = el.querySelector(".cal-view"); + if (view) view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups + } + + async function loadChrome() { + try { + // Product switcher apps: Mail + every plugin page from the manifest. + const manifest = await api.fetch("/plugins/ui-manifest"); + const pages = (manifest.plugins || []).flatMap((p) => p.pages.map((pg) => ({ label: pg.nav.label, to: pg.route === "/calendar" ? null : "/p" + pg.route }))); + state.apps = [{ label: "Mail", to: "/mail" }, ...pages]; + } catch { /* keep defaults */ } + try { + const accounts = await api.fetch("/accounts"); + if (accounts && accounts[0]) state.me = accounts[0].email; + } catch { /* ignore */ } + try { + const s = await api.fetch("/settings"); + HOUR12 = s && s.time_format === "12"; // 24h is the default + state.jitsi = (s && s.jitsi_server) || ""; + } catch { /* keep defaults (24h, public Jitsi) */ } + try { + const q = await api.fetch("/settings/quota"); + if (q && q.quota_bytes) { + const pct = (q.used_bytes / q.quota_bytes) * 100; + const gb = q.quota_bytes / 1024 ** 3; + state.quota = `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}% of ${Number.isInteger(gb) ? gb : gb.toFixed(1)} GB`; + } + } catch { /* ignore */ } + } + + (async () => { + await loadChrome(); + await loadCalendars(); + await loadEvents(); + render(); + })(); + + return () => { + el.innerHTML = ""; + }; +} diff --git a/screenshots/calendar_1.png b/screenshots/calendar_1.png new file mode 100644 index 0000000..8f9c3d8 Binary files /dev/null and b/screenshots/calendar_1.png differ diff --git a/screenshots/calendar_10.png b/screenshots/calendar_10.png new file mode 100644 index 0000000..7f5a0c8 Binary files /dev/null and b/screenshots/calendar_10.png differ diff --git a/screenshots/calendar_11.png b/screenshots/calendar_11.png new file mode 100644 index 0000000..b1974c8 Binary files /dev/null and b/screenshots/calendar_11.png differ diff --git a/screenshots/calendar_2.png b/screenshots/calendar_2.png new file mode 100644 index 0000000..eef2343 Binary files /dev/null and b/screenshots/calendar_2.png differ diff --git a/screenshots/calendar_3.png b/screenshots/calendar_3.png new file mode 100644 index 0000000..ec2487d Binary files /dev/null and b/screenshots/calendar_3.png differ diff --git a/screenshots/calendar_4.png b/screenshots/calendar_4.png new file mode 100644 index 0000000..7dc97d3 Binary files /dev/null and b/screenshots/calendar_4.png differ diff --git a/screenshots/calendar_5.png b/screenshots/calendar_5.png new file mode 100644 index 0000000..f5b4654 Binary files /dev/null and b/screenshots/calendar_5.png differ diff --git a/screenshots/calendar_6.png b/screenshots/calendar_6.png new file mode 100644 index 0000000..2696ab4 Binary files /dev/null and b/screenshots/calendar_6.png differ diff --git a/screenshots/calendar_7.png b/screenshots/calendar_7.png new file mode 100644 index 0000000..85c536c Binary files /dev/null and b/screenshots/calendar_7.png differ diff --git a/screenshots/calendar_8.png b/screenshots/calendar_8.png new file mode 100644 index 0000000..bf4af61 Binary files /dev/null and b/screenshots/calendar_8.png differ diff --git a/screenshots/calendar_9.png b/screenshots/calendar_9.png new file mode 100644 index 0000000..fb03154 Binary files /dev/null and b/screenshots/calendar_9.png differ diff --git a/screenshots/email_gmail.png b/screenshots/email_gmail.png new file mode 100644 index 0000000..57d6525 Binary files /dev/null and b/screenshots/email_gmail.png differ diff --git a/screenshots/email_ours.png b/screenshots/email_ours.png new file mode 100644 index 0000000..3cc38d0 Binary files /dev/null and b/screenshots/email_ours.png differ diff --git a/screenshots/event-cell.png b/screenshots/event-cell.png new file mode 100644 index 0000000..a234b56 Binary files /dev/null and b/screenshots/event-cell.png differ diff --git a/screenshots/events.png b/screenshots/events.png new file mode 100644 index 0000000..1eaa9ab Binary files /dev/null and b/screenshots/events.png differ diff --git a/screenshots/invitation.png b/screenshots/invitation.png new file mode 100644 index 0000000..4a29294 Binary files /dev/null and b/screenshots/invitation.png differ