commit b2f3f834486f1176f23d6a61e51ce875e54199a6
parent 7ba8cad0919f18458e431bac31df9e34efbf9d8c
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date: Sat, 9 May 2026 17:43:44 +0000
Add username-based member management, members modal, and all-day events
- Replace email-based invite with direct username lookup (POST /api/calendars/{id}/members)
- Add dedicated Manage Members modal matching family tree design
- Add all-day event toggle: stores T00:00–T23:59, renders in strip above time grid
- Inline SQLite migration to add all_day column on startup
- Update README to reflect username-based sharing and all-day events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
| M | README.md | | | 50 | +++++++++++++++++++++++++++++++++----------------- |
| M | app/main.py | | | 65 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++----------- |
| M | app/models.py | | | 1 | + |
| M | app/schemas.py | | | 8 | +++++++- |
| M | static/app.js | | | 220 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ |
| A | static/i18n.js | | | 162 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | static/index.html | | | 103 | +++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- |
| M | static/style.css | | | 151 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
8 files changed, 646 insertions(+), 114 deletions(-)
diff --git a/README.md b/README.md
@@ -1,33 +1,49 @@
# Calendar
-Self-hosted personal and shared calendar for Authentik users.
+Self-hosted personal and shared calendar, served at [calendar.piribauer.ch](https://calendar.piribauer.ch).
## Stack
-- Backend: FastAPI (Python) + SQLite via SQLAlchemy
+- Backend: FastAPI (Python 3.10+) with SQLAlchemy and SQLite
- Frontend: Vanilla JS/HTML/CSS (Nord theme)
- Auth: Authentik forward-auth via nginx
-## Setup
+## Features
-```sh
-pip install -r requirements.txt
+Each user gets a personal calendar created automatically on first login. Shared calendars can be created and other members added directly by their Authentik username via the "Manage members" modal.
-sudo mkdir -p /var/lib/calendar
-sudo chown julian:julian /var/lib/calendar
+The interface shows a monthly grid with a collapsible sidebar listing subscribed calendars. Clicking a day opens a daily time-grid view (6 AM – 10 PM). Events have a title, optional start/end time, optional notes, and a list of attendees. An **All day** toggle creates events that span the whole day and appear in a dedicated strip above the time grid.
-cp .env.example .env
+## Installation
-sudo cp calendar.service /etc/systemd/system/
-sudo systemctl daemon-reload
-sudo systemctl enable --now calendar
+A full installation guide is available at [technotes.piribauer.ch/calendar.html](https://technotes.piribauer.ch/calendar.html). The short version:
-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
+```console
+$ python -m venv venv
+$ venv/bin/pip install -r requirements.txt
```
-## Invitation flow
+Create the database directory:
-Users are invited by email. When they first log in via Authentik, pending invitations
-matching their `X-authentik-email` header are automatically accepted.
+```console
+# mkdir -p /var/lib/calendar
+# chown julian:julian /var/lib/calendar
+```
+
+Create `.env` from `.env.example`, then install the service and nginx config:
+
+```console
+# cp calendar.service /etc/systemd/system/
+# systemctl daemon-reload && systemctl enable --now calendar
+# cp nginx/calendar /etc/nginx/sites-available/calendar
+# ln -s /etc/nginx/sites-available/calendar /etc/nginx/sites-enabled/calendar
+# nginx -t && systemctl reload nginx
+```
+
+## Schema migrations
+
+SQLAlchemy creates missing tables on startup but does not alter existing ones. Any new columns must be added manually, e.g.:
+
+```console
+$ sqlite3 /var/lib/calendar/calendar.db "ALTER TABLE events ADD COLUMN description TEXT;"
+```
diff --git a/app/main.py b/app/main.py
@@ -3,24 +3,44 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
+from sqlalchemy import text
from datetime import datetime
import os
from .database import get_db, engine
from .models import Base, Calendar, CalendarMembership, PendingInvitation, Event, EventAttendee
from .schemas import (
- CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest,
+ CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest, AddMemberRequest,
EventCreate, EventUpdate,
)
Base.metadata.create_all(bind=engine)
+
+def _migrate():
+ with engine.connect() as conn:
+ cols = [row[1] for row in conn.execute(text("PRAGMA table_info(events)"))]
+ if "all_day" not in cols:
+ conn.execute(text("ALTER TABLE events ADD COLUMN all_day BOOLEAN NOT NULL DEFAULT 0"))
+ conn.commit()
+
+
+_migrate()
+
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")
+@app.middleware("http")
+async def no_cache_static(request: Request, call_next):
+ response = await call_next(request)
+ if request.url.path.startswith("/static/"):
+ response.headers["Cache-Control"] = "no-cache"
+ return response
+
+
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _get_identity(request: Request) -> tuple[str, str]:
@@ -136,17 +156,11 @@ def create_calendar(body: CalendarCreate, request: Request, db: Session = Depend
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()
- )
+ for uname in body.invite_usernames:
+ uname = uname.strip()
+ if uname and uname != username:
try:
- db.add(PendingInvitation(calendar_id=cal.id, invitee_email=email))
+ db.add(CalendarMembership(calendar_id=cal.id, username=uname, is_visible=True))
db.flush()
except IntegrityError:
db.rollback()
@@ -224,6 +238,29 @@ def invite_member(
return {"invited": email}
+@app.post("/api/calendars/{calendar_id}/members", status_code=201)
+def add_member(
+ calendar_id: int,
+ body: AddMemberRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ username, _ = _get_identity(request)
+ _require_owner(db, calendar_id, username)
+ target = body.username.strip()
+ if not target:
+ raise HTTPException(status_code=422, detail="Username is required")
+ if target == username:
+ raise HTTPException(status_code=400, detail="Cannot add yourself")
+ try:
+ db.add(CalendarMembership(calendar_id=calendar_id, username=target, is_visible=True))
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="User is already a member")
+ return {"username": target}
+
+
@app.delete("/api/calendars/{calendar_id}/members/{member_username}", status_code=204)
def remove_member(
calendar_id: int,
@@ -320,6 +357,7 @@ def list_events(year: int, month: int, request: Request, db: Session = Depends(g
"start_dt": e.start_dt.isoformat(),
"end_dt": e.end_dt.isoformat(),
"description": e.description,
+ "all_day": e.all_day,
"attendees": [a.username for a in e.attendees],
}
for e in events
@@ -341,6 +379,7 @@ def create_event(body: EventCreate, request: Request, db: Session = Depends(get_
start_dt=start,
end_dt=end,
description=body.description,
+ all_day=body.all_day,
)
db.add(ev)
db.flush()
@@ -355,6 +394,7 @@ def create_event(body: EventCreate, request: Request, db: Session = Depends(get_
"start_dt": ev.start_dt.isoformat(),
"end_dt": ev.end_dt.isoformat(),
"description": ev.description,
+ "all_day": ev.all_day,
"attendees": [a.username for a in ev.attendees],
}
@@ -375,6 +415,8 @@ def update_event(event_id: int, body: EventUpdate, request: Request, db: Session
ev.end_dt = _parse_dt(body.end_dt)
if body.description is not None:
ev.description = body.description
+ if body.all_day is not None:
+ ev.all_day = body.all_day
if body.attendee_usernames is not None:
for a in ev.attendees:
db.delete(a)
@@ -390,6 +432,7 @@ def update_event(event_id: int, body: EventUpdate, request: Request, db: Session
"start_dt": ev.start_dt.isoformat(),
"end_dt": ev.end_dt.isoformat(),
"description": ev.description,
+ "all_day": ev.all_day,
"attendees": [a.username for a in ev.attendees],
}
diff --git a/app/models.py b/app/models.py
@@ -54,6 +54,7 @@ class Event(Base):
start_dt = Column(DateTime, nullable=False)
end_dt = Column(DateTime, nullable=False)
description = Column(String, nullable=True)
+ all_day = Column(Boolean, nullable=False, default=False)
calendar = relationship("Calendar")
attendees = relationship(
diff --git a/app/schemas.py b/app/schemas.py
@@ -5,7 +5,7 @@ import re
class CalendarCreate(BaseModel):
name: str
color: str = "#88C0D0"
- invite_emails: list[str] = []
+ invite_usernames: list[str] = []
@field_validator("color")
@classmethod
@@ -35,12 +35,17 @@ class InviteRequest(BaseModel):
email: str
+class AddMemberRequest(BaseModel):
+ username: str
+
+
class EventCreate(BaseModel):
calendar_id: int
title: str
start_dt: str
end_dt: str
description: str | None = None
+ all_day: bool = False
attendee_usernames: list[str] = []
@@ -49,4 +54,5 @@ class EventUpdate(BaseModel):
start_dt: str | None = None
end_dt: str | None = None
description: str | None = None
+ all_day: bool | None = None
attendee_usernames: list[str] | None = None
diff --git a/static/app.js b/static/app.js
@@ -16,10 +16,11 @@ const COLORS = [
'#A3BE8C', '#EBCB8B', '#D08770', '#BF616A', '#B48EAD',
];
-const MONTHS = [
- 'January','February','March','April','May','June',
- 'July','August','September','October','November','December',
-];
+function monthName(idx) {
+ return getLang() === 'de'
+ ? ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'][idx]
+ : ['January','February','March','April','May','June','July','August','September','October','November','December'][idx];
+}
const HOUR_PX = 60; // pixels per hour in the daily view
@@ -110,7 +111,7 @@ function makeCalendarItem(cal) {
const toggle = document.createElement('button');
toggle.className = 'cal-toggle' + (cal.is_visible ? ' visible' : '');
- toggle.title = cal.is_visible ? 'Hide' : 'Show';
+ toggle.title = cal.is_visible ? t('hide') : t('show');
toggle.addEventListener('click', e => { e.stopPropagation(); toggleCalVisibility(cal); });
item.append(dot, name, toggle);
@@ -133,13 +134,13 @@ function showContextMenu(e, cal) {
ctxMenu.innerHTML = '';
if (cal.is_owner && !cal.is_personal) {
- addCtxItem('Edit', () => openCalModal('edit', cal));
- addCtxItem('Invite', () => openCalModal('invite', cal));
- addCtxItem('Delete', () => deleteCalendar(cal), true);
+ addCtxItem(t('ctx-edit'), () => openCalModal('edit', cal));
+ addCtxItem(t('ctx-invite'), () => openMembersModal(cal));
+ addCtxItem(t('ctx-delete'), () => deleteCalendar(cal), true);
} else if (!cal.is_personal) {
- addCtxItem('Leave calendar', () => leaveCalendar(cal), true);
+ addCtxItem(t('ctx-leave-cal'), () => leaveCalendar(cal), true);
} else {
- addCtxItem('Edit name / color', () => openCalModal('edit', cal));
+ addCtxItem(t('ctx-edit-name-color'), () => openCalModal('edit', cal));
}
if (!ctxMenu.children.length) return;
@@ -167,7 +168,7 @@ document.addEventListener('keydown', e => {
// ── Monthly view ──────────────────────────────────────────────────────────────
function renderMonthly() {
- document.getElementById('month-title').textContent = `${MONTHS[state.month]} ${state.year}`;
+ document.getElementById('month-title').textContent = `${monthName(state.month)} ${state.year}`;
const grid = document.getElementById('day-grid');
grid.innerHTML = '';
@@ -182,11 +183,15 @@ function renderMonthly() {
const today = new Date();
for (let i = 0; i < totalCells; i++) {
- let day, isCurrent = true;
+ let day, isCurrent = true, cellMonth = state.month, cellYear = state.year;
if (i < startDow) {
day = daysInPrev - startDow + 1 + i; isCurrent = false;
+ cellMonth = state.month === 0 ? 11 : state.month - 1;
+ cellYear = state.month === 0 ? state.year - 1 : state.year;
} else if (i >= startDow + daysInMonth) {
day = i - startDow - daysInMonth + 1; isCurrent = false;
+ cellMonth = state.month === 11 ? 0 : state.month + 1;
+ cellYear = state.month === 11 ? state.year + 1 : state.year;
} else {
day = i - startDow + 1;
}
@@ -199,8 +204,8 @@ function renderMonthly() {
if (!isCurrent) cell.classList.add('other-month');
if (isWeekend) cell.classList.add('weekend');
- const isToday = isCurrent && day === today.getDate() &&
- state.month === today.getMonth() && state.year === today.getFullYear();
+ const isToday = day === today.getDate() &&
+ cellMonth === today.getMonth() && cellYear === today.getFullYear();
if (isToday) cell.classList.add('today');
if (isCurrent && state.selectedDay === day) cell.classList.add('selected');
@@ -283,9 +288,10 @@ new ResizeObserver(updateMonthlyVisibility).observe(calendarMain);
// ── Daily view open / close ───────────────────────────────────────────────────
function openDailyView(day) {
const date = new Date(state.year, state.month, day);
- document.getElementById('daily-title').textContent = date.toLocaleDateString('en-GB', {
- weekday: 'long', day: 'numeric', month: 'long',
- });
+ document.getElementById('daily-title').textContent = date.toLocaleDateString(
+ getLang() === 'de' ? 'de-DE' : 'en-GB',
+ { weekday: 'long', day: 'numeric', month: 'long' }
+ );
renderTimeGrid();
calendarMain.classList.add('daily-open');
updateMonthlyVisibility();
@@ -349,10 +355,34 @@ function renderDailyEvents() {
return cal && cal.is_visible;
});
+ // All-day strip
+ const alldaySection = document.getElementById('allday-section');
+ const alldayEvents = document.getElementById('allday-events');
+ const alldayEvs = dayEvs.filter(ev => ev.all_day);
+ alldayEvents.innerHTML = '';
+ if (alldayEvs.length) {
+ alldaySection.style.display = 'flex';
+ alldayEvs.forEach(ev => {
+ const cal = state.calendars.find(c => c.id === ev.calendar_id);
+ const color = cal ? cal.color : '#88C0D0';
+ const chip = document.createElement('div');
+ chip.className = 'allday-chip';
+ chip.textContent = ev.title;
+ chip.style.background = color + '33';
+ chip.style.color = color;
+ chip.style.borderLeft = `3px solid ${color}`;
+ chip.addEventListener('click', () => openEventModal(null, ev));
+ alldayEvents.appendChild(chip);
+ });
+ } else {
+ alldaySection.style.display = 'none';
+ }
+
+ // Timed events in the grid
const dayStart = new Date(state.year, state.month, state.selectedDay, 6, 0);
const dayEnd = new Date(state.year, state.month, state.selectedDay, 22, 0);
- dayEvs.forEach(ev => {
+ dayEvs.filter(ev => !ev.all_day).forEach(ev => {
const evStart = new Date(ev.start_dt);
const evEnd = new Date(ev.end_dt);
const cs = evStart < dayStart ? dayStart : evStart;
@@ -371,8 +401,19 @@ function renderDailyEvents() {
block.style.background = color + '22';
block.style.borderLeftColor = color;
block.style.color = color;
- block.textContent = ev.title;
- block.title = ev.description ? `${ev.title}\n${ev.description}` : ev.title;
+
+ const titleEl = document.createElement('div');
+ titleEl.className = 'event-block-title';
+ titleEl.textContent = ev.title;
+ block.appendChild(titleEl);
+
+ if (ev.description) {
+ const descEl = document.createElement('div');
+ descEl.className = 'event-block-desc';
+ descEl.textContent = ev.description;
+ block.appendChild(descEl);
+ }
+
block.addEventListener('click', e => { e.stopPropagation(); openEventModal(null, ev); });
overlay.appendChild(block);
});
@@ -388,13 +429,13 @@ const calModal = document.getElementById('modal');
let calModalMode = 'create';
let calModalTarget = null;
let selectedColor = COLORS[0];
-let inviteEmails = [''];
+let inviteUsernames = [''];
function openCalModal(mode, target = null) {
calModalMode = mode;
calModalTarget = target;
selectedColor = target ? target.color : COLORS[0];
- inviteEmails = [''];
+ inviteUsernames = [''];
renderCalModal();
calModalOverlay.classList.add('open');
}
@@ -406,22 +447,22 @@ document.getElementById('modal-cancel').addEventListener('click', closeCalModal)
document.getElementById('modal-submit').addEventListener('click', submitCalModal);
function renderCalModal() {
- const titles = { create: 'New calendar', edit: 'Edit calendar', invite: 'Invite members' };
- calModal.querySelector('h2').textContent = titles[calModalMode];
+ const titles = { create: t('new-cal-title'), edit: t('edit-cal-title') };
+ calModal.querySelector('h2').textContent = titles[calModalMode] || '';
const body = calModal.querySelector('.modal-body');
body.innerHTML = '';
if (calModalMode === 'create' || calModalMode === 'edit') {
const g = document.createElement('div');
g.className = 'form-group';
- g.innerHTML = `<label class="form-label">Name</label>
- <input class="form-input" id="cal-name-input" type="text" placeholder="Calendar name"
+ g.innerHTML = `<label class="form-label">${t('form-name')}</label>
+ <input class="form-input" id="cal-name-input" type="text" placeholder="${t('cal-name-ph')}"
value="${calModalTarget ? escHtml(calModalTarget.name) : ''}">`;
body.appendChild(g);
const cg = document.createElement('div');
cg.className = 'form-group';
- cg.innerHTML = '<label class="form-label">Color</label>';
+ cg.innerHTML = `<label class="form-label">${t('form-color')}</label>`;
const swatches = document.createElement('div');
swatches.className = 'color-swatches';
COLORS.forEach(c => {
@@ -439,7 +480,7 @@ function renderCalModal() {
body.appendChild(cg);
}
- if (calModalMode === 'create' || calModalMode === 'invite') {
+ if (calModalMode === 'create') {
body.appendChild(buildInviteSection());
}
}
@@ -447,7 +488,7 @@ function renderCalModal() {
function buildInviteSection() {
const g = document.createElement('div');
g.className = 'form-group';
- g.innerHTML = '<label class="form-label">Invite by email</label>';
+ g.innerHTML = `<label class="form-label">${t('add-by-username')}</label>`;
const list = document.createElement('div');
list.className = 'invite-list';
list.id = 'invite-list';
@@ -458,24 +499,24 @@ function buildInviteSection() {
function renderInviteRows(list) {
list.innerHTML = '';
- inviteEmails.forEach((email, i) => {
+ inviteUsernames.forEach((uname, 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; });
+ inp.type = 'text'; inp.className = 'form-input';
+ inp.placeholder = t('username-ph'); inp.value = uname;
+ inp.addEventListener('input', () => { inviteUsernames[i] = inp.value; });
const add = document.createElement('button');
add.className = 'btn-icon';
add.innerHTML = svgPlus();
- add.addEventListener('click', () => { inviteEmails.push(''); renderInviteRows(list); });
+ add.addEventListener('click', () => { inviteUsernames.push(''); renderInviteRows(list); });
const rem = document.createElement('button');
rem.className = 'btn-icon danger';
rem.innerHTML = svgMinus();
rem.addEventListener('click', () => {
- if (inviteEmails.length > 1) { inviteEmails.splice(i, 1); renderInviteRows(list); }
+ if (inviteUsernames.length > 1) { inviteUsernames.splice(i, 1); renderInviteRows(list); }
});
row.append(inp, add, rem);
@@ -487,16 +528,12 @@ async function submitCalModal() {
try {
if (calModalMode === '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 });
+ if (!name) return alert(t('alert-enter-name'));
+ const usernames = inviteUsernames.map(u => u.trim()).filter(Boolean);
+ await api('POST', '/api/calendars', { name, color: selectedColor, invite_usernames: usernames });
} else if (calModalMode === 'edit') {
const name = document.getElementById('cal-name-input')?.value?.trim();
await api('PATCH', `/api/calendars/${calModalTarget.id}`, { name: name || undefined, color: selectedColor });
- } else {
- for (const email of inviteEmails.map(e => e.trim()).filter(Boolean)) {
- await api('POST', `/api/calendars/${calModalTarget.id}/invite`, { email }).catch(() => {});
- }
}
closeCalModal();
await loadCalendars();
@@ -515,6 +552,60 @@ async function leaveCalendar(cal) {
await loadCalendars();
}
+// ── Members modal ─────────────────────────────────────────────────────────────
+let _membersCalId = null;
+
+function openMembersModal(cal) {
+ _membersCalId = cal.id;
+ renderMembersModal();
+ document.getElementById('modal-members-overlay').classList.add('open');
+}
+
+function renderMembersModal() {
+ const cal = state.calendars.find(c => c.id === _membersCalId);
+ if (!cal) return;
+ document.getElementById('modal-members-title').textContent = cal.name;
+ document.getElementById('cal-members-list').innerHTML = cal.members.map(uname => `
+ <div class="member-row">
+ <span class="member-username">${escHtml(uname)}</span>
+ ${uname === state.username ? `<span style="font-size:0.7rem;opacity:0.4;text-transform:uppercase;letter-spacing:0.06em">owner</span>` : ''}
+ ${cal.is_owner && uname !== state.username ? `
+ <button class="btn-icon danger" title="${t('remove')}" onclick="removeMember(${cal.id},'${escHtml(uname)}')">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
+ </button>` : ''}
+ </div>`).join('');
+ document.getElementById('cal-members-invite').style.display = cal.is_owner ? '' : 'none';
+ document.getElementById('cal-invite-username').value = '';
+}
+
+window.removeMember = async (calId, username) => {
+ if (!confirm(`${t('remove')} ${username}?`)) return;
+ try {
+ await api('DELETE', `/api/calendars/${calId}/members/${encodeURIComponent(username)}`);
+ await loadCalendars();
+ renderMembersModal();
+ } catch (err) { alert(err.message); }
+};
+
+document.getElementById('btn-members-add').addEventListener('click', async () => {
+ const username = document.getElementById('cal-invite-username').value.trim();
+ if (!username) return;
+ try {
+ await api('POST', `/api/calendars/${_membersCalId}/members`, { username });
+ await loadCalendars();
+ renderMembersModal();
+ } catch (err) { alert(err.message); }
+});
+
+document.getElementById('btn-members-close').addEventListener('click', () => {
+ document.getElementById('modal-members-overlay').classList.remove('open');
+});
+
+document.getElementById('modal-members-overlay').addEventListener('click', e => {
+ if (e.target === document.getElementById('modal-members-overlay'))
+ document.getElementById('modal-members-overlay').classList.remove('open');
+});
+
document.getElementById('btn-new-cal').addEventListener('click', () => openCalModal('create'));
// ── Event modal ───────────────────────────────────────────────────────────────
@@ -524,7 +615,7 @@ let editingEvent = null;
function openEventModal(prefillDate, existingEvent = null) {
editingEvent = existingEvent;
const modal = document.getElementById('event-modal');
- modal.querySelector('h2').textContent = existingEvent ? 'Edit event' : 'New event';
+ modal.querySelector('h2').textContent = existingEvent ? t('edit-event') : t('new-event');
// Calendar select
const calSel = document.getElementById('ev-calendar');
@@ -550,6 +641,10 @@ function openEventModal(prefillDate, existingEvent = null) {
document.getElementById('ev-title').value = existingEvent ? existingEvent.title : '';
document.getElementById('ev-description').value = existingEvent ? (existingEvent.description || '') : '';
+ const allDay = existingEvent?.all_day ?? false;
+ document.getElementById('ev-all-day').checked = allDay;
+ setAllDayMode(allDay);
+
updateEventAttendees(existingEvent);
calSel.addEventListener('change', () => updateEventAttendees(existingEvent));
@@ -582,6 +677,12 @@ function updateEventAttendees(existingEvent) {
cont.appendChild(list);
}
+function setAllDayMode(on) {
+ document.getElementById('ev-start-time-grp').style.display = on ? 'none' : '';
+ document.getElementById('ev-end-time-grp').style.display = on ? 'none' : '';
+}
+document.getElementById('ev-all-day').addEventListener('change', e => setAllDayMode(e.target.checked));
+
function closeEventModal() { evModalOverlay.classList.remove('open'); editingEvent = null; }
evModalOverlay.addEventListener('click', e => { if (e.target === evModalOverlay) closeEventModal(); });
@@ -596,12 +697,21 @@ document.getElementById('event-modal-submit').addEventListener('click', async ()
const endT = document.getElementById('ev-end-time').value;
const desc = document.getElementById('ev-description').value.trim() || null;
- if (!title) return alert('Please enter a title.');
- if (!startD || !startT || !endD || !endT) return alert('Please fill in start and end times.');
+ if (!title) return alert(t('alert-enter-title'));
- const startDt = `${startD}T${startT}:00`;
- const endDt = `${endD}T${endT}:00`;
- if (endDt <= startDt) return alert('End must be after start.');
+ const allDay = document.getElementById('ev-all-day').checked;
+ let startDt, endDt;
+ if (allDay) {
+ if (!startD || !endD) return alert(t('alert-fill-dates'));
+ if (endD < startD) return alert(t('alert-end-after-start'));
+ startDt = `${startD}T00:00:00`;
+ endDt = `${endD}T23:59:00`;
+ } else {
+ if (!startD || !startT || !endD || !endT) return alert(t('alert-fill-times'));
+ startDt = `${startD}T${startT}:00`;
+ endDt = `${endD}T${endT}:00`;
+ if (endDt <= startDt) return alert(t('alert-end-after-start'));
+ }
const attendees = [...document.querySelectorAll('#ev-attendees input[type=checkbox]:checked')]
.map(cb => cb.value);
@@ -612,6 +722,7 @@ document.getElementById('event-modal-submit').addEventListener('click', async ()
start_dt: startDt,
end_dt: endDt,
description: desc,
+ all_day: allDay,
attendee_usernames: attendees,
};
@@ -654,5 +765,18 @@ function svgMinus() {
return '<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>';
}
+// ── Language change ───────────────────────────────────────────────────────────
+document.addEventListener('langchange', () => {
+ renderMonthly();
+ renderSidebar();
+ if (state.selectedDay) {
+ const date = new Date(state.year, state.month, state.selectedDay);
+ document.getElementById('daily-title').textContent = date.toLocaleDateString(
+ getLang() === 'de' ? 'de-DE' : 'en-GB',
+ { weekday: 'long', day: 'numeric', month: 'long' }
+ );
+ }
+});
+
// ── Start ─────────────────────────────────────────────────────────────────────
init().catch(err => console.error('Init failed:', err));
diff --git a/static/i18n.js b/static/i18n.js
@@ -0,0 +1,162 @@
+(function () {
+ const TRANSLATIONS = {
+ en: {
+ // topbar
+ 'account': 'Account',
+ 'sign-out': 'Sign out',
+ // sidebar
+ 'sidebar-calendars': 'Calendars',
+ 'new-calendar': 'New calendar',
+ // monthly view
+ 'toggle-sidebar': 'Toggle sidebar',
+ 'prev-month': 'Previous month',
+ 'next-month': 'Next month',
+ 'weekday-mon': 'Mon', 'weekday-tue': 'Tue', 'weekday-wed': 'Wed',
+ 'weekday-thu': 'Thu', 'weekday-fri': 'Fri', 'weekday-sat': 'Sat',
+ 'weekday-sun': 'Sun',
+ // daily view
+ 'add-event': '+ Add event',
+ 'close': 'Close',
+ // event modal
+ 'new-event': 'New event',
+ 'edit-event': 'Edit event',
+ 'form-title': 'Title',
+ 'form-calendar': 'Calendar',
+ 'form-start-date': 'Start date',
+ 'form-start-time': 'Start time',
+ 'form-end-date': 'End date',
+ 'form-end-time': 'End time',
+ 'form-notes': 'Notes',
+ 'form-attendees': 'Attendees',
+ 'event-title-ph': 'Event title',
+ 'notes-ph': 'Optional notes…',
+ 'delete': 'Delete',
+ 'cancel': 'Cancel',
+ 'save': 'Save',
+ // calendar modal
+ 'new-cal-title': 'New calendar',
+ 'edit-cal-title': 'Edit calendar',
+ 'invite-members-title': 'Add members',
+ 'form-name': 'Name',
+ 'cal-name-ph': 'Calendar name',
+ 'form-color': 'Color',
+ 'add-by-username': 'Add by username',
+ 'username-ph': 'username',
+ 'members-title': 'Members',
+ 'invite-btn': 'Add',
+ 'done': 'Done',
+ 'remove': 'Remove',
+ // context menu
+ 'ctx-edit': 'Edit',
+ 'ctx-invite': 'Add members',
+ 'ctx-delete': 'Delete',
+ 'ctx-leave-cal': 'Leave calendar',
+ 'ctx-edit-name-color': 'Edit name / color',
+ // toggle visibility
+ 'hide': 'Hide',
+ 'show': 'Show',
+ // alerts
+ 'alert-enter-name': 'Please enter a name.',
+ 'alert-enter-title': 'Please enter a title.',
+ 'alert-fill-times': 'Please fill in start and end times.',
+ 'alert-end-after-start':'End must be after start.',
+ 'form-all-day': 'All day',
+ 'all-day-label': 'all day',
+ 'alert-fill-dates': 'Please fill in start and end date.',
+ },
+ de: {
+ 'account': 'Konto',
+ 'sign-out': 'Abmelden',
+ 'sidebar-calendars': 'Kalender',
+ 'new-calendar': 'Neuer Kalender',
+ 'toggle-sidebar': 'Seitenleiste umschalten',
+ 'prev-month': 'Vorheriger Monat',
+ 'next-month': 'Nächster Monat',
+ 'weekday-mon': 'Mo', 'weekday-tue': 'Di', 'weekday-wed': 'Mi',
+ 'weekday-thu': 'Do', 'weekday-fri': 'Fr', 'weekday-sat': 'Sa',
+ 'weekday-sun': 'So',
+ 'add-event': '+ Termin hinzufügen',
+ 'close': 'Schließen',
+ 'new-event': 'Neuer Termin',
+ 'edit-event': 'Termin bearbeiten',
+ 'form-title': 'Titel',
+ 'form-calendar': 'Kalender',
+ 'form-start-date': 'Startdatum',
+ 'form-start-time': 'Startzeit',
+ 'form-end-date': 'Enddatum',
+ 'form-end-time': 'Endzeit',
+ 'form-notes': 'Notizen',
+ 'form-attendees': 'Teilnehmer',
+ 'event-title-ph': 'Termintitel',
+ 'notes-ph': 'Optionale Notizen…',
+ 'delete': 'Löschen',
+ 'cancel': 'Abbrechen',
+ 'save': 'Speichern',
+ 'new-cal-title': 'Neuer Kalender',
+ 'edit-cal-title': 'Kalender bearbeiten',
+ 'invite-members-title': 'Mitglieder hinzufügen',
+ 'form-name': 'Name',
+ 'cal-name-ph': 'Kalendername',
+ 'form-color': 'Farbe',
+ 'add-by-username': 'Benutzername hinzufügen',
+ 'username-ph': 'Benutzername',
+ 'members-title': 'Mitglieder',
+ 'invite-btn': 'Hinzufügen',
+ 'done': 'Fertig',
+ 'remove': 'Entfernen',
+ 'ctx-edit': 'Bearbeiten',
+ 'ctx-invite': 'Mitglieder hinzufügen',
+ 'ctx-delete': 'Löschen',
+ 'ctx-leave-cal': 'Kalender verlassen',
+ 'ctx-edit-name-color': 'Name / Farbe bearbeiten',
+ 'hide': 'Ausblenden',
+ 'show': 'Einblenden',
+ 'alert-enter-name': 'Bitte gib einen Namen ein.',
+ 'alert-enter-title': 'Bitte gib einen Titel ein.',
+ 'alert-fill-times': 'Bitte fülle Start- und Endzeit aus.',
+ 'alert-end-after-start':'Das Ende muss nach dem Start liegen.',
+ 'form-all-day': 'Ganztägig',
+ 'all-day-label': 'ganztägig',
+ 'alert-fill-dates': 'Bitte Start- und Enddatum ausfüllen.',
+ },
+ };
+
+ window.getLang = function () {
+ const m = document.cookie.match(/(?:^|;\s*)lang=([^;]*)/);
+ return (m && (m[1] === 'en' || m[1] === 'de')) ? m[1] : 'en';
+ };
+
+ window.setLang = function (lang) {
+ document.cookie = 'lang=' + lang + '; domain=.piribauer.ch; path=/; max-age=31536000; SameSite=Lax';
+ applyTranslations(lang);
+ document.dispatchEvent(new CustomEvent('langchange', { detail: { lang: lang } }));
+ };
+
+ window.t = function (key) {
+ const lang = getLang();
+ return (TRANSLATIONS[lang] && TRANSLATIONS[lang][key] !== undefined)
+ ? TRANSLATIONS[lang][key]
+ : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key);
+ };
+
+ function applyTranslations(lang) {
+ const tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
+ const v = tr[el.dataset.i18n];
+ if (v !== undefined) el.textContent = v;
+ });
+ document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
+ const v = tr[el.dataset.i18nTitle];
+ if (v !== undefined) el.title = v;
+ });
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
+ const v = tr[el.dataset.i18nPlaceholder];
+ if (v !== undefined) el.placeholder = v;
+ });
+ document.querySelectorAll('.lang-opt').forEach(function (btn) {
+ btn.classList.toggle('lang-active', btn.dataset.lang === lang);
+ });
+ }
+
+ applyTranslations(getLang());
+})();
diff --git a/static/index.html b/static/index.html
@@ -19,8 +19,12 @@
</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://auth.piribauer.ch/if/user/" class="nav-link" target="_blank" rel="noopener" data-i18n="account">Account</a>
+ <a href="/outpost.goauthentik.io/sign_out" class="nav-link signout" data-i18n="sign-out">Sign out</a>
+ <div class="lang-switcher">
+ <button class="lang-opt lang-active" data-lang="en" onclick="setLang('en')">EN</button>
+ <button class="lang-opt" data-lang="de" onclick="setLang('de')">DE</button>
+ </div>
<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" />
@@ -34,14 +38,14 @@
<!-- Sidebar -->
<aside class="sidebar">
- <div class="sidebar-header">Calendars</div>
+ <div class="sidebar-header" data-i18n="sidebar-calendars">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
+ <span data-i18n="new-calendar">New calendar</span>
</button>
</div>
</aside>
@@ -53,7 +57,7 @@
<section class="monthly-view">
<div class="monthly-header">
<div class="monthly-header-left">
- <button class="btn-sidebar-toggle" id="btn-sidebar-toggle" title="Toggle sidebar">
+ <button class="btn-sidebar-toggle" id="btn-sidebar-toggle" data-i18n-title="toggle-sidebar" title="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18" stroke-linecap="round"/>
</svg>
@@ -61,12 +65,12 @@
<h2 class="monthly-title" id="month-title"></h2>
</div>
<div class="nav-btns">
- <button class="nav-btn" id="prev-month" title="Previous month">
+ <button class="nav-btn" id="prev-month" data-i18n-title="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">
+ <button class="nav-btn" id="next-month" data-i18n-title="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>
@@ -75,13 +79,13 @@
</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 class="weekday-label" data-i18n="weekday-mon">Mon</div>
+ <div class="weekday-label" data-i18n="weekday-tue">Tue</div>
+ <div class="weekday-label" data-i18n="weekday-wed">Wed</div>
+ <div class="weekday-label" data-i18n="weekday-thu">Thu</div>
+ <div class="weekday-label" data-i18n="weekday-fri">Fri</div>
+ <div class="weekday-label" data-i18n="weekday-sat">Sat</div>
+ <div class="weekday-label" data-i18n="weekday-sun">Sun</div>
</div>
<div class="day-grid" id="day-grid"></div>
@@ -92,14 +96,18 @@
<div class="daily-header">
<h3 class="daily-title" id="daily-title"></h3>
<div class="daily-header-right">
- <button class="btn-add-event" id="btn-add-event">+ Add event</button>
- <button class="btn-close" id="btn-close-daily" title="Close">
+ <button class="btn-add-event" id="btn-add-event" data-i18n="add-event">+ Add event</button>
+ <button class="btn-close" id="btn-close-daily" data-i18n-title="close" 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>
+ <div class="allday-section" id="allday-section" style="display:none">
+ <div class="allday-label" data-i18n="all-day-label">all day</div>
+ <div class="allday-events" id="allday-events"></div>
+ </div>
<div class="time-grid" id="time-grid"></div>
</section>
@@ -115,8 +123,26 @@
<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>
+ <button class="btn" id="modal-cancel" data-i18n="cancel">Cancel</button>
+ <button class="btn btn-primary" id="modal-submit" data-i18n="save">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Manage calendar members -->
+ <div class="modal-overlay" id="modal-members-overlay">
+ <div class="modal modal-wide">
+ <h2 id="modal-members-title"></h2>
+ <div id="cal-members-list"></div>
+ <div class="members-invite-section" id="cal-members-invite">
+ <p class="form-label" style="margin-bottom:0.6rem;margin-top:1.25rem" data-i18n="add-by-username">Add by username</p>
+ <div class="invite-row">
+ <input class="form-input" id="cal-invite-username" type="text" data-i18n-placeholder="username-ph" placeholder="username">
+ <button class="btn btn-primary" id="btn-members-add" data-i18n="invite-btn">Add</button>
+ </div>
+ </div>
+ <div class="modal-actions">
+ <button class="btn btn-primary" id="btn-members-close" data-i18n="done">Done</button>
</div>
</div>
</div>
@@ -124,58 +150,67 @@
<!-- Event modal -->
<div class="modal-overlay" id="event-modal-overlay">
<div class="modal event-modal" id="event-modal">
- <h2>New event</h2>
+ <h2 data-i18n="new-event">New event</h2>
<div class="form-group">
- <label class="form-label">Title</label>
- <input class="form-input" id="ev-title" type="text" placeholder="Event title">
+ <label class="form-label" data-i18n="form-title">Title</label>
+ <input class="form-input" id="ev-title" type="text" data-i18n-placeholder="event-title-ph" placeholder="Event title">
</div>
<div class="form-group">
- <label class="form-label">Calendar</label>
+ <label class="form-label" data-i18n="form-calendar">Calendar</label>
<select class="form-input" id="ev-calendar"></select>
</div>
+ <div class="form-group toggle-row">
+ <label class="form-label" data-i18n="form-all-day">All day</label>
+ <label class="toggle-switch">
+ <input type="checkbox" id="ev-all-day">
+ <span class="toggle-slider"></span>
+ </label>
+ </div>
+
<div class="form-row">
<div class="form-group">
- <label class="form-label">Start date</label>
+ <label class="form-label" data-i18n="form-start-date">Start date</label>
<input class="form-input" id="ev-start-date" type="date">
</div>
- <div class="form-group">
- <label class="form-label">Start time</label>
+ <div class="form-group" id="ev-start-time-grp">
+ <label class="form-label" data-i18n="form-start-time">Start time</label>
<input class="form-input" id="ev-start-time" type="time">
</div>
</div>
<div class="form-row">
<div class="form-group">
- <label class="form-label">End date</label>
+ <label class="form-label" data-i18n="form-end-date">End date</label>
<input class="form-input" id="ev-end-date" type="date">
</div>
- <div class="form-group">
- <label class="form-label">End time</label>
+ <div class="form-group" id="ev-end-time-grp">
+ <label class="form-label" data-i18n="form-end-time">End time</label>
<input class="form-input" id="ev-end-time" type="time">
</div>
</div>
<div class="form-group">
- <label class="form-label">Notes</label>
- <textarea class="form-input" id="ev-description" placeholder="Optional notes…"></textarea>
+ <label class="form-label" data-i18n="form-notes">Notes</label>
+ <textarea class="form-input" id="ev-description" data-i18n-placeholder="notes-ph" placeholder="Optional notes…"></textarea>
</div>
<div class="form-group" id="ev-attendees-group" style="display:none">
- <label class="form-label">Attendees</label>
+ <label class="form-label" data-i18n="form-attendees">Attendees</label>
<div id="ev-attendees"></div>
</div>
<div class="modal-actions">
- <button class="btn btn-danger" id="ev-delete-btn" style="display:none;margin-right:auto">Delete</button>
- <button class="btn" id="event-modal-cancel">Cancel</button>
- <button class="btn btn-primary" id="event-modal-submit">Save</button>
+ <button class="btn btn-danger" id="ev-delete-btn" style="display:none;margin-right:auto" data-i18n="delete">Delete</button>
+ <button class="btn" id="event-modal-cancel" data-i18n="cancel">Cancel</button>
+ <button class="btn btn-primary" id="event-modal-submit" data-i18n="save">Save</button>
</div>
</div>
</div>
+ <script src="/static/i18n.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
diff --git a/static/style.css b/static/style.css
@@ -62,6 +62,36 @@ body {
}
.nav-link:hover { color: var(--lightblue); }
.nav-link.signout:hover { color: var(--red); }
+.lang-switcher {
+ display: inline-flex;
+ align-items: center;
+ background: var(--polar3);
+ border-radius: 6px;
+ padding: 3px;
+ gap: 1px;
+}
+.lang-opt {
+ background: none;
+ border: none;
+ border-radius: 4px;
+ color: var(--snow1);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ line-height: 1;
+ opacity: 0.4;
+ padding: 4px 8px;
+ transition: background 0.15s, color 0.15s, opacity 0.15s, box-shadow 0.15s;
+}
+.lang-opt.lang-active {
+ background: var(--polar1);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.35);
+ color: var(--lightblue);
+ opacity: 1;
+}
+.lang-opt:not(.lang-active):hover { opacity: 0.75; }
.topbar-home {
display: flex;
align-items: center;
@@ -374,6 +404,85 @@ body {
.btn-close:hover { border-color: var(--red); color: var(--red); }
.btn-close svg { width: 14px; height: 14px; }
+/* All-day strip */
+.allday-section {
+ display: flex;
+ align-items: flex-start;
+ border-bottom: 1px solid var(--polar3);
+ padding: 0.35rem 0;
+ flex-shrink: 0;
+}
+.allday-label {
+ width: 56px;
+ flex-shrink: 0;
+ font-size: 0.65rem;
+ color: var(--snow1);
+ opacity: 0.35;
+ text-align: right;
+ padding-right: 0.75rem;
+ padding-top: 0.3rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ line-height: 1;
+}
+.allday-events {
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ padding-left: 20px;
+ margin-right: 1rem;
+}
+.allday-chip {
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 0.72rem;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ transition: filter 0.1s;
+}
+.allday-chip:hover { filter: brightness(1.15); }
+
+/* Toggle switch */
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.toggle-switch {
+ position: relative;
+ display: inline-block;
+ width: 38px;
+ height: 22px;
+ flex-shrink: 0;
+}
+.toggle-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
+.toggle-slider {
+ position: absolute;
+ inset: 0;
+ border-radius: 22px;
+ background: var(--polar3);
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.toggle-slider::after {
+ content: '';
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--snow1);
+ top: 3px;
+ left: 3px;
+ transition: transform 0.2s;
+}
+.toggle-switch input:checked + .toggle-slider { background: var(--lightblue); }
+.toggle-switch input:checked + .toggle-slider::after { transform: translateX(16px); }
+
.time-grid {
flex: 1;
overflow-y: auto;
@@ -423,16 +532,30 @@ body {
border-radius: 4px;
padding: 2px 6px;
font-size: 0.72rem;
- font-weight: 500;
line-height: 1.3;
overflow: hidden;
cursor: pointer;
pointer-events: all;
border-left: 3px solid;
+ display: flex;
+ flex-direction: column;
+}
+.event-block:hover { filter: brightness(1.15); }
+.event-block-title {
+ font-weight: 500;
white-space: nowrap;
+ overflow: hidden;
text-overflow: ellipsis;
+ flex-shrink: 0;
+}
+.event-block-desc {
+ font-size: 0.67rem;
+ opacity: 0.75;
+ overflow: hidden;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin-top: 1px;
}
-.event-block:hover { filter: brightness(1.15); }
/* ── Modal ── */
.modal-overlay {
@@ -518,9 +641,24 @@ textarea.form-input {
line-height: 1.5;
}
+.modal-wide { width: min(520px, 92vw); }
+
.invite-list { display: flex; flex-direction: column; gap: 0.4rem; }
-.invite-row { display: flex; gap: 0.4rem; }
+.invite-row { display: flex; gap: 0.5rem; align-items: center; }
.invite-row .form-input { flex: 1; }
+
+.member-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--polar3);
+ font-size: 0.875rem;
+}
+.member-username { flex: 1; color: var(--snow2); }
+
+.members-invite-section { margin-top: 0.25rem; }
+
.btn-icon {
width: 34px;
height: 34px;
@@ -599,6 +737,13 @@ textarea.form-input {
::-webkit-scrollbar-thumb { background: var(--polar4); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--snow1); opacity: 0.3; }
+/* ── Topbar left group ── */
+.topbar-left {
+ display: flex;
+ align-items: baseline;
+ gap: 1.5rem;
+}
+
/* ── Topbar back link ── */
.topbar-back {
font-size: 0.8rem;