commit 5f385667576e2a20e5a44c1d3d6bad996debaa8f
parent 3486cdba7dc0551637bf5313722b1e4fc3d2ec1e
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date: Fri, 1 May 2026 17:19:54 +0000
Add full calendar app: FastAPI backend, Nord-themed frontend
- FastAPI backend with SQLite via SQLAlchemy
- Personal calendar auto-created on first login
- Shared calendars with email-based invitation (pending invite system)
- Monthly grid view with Mon-first weekdays and today highlight
- Animated daily view (6 AM-10 PM) that slides in on day click
- Sidebar calendar list with per-calendar visibility toggle
- Right-click context menu: edit, invite, delete, leave
- Create/edit calendar modal with Nord color swatches
- Authentik forward-auth via nginx (username + email headers)
- Nginx config and systemd unit included
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
13 files changed, 1601 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
@@ -0,0 +1 @@
+DATABASE_URL=sqlite:////var/lib/calendar/calendar.db
diff --git a/README.md b/README.md
@@ -1 +1,33 @@
-# Family calendar
+# Calendar
+
+Self-hosted personal and shared calendar for Authentik users.
+
+## Stack
+
+- Backend: FastAPI (Python) + SQLite via SQLAlchemy
+- Frontend: Vanilla JS/HTML/CSS (Nord theme)
+- Auth: Authentik forward-auth via nginx
+
+## Setup
+
+```sh
+pip install -r requirements.txt
+
+sudo mkdir -p /var/lib/calendar
+sudo chown julian:julian /var/lib/calendar
+
+cp .env.example .env
+
+sudo cp calendar.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now calendar
+
+sudo cp nginx/calendar /etc/nginx/sites-available/calendar
+sudo ln -s /etc/nginx/sites-available/calendar /etc/nginx/sites-enabled/calendar
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+## Invitation flow
+
+Users are invited by email. When they first log in via Authentik, pending invitations
+matching their `X-authentik-email` header are automatically accepted.
diff --git a/app/__init__.py b/app/__init__.py
diff --git a/app/database.py b/app/database.py
@@ -0,0 +1,19 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+import os
+
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////var/lib/calendar/calendar.db")
+engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
+SessionLocal = sessionmaker(bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/app/main.py b/app/main.py
@@ -0,0 +1,285 @@
+from fastapi import FastAPI, Request, Depends, HTTPException
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import IntegrityError
+import os
+
+from .database import get_db, engine
+from .models import Base, Calendar, CalendarMembership, PendingInvitation
+from .schemas import CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest
+
+Base.metadata.create_all(bind=engine)
+
+app = FastAPI(title="Calendar")
+
+STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
+app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+
+
+# ── Auth helpers ──────────────────────────────────────────────────────────────
+
+def _get_identity(request: Request) -> tuple[str, str]:
+ username = request.headers.get("x-authentik-username", "")
+ email = request.headers.get("x-authentik-email", "")
+ if not username:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return username, email
+
+
+def _ensure_personal_calendar(db: Session, username: str) -> None:
+ exists = (
+ db.query(Calendar)
+ .filter(Calendar.owner_username == username, Calendar.is_personal == True)
+ .first()
+ )
+ if exists:
+ return
+ cal = Calendar(name="Personal", color="#88C0D0", owner_username=username, is_personal=True)
+ db.add(cal)
+ db.flush()
+ db.add(CalendarMembership(calendar_id=cal.id, username=username, is_visible=True))
+ db.commit()
+
+
+def _accept_pending_invitations(db: Session, username: str, email: str) -> None:
+ if not email:
+ return
+ pending = (
+ db.query(PendingInvitation).filter(PendingInvitation.invitee_email == email).all()
+ )
+ for inv in pending:
+ try:
+ db.add(CalendarMembership(calendar_id=inv.calendar_id, username=username))
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ db.delete(inv)
+ db.commit()
+
+
+def _require_member(db: Session, calendar_id: int, username: str) -> Calendar:
+ cal = db.query(Calendar).filter(Calendar.id == calendar_id).first()
+ if not cal:
+ raise HTTPException(status_code=404, detail="Calendar not found")
+ is_member = (
+ db.query(CalendarMembership)
+ .filter(
+ CalendarMembership.calendar_id == calendar_id,
+ CalendarMembership.username == username,
+ )
+ .first()
+ )
+ if not is_member:
+ raise HTTPException(status_code=403, detail="Access denied")
+ return cal
+
+
+def _require_owner(db: Session, calendar_id: int, username: str) -> Calendar:
+ cal = _require_member(db, calendar_id, username)
+ if cal.owner_username != username:
+ raise HTTPException(status_code=403, detail="Only the owner can do this")
+ return cal
+
+
+# ── Routes ────────────────────────────────────────────────────────────────────
+
+@app.get("/", include_in_schema=False)
+async def root():
+ return FileResponse(os.path.join(STATIC_DIR, "index.html"))
+
+
+@app.get("/api/me")
+def get_me(request: Request, db: Session = Depends(get_db)):
+ username, email = _get_identity(request)
+ _accept_pending_invitations(db, username, email)
+ _ensure_personal_calendar(db, username)
+ return {"username": username, "email": email}
+
+
+@app.get("/api/calendars")
+def list_calendars(request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ memberships = (
+ db.query(CalendarMembership)
+ .filter(CalendarMembership.username == username)
+ .all()
+ )
+ result = []
+ for m in memberships:
+ cal = m.calendar
+ member_usernames = [x.username for x in cal.memberships]
+ pending_emails = [p.invitee_email for p in cal.pending_invitations]
+ result.append(
+ {
+ "id": cal.id,
+ "name": cal.name,
+ "color": cal.color,
+ "is_personal": cal.is_personal,
+ "is_owner": cal.owner_username == username,
+ "is_visible": m.is_visible,
+ "members": member_usernames,
+ "pending_invitations": pending_emails if cal.owner_username == username else [],
+ }
+ )
+ return result
+
+
+@app.post("/api/calendars", status_code=201)
+def create_calendar(body: CalendarCreate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ cal = Calendar(name=body.name, color=body.color, owner_username=username, is_personal=False)
+ db.add(cal)
+ db.flush()
+ db.add(CalendarMembership(calendar_id=cal.id, username=username, is_visible=True))
+ for email in body.invite_emails:
+ email = email.strip().lower()
+ if email:
+ existing_member = (
+ db.query(CalendarMembership)
+ .join(Calendar, CalendarMembership.calendar_id == Calendar.id)
+ .filter(Calendar.id == cal.id)
+ .first()
+ )
+ try:
+ db.add(PendingInvitation(calendar_id=cal.id, invitee_email=email))
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ db.commit()
+ db.refresh(cal)
+ return {"id": cal.id, "name": cal.name, "color": cal.color}
+
+
+@app.patch("/api/calendars/{calendar_id}")
+def update_calendar(
+ calendar_id: int,
+ body: CalendarUpdate,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ username, _ = _get_identity(request)
+ cal = _require_owner(db, calendar_id, username)
+ if body.name is not None:
+ cal.name = body.name
+ if body.color is not None:
+ cal.color = body.color
+ db.commit()
+ return {"id": cal.id, "name": cal.name, "color": cal.color}
+
+
+@app.delete("/api/calendars/{calendar_id}", status_code=204)
+def delete_calendar(calendar_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ cal = _require_owner(db, calendar_id, username)
+ if cal.is_personal:
+ raise HTTPException(status_code=400, detail="Cannot delete personal calendar")
+ db.delete(cal)
+ db.commit()
+
+
+@app.patch("/api/calendars/{calendar_id}/visibility")
+def set_visibility(
+ calendar_id: int,
+ body: VisibilityUpdate,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ username, _ = _get_identity(request)
+ m = (
+ db.query(CalendarMembership)
+ .filter(
+ CalendarMembership.calendar_id == calendar_id,
+ CalendarMembership.username == username,
+ )
+ .first()
+ )
+ if not m:
+ raise HTTPException(status_code=404)
+ m.is_visible = body.is_visible
+ db.commit()
+ return {"is_visible": m.is_visible}
+
+
+@app.post("/api/calendars/{calendar_id}/invite", status_code=201)
+def invite_member(
+ calendar_id: int,
+ body: InviteRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ username, _ = _get_identity(request)
+ _require_owner(db, calendar_id, username)
+ email = body.email.strip().lower()
+ try:
+ db.add(PendingInvitation(calendar_id=calendar_id, invitee_email=email))
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="Already invited")
+ return {"invited": email}
+
+
+@app.delete("/api/calendars/{calendar_id}/members/{member_username}", status_code=204)
+def remove_member(
+ calendar_id: int,
+ member_username: str,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ username, _ = _get_identity(request)
+ cal = _require_owner(db, calendar_id, username)
+ if member_username == username:
+ raise HTTPException(status_code=400, detail="Cannot remove yourself as owner")
+ m = (
+ db.query(CalendarMembership)
+ .filter(
+ CalendarMembership.calendar_id == calendar_id,
+ CalendarMembership.username == member_username,
+ )
+ .first()
+ )
+ if m:
+ db.delete(m)
+ db.commit()
+
+
+@app.delete("/api/calendars/{calendar_id}/invitations/{email}", status_code=204)
+def revoke_invitation(
+ calendar_id: int,
+ email: str,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ username, _ = _get_identity(request)
+ _require_owner(db, calendar_id, username)
+ inv = (
+ db.query(PendingInvitation)
+ .filter(
+ PendingInvitation.calendar_id == calendar_id,
+ PendingInvitation.invitee_email == email,
+ )
+ .first()
+ )
+ if inv:
+ db.delete(inv)
+ db.commit()
+
+
+@app.delete("/api/calendars/{calendar_id}/leave", status_code=204)
+def leave_calendar(calendar_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ cal = _require_member(db, calendar_id, username)
+ if cal.owner_username == username:
+ raise HTTPException(status_code=400, detail="Owner cannot leave; delete the calendar instead")
+ m = (
+ db.query(CalendarMembership)
+ .filter(
+ CalendarMembership.calendar_id == calendar_id,
+ CalendarMembership.username == username,
+ )
+ .first()
+ )
+ if m:
+ db.delete(m)
+ db.commit()
diff --git a/app/models.py b/app/models.py
@@ -0,0 +1,45 @@
+from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import relationship
+from .database import Base
+
+
+class Calendar(Base):
+ __tablename__ = "calendars"
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String, nullable=False)
+ color = Column(String, nullable=False, default="#88C0D0")
+ owner_username = Column(String, nullable=False)
+ is_personal = Column(Boolean, default=False, nullable=False)
+
+ memberships = relationship(
+ "CalendarMembership", back_populates="calendar", cascade="all, delete-orphan"
+ )
+ pending_invitations = relationship(
+ "PendingInvitation", back_populates="calendar", cascade="all, delete-orphan"
+ )
+
+
+class CalendarMembership(Base):
+ __tablename__ = "calendar_memberships"
+
+ id = Column(Integer, primary_key=True)
+ calendar_id = Column(Integer, ForeignKey("calendars.id"), nullable=False)
+ username = Column(String, nullable=False)
+ is_visible = Column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (UniqueConstraint("calendar_id", "username"),)
+
+ calendar = relationship("Calendar", back_populates="memberships")
+
+
+class PendingInvitation(Base):
+ __tablename__ = "pending_invitations"
+
+ id = Column(Integer, primary_key=True)
+ calendar_id = Column(Integer, ForeignKey("calendars.id"), nullable=False)
+ invitee_email = Column(String, nullable=False)
+
+ __table_args__ = (UniqueConstraint("calendar_id", "invitee_email"),)
+
+ calendar = relationship("Calendar", back_populates="pending_invitations")
diff --git a/app/schemas.py b/app/schemas.py
@@ -0,0 +1,35 @@
+from pydantic import BaseModel, field_validator
+import re
+
+
+class CalendarCreate(BaseModel):
+ name: str
+ color: str = "#88C0D0"
+ invite_emails: list[str] = []
+
+ @field_validator("color")
+ @classmethod
+ def valid_color(cls, v: str) -> str:
+ if not re.match(r"^#[0-9a-fA-F]{6}$", v):
+ raise ValueError("color must be a hex color like #88C0D0")
+ return v
+
+
+class CalendarUpdate(BaseModel):
+ name: str | None = None
+ color: str | None = None
+
+ @field_validator("color")
+ @classmethod
+ def valid_color(cls, v: str | None) -> str | None:
+ if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v):
+ raise ValueError("color must be a hex color like #88C0D0")
+ return v
+
+
+class VisibilityUpdate(BaseModel):
+ is_visible: bool
+
+
+class InviteRequest(BaseModel):
+ email: str
diff --git a/calendar.service b/calendar.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Calendar web app
+After=network.target
+
+[Service]
+Type=simple
+User=julian
+WorkingDirectory=/home/julian/calendar
+EnvironmentFile=/home/julian/calendar/.env
+ExecStart=/usr/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8765 --no-access-log
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/nginx/calendar b/nginx/calendar
@@ -0,0 +1,57 @@
+server {
+ server_name calendar.piribauer.ch;
+
+ location /outpost.goauthentik.io {
+ proxy_pass http://127.0.0.1:9000/outpost.goauthentik.io;
+ proxy_set_header Host $host;
+ proxy_set_header X-Original-URL $scheme://$host$request_uri;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ add_header Set-Cookie $auth_cookie;
+ auth_request_set $auth_cookie $upstream_http_set_cookie;
+ }
+
+ location @goauthentik_proxy_signin {
+ internal;
+ add_header Set-Cookie $auth_cookie;
+ return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8765;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ auth_request /outpost.goauthentik.io/auth/nginx;
+ error_page 401 = @goauthentik_proxy_signin;
+
+ auth_request_set $auth_cookie $upstream_http_set_cookie;
+ auth_request_set $authentik_username $upstream_http_x_authentik_username;
+ auth_request_set $authentik_email $upstream_http_x_authentik_email;
+
+ add_header Set-Cookie $auth_cookie;
+
+ proxy_set_header X-Authentik-Username $authentik_username;
+ proxy_set_header X-Authentik-Email $authentik_email;
+ }
+
+ listen 443 ssl;
+ ssl_certificate /etc/letsencrypt/live/piribauer.ch/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/piribauer.ch/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+}
+
+server {
+ if ($host = calendar.piribauer.ch) {
+ return 301 https://$host$request_uri;
+ }
+ listen 80;
+ server_name calendar.piribauer.ch;
+ return 404;
+}
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1,4 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.29.0
+sqlalchemy>=2.0.0
+python-dotenv>=1.0.0
diff --git a/static/app.js b/static/app.js
@@ -0,0 +1,445 @@
+'use strict';
+
+// ── State ─────────────────────────────────────────────────────────────────────
+const state = {
+ username: '',
+ email: '',
+ calendars: [],
+ year: new Date().getFullYear(),
+ month: new Date().getMonth(), // 0-based
+ selectedDay: null,
+};
+
+const COLORS = [
+ '#88C0D0', '#8FBCBB', '#81A1C1', '#5E81AC',
+ '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A', '#B48EAD',
+];
+
+const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+const MONTHS = [
+ 'January','February','March','April','May','June',
+ 'July','August','September','October','November','December',
+];
+
+// ── API ───────────────────────────────────────────────────────────────────────
+async function api(method, path, body) {
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
+ if (body !== undefined) opts.body = JSON.stringify(body);
+ const res = await fetch(path, opts);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
+ throw new Error(err.detail || res.statusText);
+ }
+ if (res.status === 204) return null;
+ return res.json();
+}
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+async function init() {
+ const me = await api('GET', '/api/me');
+ state.username = me.username;
+ state.email = me.email;
+
+ document.getElementById('username-display').textContent = me.username;
+
+ await loadCalendars();
+ renderMonthly();
+}
+
+async function loadCalendars() {
+ state.calendars = await api('GET', '/api/calendars');
+ renderSidebar();
+}
+
+// ── Sidebar ───────────────────────────────────────────────────────────────────
+function renderSidebar() {
+ const list = document.getElementById('calendar-list');
+ list.innerHTML = '';
+
+ state.calendars.forEach(cal => {
+ const item = document.createElement('div');
+ item.className = 'calendar-item';
+ item.dataset.id = cal.id;
+
+ const dot = document.createElement('span');
+ dot.className = 'cal-dot';
+ dot.style.background = cal.color;
+
+ const name = document.createElement('span');
+ name.className = 'cal-name';
+ name.textContent = cal.name;
+
+ const toggle = document.createElement('button');
+ toggle.className = 'cal-toggle' + (cal.is_visible ? ' visible' : '');
+ toggle.title = cal.is_visible ? 'Hide' : 'Show';
+ toggle.addEventListener('click', e => {
+ e.stopPropagation();
+ toggleVisibility(cal);
+ });
+
+ item.append(dot, name, toggle);
+ item.addEventListener('contextmenu', e => {
+ e.preventDefault();
+ showContextMenu(e, cal);
+ });
+
+ list.appendChild(item);
+ });
+}
+
+async function toggleVisibility(cal) {
+ const newVal = !cal.is_visible;
+ await api('PATCH', `/api/calendars/${cal.id}/visibility`, { is_visible: newVal });
+ cal.is_visible = newVal;
+ renderSidebar();
+ renderMonthly();
+}
+
+// ── Context menu ──────────────────────────────────────────────────────────────
+const ctxMenu = document.getElementById('ctx-menu');
+let ctxCalendar = null;
+
+function showContextMenu(e, cal) {
+ ctxCalendar = cal;
+ ctxMenu.innerHTML = '';
+
+ if (cal.is_owner && !cal.is_personal) {
+ addCtxItem('Edit', () => openEditModal(cal));
+ addCtxItem('Invite', () => openInviteModal(cal));
+ addCtxItem('Delete', () => deleteCalendar(cal), true);
+ } else if (!cal.is_personal) {
+ addCtxItem('Leave calendar', () => leaveCalendar(cal), true);
+ }
+ if (cal.is_personal) {
+ addCtxItem('Edit name / color', () => openEditModal(cal));
+ }
+
+ const x = Math.min(e.clientX, window.innerWidth - 180);
+ const y = Math.min(e.clientY, window.innerHeight - ctxMenu.scrollHeight - 10);
+ ctxMenu.style.left = x + 'px';
+ ctxMenu.style.top = y + 'px';
+ ctxMenu.classList.add('open');
+}
+
+function addCtxItem(label, action, danger = false) {
+ const el = document.createElement('div');
+ el.className = 'ctx-item' + (danger ? ' danger' : '');
+ el.textContent = label;
+ el.addEventListener('click', () => { closeCtxMenu(); action(); });
+ ctxMenu.appendChild(el);
+}
+
+function closeCtxMenu() { ctxMenu.classList.remove('open'); }
+
+document.addEventListener('click', closeCtxMenu);
+document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeCtxMenu(); closeModal(); closeDailyView(); } });
+
+// ── Monthly calendar ──────────────────────────────────────────────────────────
+function renderMonthly() {
+ document.getElementById('month-title').textContent =
+ `${MONTHS[state.month]} ${state.year}`;
+
+ const grid = document.getElementById('day-grid');
+ grid.innerHTML = '';
+
+ const firstDay = new Date(state.year, state.month, 1);
+ // Weekday of first day (0=Sun..6=Sat → convert to Mon-first: 0=Mon..6=Sun)
+ let startDow = firstDay.getDay(); // 0=Sun
+ startDow = startDow === 0 ? 6 : startDow - 1;
+
+ const daysInMonth = new Date(state.year, state.month + 1, 0).getDate();
+ const daysInPrev = new Date(state.year, state.month, 0).getDate();
+
+ const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7;
+
+ const today = new Date();
+
+ for (let i = 0; i < totalCells; i++) {
+ let day, isCurrentMonth = true;
+ if (i < startDow) {
+ day = daysInPrev - startDow + 1 + i;
+ isCurrentMonth = false;
+ } else if (i >= startDow + daysInMonth) {
+ day = i - startDow - daysInMonth + 1;
+ isCurrentMonth = false;
+ } else {
+ day = i - startDow + 1;
+ }
+
+ const cell = document.createElement('div');
+ cell.className = 'day-cell';
+ if (!isCurrentMonth) cell.classList.add('other-month');
+
+ const isToday = isCurrentMonth &&
+ day === today.getDate() &&
+ state.month === today.getMonth() &&
+ state.year === today.getFullYear();
+ if (isToday) cell.classList.add('today');
+
+ if (isCurrentMonth && state.selectedDay === day) {
+ cell.classList.add('selected');
+ }
+
+ const num = document.createElement('div');
+ num.className = 'day-number';
+ num.textContent = day;
+ cell.appendChild(num);
+
+ if (isCurrentMonth) {
+ cell.addEventListener('click', () => selectDay(day));
+ }
+
+ grid.appendChild(cell);
+ }
+}
+
+function selectDay(day) {
+ state.selectedDay = day;
+ renderMonthly();
+ openDailyView(day);
+}
+
+function prevMonth() {
+ if (state.month === 0) { state.month = 11; state.year--; }
+ else state.month--;
+ state.selectedDay = null;
+ renderMonthly();
+ closeDailyView();
+}
+
+function nextMonth() {
+ if (state.month === 11) { state.month = 0; state.year++; }
+ else state.month++;
+ state.selectedDay = null;
+ renderMonthly();
+ closeDailyView();
+}
+
+// ── Daily view ────────────────────────────────────────────────────────────────
+function openDailyView(day) {
+ const date = new Date(state.year, state.month, day);
+ const label = date.toLocaleDateString('en-GB', {
+ weekday: 'long', day: 'numeric', month: 'long',
+ });
+ document.getElementById('daily-title').textContent = label;
+
+ renderTimeGrid();
+
+ document.querySelector('.calendar-main').classList.add('daily-open');
+}
+
+function closeDailyView() {
+ document.querySelector('.calendar-main').classList.remove('daily-open');
+ state.selectedDay = null;
+ renderMonthly();
+}
+
+function renderTimeGrid() {
+ const grid = document.getElementById('time-grid');
+ grid.innerHTML = '';
+ for (let h = 6; h <= 22; h++) {
+ const row = document.createElement('div');
+ row.className = 'time-row';
+
+ const label = document.createElement('div');
+ label.className = 'time-label';
+ label.textContent = h.toString().padStart(2, '0') + ':00';
+
+ const slot = document.createElement('div');
+ slot.className = 'time-slot';
+
+ row.append(label, slot);
+ grid.appendChild(row);
+ }
+}
+
+// ── Create calendar modal ─────────────────────────────────────────────────────
+const modal = document.getElementById('modal');
+const modalOverlay = document.getElementById('modal-overlay');
+let modalMode = 'create'; // 'create' | 'edit' | 'invite'
+let modalCalendar = null;
+let selectedColor = COLORS[0];
+let inviteEmails = [''];
+
+function openCreateModal() {
+ modalMode = 'create';
+ modalCalendar = null;
+ selectedColor = COLORS[0];
+ inviteEmails = [''];
+ renderModal();
+ modalOverlay.classList.add('open');
+}
+
+function openEditModal(cal) {
+ modalMode = 'edit';
+ modalCalendar = cal;
+ selectedColor = cal.color;
+ renderModal();
+ modalOverlay.classList.add('open');
+}
+
+function openInviteModal(cal) {
+ modalMode = 'invite';
+ modalCalendar = cal;
+ inviteEmails = [''];
+ renderModal();
+ modalOverlay.classList.add('open');
+}
+
+function closeModal() { modalOverlay.classList.remove('open'); }
+
+modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeModal(); });
+
+function renderModal() {
+ const title = { create: 'New calendar', edit: 'Edit calendar', invite: 'Invite members' }[modalMode];
+ modal.querySelector('h2').textContent = title;
+
+ const body = modal.querySelector('.modal-body');
+ body.innerHTML = '';
+
+ if (modalMode === 'create' || modalMode === 'edit') {
+ // Name
+ const nameGroup = makeFormGroup('Name',
+ `<input class="form-input" id="cal-name-input" type="text" placeholder="Calendar name"
+ value="${modalMode === 'edit' ? escHtml(modalCalendar.name) : ''}">`
+ );
+ body.appendChild(nameGroup);
+
+ // Color
+ const colorGroup = makeFormGroup('Color', '');
+ const swatches = document.createElement('div');
+ swatches.className = 'color-swatches';
+ COLORS.forEach(c => {
+ const sw = document.createElement('div');
+ sw.className = 'color-swatch' + (c === selectedColor ? ' selected' : '');
+ sw.style.background = c;
+ sw.title = c;
+ sw.addEventListener('click', () => {
+ selectedColor = c;
+ swatches.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
+ sw.classList.add('selected');
+ });
+ swatches.appendChild(sw);
+ });
+ colorGroup.appendChild(swatches);
+ body.appendChild(colorGroup);
+ }
+
+ if (modalMode === 'create') {
+ body.appendChild(makeInviteSection());
+ }
+
+ if (modalMode === 'invite') {
+ body.appendChild(makeInviteSection());
+ }
+}
+
+function makeFormGroup(label, innerHtml) {
+ const g = document.createElement('div');
+ g.className = 'form-group';
+ g.innerHTML = `<label class="form-label">${label}</label>${innerHtml}`;
+ return g;
+}
+
+function makeInviteSection() {
+ const g = document.createElement('div');
+ g.className = 'form-group';
+ g.innerHTML = '<label class="form-label">Invite by email</label>';
+ const list = document.createElement('div');
+ list.className = 'invite-list';
+ list.id = 'invite-list';
+ g.appendChild(list);
+ renderInviteRows(list);
+ return g;
+}
+
+function renderInviteRows(list) {
+ list.innerHTML = '';
+ inviteEmails.forEach((email, i) => {
+ const row = document.createElement('div');
+ row.className = 'invite-row';
+ const inp = document.createElement('input');
+ inp.type = 'email';
+ inp.className = 'form-input';
+ inp.placeholder = 'user@example.com';
+ inp.value = email;
+ inp.addEventListener('input', () => { inviteEmails[i] = inp.value; });
+
+ const addBtn = document.createElement('button');
+ addBtn.className = 'btn-icon';
+ addBtn.title = 'Add another';
+ addBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>';
+ addBtn.addEventListener('click', () => {
+ inviteEmails.push('');
+ renderInviteRows(list);
+ });
+
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'btn-icon danger';
+ removeBtn.title = 'Remove';
+ removeBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M5 12h14" stroke-linecap="round"/></svg>';
+ removeBtn.addEventListener('click', () => {
+ if (inviteEmails.length > 1) {
+ inviteEmails.splice(i, 1);
+ renderInviteRows(list);
+ }
+ });
+
+ row.append(inp, addBtn, removeBtn);
+ list.appendChild(row);
+ });
+}
+
+async function submitModal() {
+ try {
+ if (modalMode === 'create') {
+ const name = document.getElementById('cal-name-input')?.value?.trim();
+ if (!name) return alert('Please enter a name.');
+ const emails = inviteEmails.map(e => e.trim()).filter(Boolean);
+ await api('POST', '/api/calendars', { name, color: selectedColor, invite_emails: emails });
+ } else if (modalMode === 'edit') {
+ const name = document.getElementById('cal-name-input')?.value?.trim();
+ await api('PATCH', `/api/calendars/${modalCalendar.id}`, {
+ name: name || undefined,
+ color: selectedColor,
+ });
+ } else if (modalMode === 'invite') {
+ const emails = inviteEmails.map(e => e.trim()).filter(Boolean);
+ for (const email of emails) {
+ await api('POST', `/api/calendars/${modalCalendar.id}/invite`, { email }).catch(() => {});
+ }
+ }
+ closeModal();
+ await loadCalendars();
+ } catch (err) {
+ alert(err.message);
+ }
+}
+
+async function deleteCalendar(cal) {
+ if (!confirm(`Delete "${cal.name}"? This cannot be undone.`)) return;
+ await api('DELETE', `/api/calendars/${cal.id}`);
+ await loadCalendars();
+}
+
+async function leaveCalendar(cal) {
+ if (!confirm(`Leave "${cal.name}"?`)) return;
+ await api('DELETE', `/api/calendars/${cal.id}/leave`);
+ await loadCalendars();
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+function escHtml(str) {
+ return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+}
+
+// ── Wire up global buttons ────────────────────────────────────────────────────
+document.getElementById('prev-month').addEventListener('click', prevMonth);
+document.getElementById('next-month').addEventListener('click', nextMonth);
+document.getElementById('btn-new-cal').addEventListener('click', openCreateModal);
+document.getElementById('modal-cancel').addEventListener('click', closeModal);
+document.getElementById('modal-submit').addEventListener('click', submitModal);
+document.getElementById('btn-close-daily').addEventListener('click', closeDailyView);
+
+// ── Start ─────────────────────────────────────────────────────────────────────
+init().catch(err => console.error('Init failed:', err));
diff --git a/static/index.html b/static/index.html
@@ -0,0 +1,115 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="/static/style.css">
+ <title>Calendar — Piribauer</title>
+</head>
+<body>
+
+ <nav class="topbar">
+ <div class="topbar-inner">
+ <div class="topbar-left">
+ <a href="/" class="topbar-name">Calendar</a>
+ </div>
+ <div class="topbar-right">
+ <span class="nav-link" id="username-display"></span>
+ <a href="https://auth.piribauer.ch/if/user/" class="nav-link" target="_blank" rel="noopener">Account</a>
+ <a href="/outpost.goauthentik.io/sign_out" class="nav-link signout">Sign out</a>
+ <a href="https://hub.piribauer.ch" class="topbar-home" aria-label="Hub">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
+ </svg>
+ </a>
+ </div>
+ </div>
+ </nav>
+
+ <div class="app-body">
+
+ <!-- Sidebar -->
+ <aside class="sidebar">
+ <div class="sidebar-header">Calendars</div>
+ <div class="calendar-list" id="calendar-list"></div>
+ <div class="sidebar-actions">
+ <button class="btn-new-cal" id="btn-new-cal">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M12 5v14M5 12h14" stroke-linecap="round"/>
+ </svg>
+ New calendar
+ </button>
+ </div>
+ </aside>
+
+ <!-- Calendar main -->
+ <div class="calendar-main">
+
+ <!-- Monthly view -->
+ <section class="monthly-view">
+ <div class="monthly-header">
+ <h2 class="monthly-title" id="month-title"></h2>
+ <div class="nav-btns">
+ <button class="nav-btn" id="prev-month" title="Previous month">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ </button>
+ <button class="nav-btn" id="next-month" title="Next month">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M9 5l7 7-7 7" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ <div class="weekday-row">
+ <div class="weekday-label">Mon</div>
+ <div class="weekday-label">Tue</div>
+ <div class="weekday-label">Wed</div>
+ <div class="weekday-label">Thu</div>
+ <div class="weekday-label">Fri</div>
+ <div class="weekday-label">Sat</div>
+ <div class="weekday-label">Sun</div>
+ </div>
+
+ <div class="day-grid" id="day-grid"></div>
+ </section>
+
+ <!-- Daily view -->
+ <section class="daily-view" id="daily-view">
+ <div class="daily-header">
+ <h3 class="daily-title" id="daily-title"></h3>
+ <button class="btn-close" id="btn-close-daily" title="Close">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/>
+ </svg>
+ </button>
+ </div>
+ <div class="time-grid" id="time-grid"></div>
+ </section>
+
+ </div>
+ </div>
+
+ <!-- Context menu -->
+ <div class="ctx-menu" id="ctx-menu"></div>
+
+ <!-- Modal -->
+ <div class="modal-overlay" id="modal-overlay">
+ <div class="modal" id="modal">
+ <h2></h2>
+ <div class="modal-body"></div>
+ <div class="modal-actions">
+ <button class="btn" id="modal-cancel">Cancel</button>
+ <button class="btn btn-primary" id="modal-submit">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <script src="/static/app.js"></script>
+</body>
+</html>
diff --git a/static/style.css b/static/style.css
@@ -0,0 +1,547 @@
+:root {
+ --polar1: #2E3440;
+ --polar2: #3B4252;
+ --polar3: #434C5E;
+ --polar4: #4C566A;
+ --snow1: #D8DEE9;
+ --snow2: #E5E9F0;
+ --snow3: #ECEFF4;
+ --turquoise: #8FBCBB;
+ --lightblue: #88C0D0;
+ --midblue: #81A1C1;
+ --darkblue: #5E81AC;
+ --red: #BF616A;
+ --orange: #D08770;
+ --yellow: #EBCB8B;
+ --green: #A3BE8C;
+ --violet: #B48EAD;
+}
+
+*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { height: 100%; overflow: hidden; }
+
+body {
+ background: var(--polar1);
+ font-family: 'Inter', system-ui, sans-serif;
+ color: var(--snow1);
+ font-size: 1rem;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Topbar ── */
+.topbar {
+ flex-shrink: 0;
+ background: var(--polar2);
+ border-bottom: 1px solid var(--polar3);
+ z-index: 100;
+}
+.topbar-inner {
+ height: 56px;
+ padding: 0 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.topbar-name {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: var(--snow3);
+ text-decoration: none;
+ letter-spacing: 0.02em;
+}
+.topbar-name:hover { color: var(--lightblue); }
+.topbar-right { display: flex; align-items: center; gap: 1.25rem; }
+.nav-link {
+ color: var(--snow1);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 400;
+ letter-spacing: 0.03em;
+ transition: color 0.15s;
+}
+.nav-link:hover { color: var(--lightblue); }
+.nav-link.signout:hover { color: var(--red); }
+.topbar-home {
+ display: flex;
+ align-items: center;
+ color: var(--snow1);
+ opacity: 0.45;
+ transition: opacity 0.15s, color 0.15s;
+}
+.topbar-home:hover { opacity: 1; color: var(--lightblue); }
+.topbar-home svg { width: 1rem; height: 1rem; }
+
+/* ── App body ── */
+.app-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Sidebar ── */
+.sidebar {
+ width: 220px;
+ flex-shrink: 0;
+ background: var(--polar2);
+ border-right: 1px solid var(--polar3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.sidebar-header {
+ padding: 1.25rem 1rem 0.75rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.45;
+}
+.calendar-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 0.5rem;
+}
+.calendar-item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.45rem 0.5rem;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s;
+ user-select: none;
+}
+.calendar-item:hover { background: var(--polar3); }
+.calendar-item.selected { background: rgba(136,192,208,0.08); }
+
+.cal-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.cal-name {
+ flex: 1;
+ font-size: 0.875rem;
+ color: var(--snow2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.cal-toggle {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ border-radius: 3px;
+ border: 1.5px solid var(--polar4);
+ background: transparent;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s, border-color 0.15s;
+}
+.cal-toggle.visible {
+ background: var(--lightblue);
+ border-color: var(--lightblue);
+}
+.cal-toggle.visible::after {
+ content: '';
+ width: 9px;
+ height: 5px;
+ border-left: 2px solid var(--polar1);
+ border-bottom: 2px solid var(--polar1);
+ transform: rotate(-45deg) translateY(-1px);
+ display: block;
+}
+
+.sidebar-actions {
+ padding: 0.75rem 1rem 1.25rem;
+ border-top: 1px solid var(--polar3);
+}
+.btn-new-cal {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ background: transparent;
+ border: 1px solid var(--polar4);
+ border-radius: 7px;
+ color: var(--snow1);
+ font-size: 0.8rem;
+ font-family: inherit;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: border-color 0.15s, color 0.15s;
+}
+.btn-new-cal:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.btn-new-cal svg { width: 14px; height: 14px; }
+
+/* ── Main calendar area ── */
+.calendar-main {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+}
+
+/* ── Monthly view ── */
+.monthly-view {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-width: 0;
+ transition: flex-basis 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94),
+ max-width 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+ overflow: hidden;
+}
+.daily-open .monthly-view {
+ flex: 0 0 400px;
+ max-width: 400px;
+}
+.monthly-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem 1rem;
+ flex-shrink: 0;
+}
+.monthly-title {
+ font-family: 'EB Garamond', Georgia, serif;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--snow3);
+}
+.nav-btns { display: flex; gap: 0.4rem; }
+.nav-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: border-color 0.15s, color 0.15s;
+}
+.nav-btn:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.nav-btn svg { width: 14px; height: 14px; }
+
+.weekday-row {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ padding: 0 1rem;
+ flex-shrink: 0;
+}
+.weekday-label {
+ text-align: center;
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.35;
+ padding: 0.3rem 0;
+}
+
+.day-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ grid-auto-rows: 1fr;
+ flex: 1;
+ padding: 0 1rem 1rem;
+ gap: 3px;
+ min-height: 0;
+}
+.day-cell {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.4rem 0.5rem;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ transition: background 0.15s, border-color 0.15s;
+ min-height: 0;
+ overflow: hidden;
+}
+.day-cell:hover {
+ background: var(--polar3);
+ border-color: var(--polar4);
+}
+.day-cell.other-month { opacity: 0.3; }
+.day-cell.today .day-number {
+ background: var(--lightblue);
+ color: var(--polar1);
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+}
+.day-cell.selected {
+ background: rgba(136,192,208,0.08);
+ border-color: var(--lightblue);
+}
+.day-number {
+ font-size: 0.8rem;
+ color: var(--snow2);
+ line-height: 1;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* ── Daily view ── */
+.daily-view {
+ display: flex;
+ flex-direction: column;
+ flex: 0 0 0;
+ min-width: 0;
+ overflow: hidden;
+ border-left: 1px solid transparent;
+ opacity: 0;
+ transition: flex-basis 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94),
+ opacity 0.25s ease,
+ border-color 0.35s;
+}
+.daily-open .daily-view {
+ flex: 1 1 auto;
+ opacity: 1;
+ border-color: var(--polar3);
+}
+
+.daily-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem 1rem;
+ flex-shrink: 0;
+ border-bottom: 1px solid var(--polar3);
+}
+.daily-title {
+ font-family: 'EB Garamond', Georgia, serif;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--snow3);
+}
+.btn-close {
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: border-color 0.15s, color 0.15s;
+}
+.btn-close:hover { border-color: var(--red); color: var(--red); }
+.btn-close svg { width: 14px; height: 14px; }
+
+.time-grid {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem 0;
+}
+.time-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0;
+ min-height: 48px;
+ position: relative;
+}
+.time-label {
+ width: 56px;
+ flex-shrink: 0;
+ font-size: 0.7rem;
+ color: var(--snow1);
+ opacity: 0.35;
+ text-align: right;
+ padding-right: 0.75rem;
+ padding-top: 0.1rem;
+ line-height: 1;
+}
+.time-slot {
+ flex: 1;
+ border-top: 1px solid var(--polar3);
+ min-height: 48px;
+ margin-right: 1rem;
+ position: relative;
+}
+
+/* ── Modal ── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.55);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s;
+}
+.modal-overlay.open {
+ opacity: 1;
+ pointer-events: all;
+}
+.modal {
+ background: var(--polar2);
+ border: 1px solid var(--polar3);
+ border-radius: 12px;
+ padding: 2rem;
+ width: 420px;
+ max-width: calc(100vw - 2rem);
+ transform: translateY(12px);
+ transition: transform 0.2s;
+}
+.modal-overlay.open .modal { transform: translateY(0); }
+
+.modal h2 {
+ font-family: 'EB Garamond', Georgia, serif;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: var(--snow3);
+ margin-bottom: 1.5rem;
+}
+.form-group { margin-bottom: 1.1rem; }
+.form-label {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.55;
+ margin-bottom: 0.4rem;
+}
+.form-input {
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ background: var(--polar1);
+ border: 1px solid var(--polar3);
+ border-radius: 7px;
+ color: var(--snow2);
+ font-family: inherit;
+ font-size: 0.9rem;
+ outline: none;
+ transition: border-color 0.15s;
+}
+.form-input:focus { border-color: var(--lightblue); }
+.form-input::placeholder { color: var(--snow1); opacity: 0.3; }
+
+.color-swatches {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+.color-swatch {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid transparent;
+ transition: border-color 0.15s, transform 0.15s;
+}
+.color-swatch:hover { transform: scale(1.1); }
+.color-swatch.selected { border-color: var(--snow3); }
+
+.invite-list { display: flex; flex-direction: column; gap: 0.4rem; }
+.invite-row { display: flex; gap: 0.4rem; }
+.invite-row .form-input { flex: 1; }
+.btn-icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 7px;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 1.1rem;
+ transition: border-color 0.15s, color 0.15s;
+}
+.btn-icon:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.btn-icon.danger:hover { border-color: var(--red); color: var(--red); }
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.6rem;
+ margin-top: 1.75rem;
+}
+.btn {
+ padding: 0.5rem 1.1rem;
+ border-radius: 7px;
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
+}
+.btn:hover { border-color: var(--snow1); }
+.btn-primary {
+ background: var(--lightblue);
+ border-color: var(--lightblue);
+ color: var(--polar1);
+}
+.btn-primary:hover { background: var(--turquoise); border-color: var(--turquoise); }
+
+/* ── Context menu ── */
+.ctx-menu {
+ position: fixed;
+ z-index: 300;
+ background: var(--polar2);
+ border: 1px solid var(--polar3);
+ border-radius: 8px;
+ padding: 0.3rem;
+ min-width: 160px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+ display: none;
+}
+.ctx-menu.open { display: block; }
+.ctx-item {
+ padding: 0.45rem 0.75rem;
+ border-radius: 5px;
+ font-size: 0.85rem;
+ cursor: pointer;
+ color: var(--snow1);
+ transition: background 0.1s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.ctx-item:hover { background: var(--polar3); }
+.ctx-item.danger { color: var(--red); }
+.ctx-item.danger:hover { background: rgba(191,97,106,0.15); }
+
+/* ── Scrollbar ── */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--polar4); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--snow1); opacity: 0.3; }
+
+/* ── Responsive ── */
+@media (max-width: 700px) {
+ .sidebar { width: 180px; }
+ .daily-open .monthly-view { flex-basis: 0; max-width: 0; overflow: hidden; }
+}