calendar

A HTML/PHP project for a self-hosted (family)-calendar.
Log | Files | Refs | README

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:
A.env.example | 1+
MREADME.md | 34+++++++++++++++++++++++++++++++++-
Aapp/__init__.py | 0
Aapp/database.py | 19+++++++++++++++++++
Aapp/main.py | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/models.py | 45+++++++++++++++++++++++++++++++++++++++++++++
Aapp/schemas.py | 35+++++++++++++++++++++++++++++++++++
Acalendar.service | 15+++++++++++++++
Anginx/calendar | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 4++++
Astatic/app.js | 445+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/index.html | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/style.css | 547+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); +} + +// ── 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 &mdash; 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; } +}