calendar

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

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:
MREADME.md | 50+++++++++++++++++++++++++++++++++-----------------
Mapp/main.py | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mapp/models.py | 1+
Mapp/schemas.py | 8+++++++-
Mstatic/app.js | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Astatic/i18n.js | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic/index.html | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mstatic/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;