calendar

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

commit 973d2763f673ea0ceb71df32e0c32530b0012d76
parent 80241ab49fd0fac9457a7ce7acd3d59d83717c7a
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Fri,  1 May 2026 21:13:11 +0000

UI polish: sidebar toggle in header, margins, daily max-width, weekends, drop categories

- Sidebar toggle button moved into monthly header (left of month/year title)
- app-body capped at max-width 1600px and centered for symmetric margins
- Daily view capped at 520px wide (fixed, monthly takes remaining space)
- Weekend cells (Sat/Sun) get a subtle background tint
- Categories removed; event form now has a free-text Notes field instead
- Event description stored in DB and shown as block tooltip in daily view
- Category modal, routes, model, and all related JS removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Mapp/main.py | 78++++++++----------------------------------------------------------------------
Mapp/models.py | 15+--------------
Mapp/schemas.py | 30++----------------------------
Mstatic/app.js | 209+++++++++++++++++--------------------------------------------------------------
Mstatic/index.html | 74+++++++++++++++++++-------------------------------------------------------
Mstatic/style.css | 21++++++++++++++++++---
6 files changed, 91 insertions(+), 336 deletions(-)

diff --git a/app/main.py b/app/main.py @@ -7,10 +7,10 @@ from datetime import datetime import os from .database import get_db, engine -from .models import Base, Calendar, CalendarMembership, PendingInvitation, Category, Event, EventAttendee +from .models import Base, Calendar, CalendarMembership, PendingInvitation, Event, EventAttendee from .schemas import ( CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest, - CategoryCreate, CategoryUpdate, EventCreate, EventUpdate, + EventCreate, EventUpdate, ) Base.metadata.create_all(bind=engine) @@ -289,68 +289,6 @@ def leave_calendar(calendar_id: int, request: Request, db: Session = Depends(get db.commit() -# ── Categories ──────────────────────────────────────────────────────────────── - -@app.get("/api/categories") -def list_categories(request: Request, db: Session = Depends(get_db)): - username, _ = _get_identity(request) - cats = db.query(Category).filter(Category.owner_username == username).all() - return [{"id": c.id, "name": c.name, "color": c.color, "is_visible": c.is_visible} for c in cats] - - -@app.post("/api/categories", status_code=201) -def create_category(body: CategoryCreate, request: Request, db: Session = Depends(get_db)): - username, _ = _get_identity(request) - cat = Category(name=body.name, color=body.color, owner_username=username) - db.add(cat) - try: - db.commit() - except IntegrityError: - db.rollback() - raise HTTPException(status_code=409, detail="Category name already exists") - db.refresh(cat) - return {"id": cat.id, "name": cat.name, "color": cat.color, "is_visible": cat.is_visible} - - -@app.patch("/api/categories/{category_id}") -def update_category(category_id: int, body: CategoryUpdate, request: Request, db: Session = Depends(get_db)): - username, _ = _get_identity(request) - cat = db.query(Category).filter(Category.id == category_id, Category.owner_username == username).first() - if not cat: - raise HTTPException(status_code=404) - if body.name is not None: - cat.name = body.name - if body.color is not None: - cat.color = body.color - try: - db.commit() - except IntegrityError: - db.rollback() - raise HTTPException(status_code=409, detail="Category name already exists") - return {"id": cat.id, "name": cat.name, "color": cat.color, "is_visible": cat.is_visible} - - -@app.patch("/api/categories/{category_id}/visibility") -def set_category_visibility(category_id: int, body: VisibilityUpdate, request: Request, db: Session = Depends(get_db)): - username, _ = _get_identity(request) - cat = db.query(Category).filter(Category.id == category_id, Category.owner_username == username).first() - if not cat: - raise HTTPException(status_code=404) - cat.is_visible = body.is_visible - db.commit() - return {"is_visible": cat.is_visible} - - -@app.delete("/api/categories/{category_id}", status_code=204) -def delete_category(category_id: int, request: Request, db: Session = Depends(get_db)): - username, _ = _get_identity(request) - cat = db.query(Category).filter(Category.id == category_id, Category.owner_username == username).first() - if not cat: - raise HTTPException(status_code=404) - db.delete(cat) - db.commit() - - # ── Events ──────────────────────────────────────────────────────────────────── def _parse_dt(s: str) -> datetime: @@ -381,7 +319,7 @@ def list_events(year: int, month: int, request: Request, db: Session = Depends(g "title": e.title, "start_dt": e.start_dt.isoformat(), "end_dt": e.end_dt.isoformat(), - "category_id": e.category_id, + "description": e.description, "attendees": [a.username for a in e.attendees], } for e in events @@ -402,7 +340,7 @@ def create_event(body: EventCreate, request: Request, db: Session = Depends(get_ title=body.title, start_dt=start, end_dt=end, - category_id=body.category_id, + description=body.description, ) db.add(ev) db.flush() @@ -416,7 +354,7 @@ def create_event(body: EventCreate, request: Request, db: Session = Depends(get_ "title": ev.title, "start_dt": ev.start_dt.isoformat(), "end_dt": ev.end_dt.isoformat(), - "category_id": ev.category_id, + "description": ev.description, "attendees": [a.username for a in ev.attendees], } @@ -435,8 +373,8 @@ def update_event(event_id: int, body: EventUpdate, request: Request, db: Session ev.start_dt = _parse_dt(body.start_dt) if body.end_dt is not None: ev.end_dt = _parse_dt(body.end_dt) - if body.category_id is not None: - ev.category_id = body.category_id + if body.description is not None: + ev.description = body.description if body.attendee_usernames is not None: for a in ev.attendees: db.delete(a) @@ -451,7 +389,7 @@ def update_event(event_id: int, body: EventUpdate, request: Request, db: Session "title": ev.title, "start_dt": ev.start_dt.isoformat(), "end_dt": ev.end_dt.isoformat(), - "category_id": ev.category_id, + "description": ev.description, "attendees": [a.username for a in ev.attendees], } diff --git a/app/models.py b/app/models.py @@ -45,18 +45,6 @@ class PendingInvitation(Base): calendar = relationship("Calendar", back_populates="pending_invitations") -class Category(Base): - __tablename__ = "categories" - - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) - color = Column(String, nullable=False, default="#EBCB8B") - owner_username = Column(String, nullable=False) - is_visible = Column(Boolean, default=True, nullable=False) - - __table_args__ = (UniqueConstraint("owner_username", "name"),) - - class Event(Base): __tablename__ = "events" @@ -65,10 +53,9 @@ class Event(Base): title = Column(String, nullable=False) start_dt = Column(DateTime, nullable=False) end_dt = Column(DateTime, nullable=False) - category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + description = Column(String, nullable=True) calendar = relationship("Calendar") - category = relationship("Category") attendees = relationship( "EventAttendee", back_populates="event", cascade="all, delete-orphan" ) diff --git a/app/schemas.py b/app/schemas.py @@ -35,38 +35,12 @@ class InviteRequest(BaseModel): email: str -def _hex(v: str | None) -> str | None: - if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v): - raise ValueError("invalid hex color") - return v - - -class CategoryCreate(BaseModel): - name: str - color: str = "#EBCB8B" - - @field_validator("color") - @classmethod - def valid_color(cls, v: str) -> str: - return _hex(v) # type: ignore[return-value] - - -class CategoryUpdate(BaseModel): - name: str | None = None - color: str | None = None - - @field_validator("color") - @classmethod - def valid_color(cls, v: str | None) -> str | None: - return _hex(v) - - class EventCreate(BaseModel): calendar_id: int title: str start_dt: str end_dt: str - category_id: int | None = None + description: str | None = None attendee_usernames: list[str] = [] @@ -74,5 +48,5 @@ class EventUpdate(BaseModel): title: str | None = None start_dt: str | None = None end_dt: str | None = None - category_id: int | None = None + description: str | None = None attendee_usernames: list[str] | None = None diff --git a/static/app.js b/static/app.js @@ -5,7 +5,6 @@ const state = { username: '', email: '', calendars: [], - categories: [], events: {}, // keyed "YYYY-MM-DD" → array of event objects year: new Date().getFullYear(), month: new Date().getMonth(), // 0-based @@ -44,7 +43,7 @@ async function init() { state.email = me.email; document.getElementById('username-display').textContent = me.username; - await Promise.all([loadCalendars(), loadCategories()]); + await loadCalendars(); await fetchEvents(); renderMonthly(); @@ -60,11 +59,6 @@ async function loadCalendars() { renderSidebar(); } -async function loadCategories() { - state.categories = await api('GET', '/api/categories'); - renderCategories(); -} - async function fetchEvents() { const evs = await api('GET', `/api/events?year=${state.year}&month=${state.month + 1}`); indexEvents(evs); @@ -98,9 +92,7 @@ document.getElementById('btn-sidebar-toggle').addEventListener('click', () => { function renderSidebar() { const list = document.getElementById('calendar-list'); list.innerHTML = ''; - state.calendars.forEach(cal => { - list.appendChild(makeCalendarItem(cal)); - }); + state.calendars.forEach(cal => list.appendChild(makeCalendarItem(cal))); } function makeCalendarItem(cal) { @@ -122,7 +114,7 @@ function makeCalendarItem(cal) { toggle.addEventListener('click', e => { e.stopPropagation(); toggleCalVisibility(cal); }); item.append(dot, name, toggle); - item.addEventListener('contextmenu', e => { e.preventDefault(); showContextMenu(e, cal, 'calendar'); }); + item.addEventListener('contextmenu', e => { e.preventDefault(); showContextMenu(e, cal); }); return item; } @@ -134,64 +126,20 @@ async function toggleCalVisibility(cal) { if (state.selectedDay) renderDailyEvents(); } -// ── Sidebar: Categories ─────────────────────────────────────────────────────── -function renderCategories() { - const list = document.getElementById('category-list'); - list.innerHTML = ''; - state.categories.forEach(cat => { - list.appendChild(makeCategoryItem(cat)); - }); -} - -function makeCategoryItem(cat) { - const item = document.createElement('div'); - item.className = 'calendar-item'; - item.dataset.id = cat.id; - - const dot = document.createElement('span'); - dot.className = 'cal-dot'; - dot.style.background = cat.color; - - const name = document.createElement('span'); - name.className = 'cal-name'; - name.textContent = cat.name; - - const toggle = document.createElement('button'); - toggle.className = 'cal-toggle' + (cat.is_visible ? ' visible' : ''); - toggle.title = cat.is_visible ? 'Hide' : 'Show'; - toggle.addEventListener('click', e => { e.stopPropagation(); toggleCatVisibility(cat); }); - - item.append(dot, name, toggle); - item.addEventListener('contextmenu', e => { e.preventDefault(); showContextMenu(e, cat, 'category'); }); - return item; -} - -async function toggleCatVisibility(cat) { - await api('PATCH', `/api/categories/${cat.id}/visibility`, { is_visible: !cat.is_visible }); - cat.is_visible = !cat.is_visible; - renderCategories(); - if (state.selectedDay) renderDailyEvents(); -} - // ── Context menu ────────────────────────────────────────────────────────────── const ctxMenu = document.getElementById('ctx-menu'); -function showContextMenu(e, item, type) { +function showContextMenu(e, cal) { ctxMenu.innerHTML = ''; - if (type === 'calendar') { - if (item.is_owner && !item.is_personal) { - addCtxItem('Edit', () => openCalModal('edit', item)); - addCtxItem('Invite', () => openCalModal('invite', item)); - addCtxItem('Delete', () => deleteCalendar(item), true); - } else if (!item.is_personal) { - addCtxItem('Leave calendar', () => leaveCalendar(item), true); - } else { - addCtxItem('Edit name / color', () => openCalModal('edit', item)); - } + if (cal.is_owner && !cal.is_personal) { + addCtxItem('Edit', () => openCalModal('edit', cal)); + addCtxItem('Invite', () => openCalModal('invite', cal)); + addCtxItem('Delete', () => deleteCalendar(cal), true); + } else if (!cal.is_personal) { + addCtxItem('Leave calendar', () => leaveCalendar(cal), true); } else { - addCtxItem('Edit', () => openCategoryModal(item)); - addCtxItem('Delete', () => deleteCategoryItem(item), true); + addCtxItem('Edit name / color', () => openCalModal('edit', cal)); } if (!ctxMenu.children.length) return; @@ -214,7 +162,7 @@ function closeCtxMenu() { ctxMenu.classList.remove('open'); } document.addEventListener('click', closeCtxMenu); document.addEventListener('keydown', e => { - if (e.key === 'Escape') { closeCtxMenu(); closeCalModal(); closeEventModal(); closeCategoryModal(); closeDailyView(); } + if (e.key === 'Escape') { closeCtxMenu(); closeCalModal(); closeEventModal(); closeDailyView(); } }); // ── Monthly view ────────────────────────────────────────────────────────────── @@ -226,7 +174,7 @@ function renderMonthly() { const firstDay = new Date(state.year, state.month, 1); let startDow = firstDay.getDay(); - startDow = startDow === 0 ? 6 : startDow - 1; + startDow = startDow === 0 ? 6 : startDow - 1; // Mon=0 … Sun=6 const daysInMonth = new Date(state.year, state.month + 1, 0).getDate(); const daysInPrev = new Date(state.year, state.month, 0).getDate(); @@ -243,12 +191,17 @@ function renderMonthly() { day = i - startDow + 1; } + const col = i % 7; // 0=Mon … 6=Sun + const isWeekend = col >= 5; + const cell = document.createElement('div'); - cell.className = 'day-cell' + (!isCurrent ? ' other-month' : ''); + cell.className = 'day-cell'; + 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(); - if (isToday) cell.classList.add('today'); + if (isToday) cell.classList.add('today'); if (isCurrent && state.selectedDay === day) cell.classList.add('selected'); const num = document.createElement('div'); @@ -256,7 +209,7 @@ function renderMonthly() { num.textContent = day; cell.appendChild(num); - // Event dots + // Event dots for current-month days if (isCurrent) { const k = `${state.year}-${String(state.month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`; const dayEvs = (state.events[k] || []).filter(ev => { @@ -281,9 +234,9 @@ function renderMonthly() { } cell.appendChild(dots); } + cell.addEventListener('click', () => selectDay(day)); } - if (isCurrent) cell.addEventListener('click', () => selectDay(day)); grid.appendChild(cell); } } @@ -313,7 +266,7 @@ document.getElementById('next-month').addEventListener('click', nextMonth); // ── Daily view ──────────────────────────────────────────────────────────────── function openDailyView(day) { - const date = new Date(state.year, state.month, 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', }); @@ -375,12 +328,7 @@ function renderDailyEvents() { const k = `${state.year}-${String(state.month+1).padStart(2,'0')}-${String(state.selectedDay).padStart(2,'0')}`; const dayEvs = (state.events[k] || []).filter(ev => { const cal = state.calendars.find(c => c.id === ev.calendar_id); - if (!cal || !cal.is_visible) return false; - if (ev.category_id) { - const cat = state.categories.find(c => c.id === ev.category_id); - if (cat && !cat.is_visible) return false; - } - return true; + return cal && cal.is_visible; }); const dayStart = new Date(state.year, state.month, state.selectedDay, 6, 0); @@ -389,12 +337,12 @@ function renderDailyEvents() { dayEvs.forEach(ev => { const evStart = new Date(ev.start_dt); const evEnd = new Date(ev.end_dt); - const clamped_start = evStart < dayStart ? dayStart : evStart; - const clamped_end = evEnd > dayEnd ? dayEnd : evEnd; - if (clamped_start >= clamped_end) return; + const cs = evStart < dayStart ? dayStart : evStart; + const ce = evEnd > dayEnd ? dayEnd : evEnd; + if (cs >= ce) return; - const startMin = (clamped_start.getHours() - 6) * 60 + clamped_start.getMinutes(); - const durMin = (clamped_end - clamped_start) / 60000; + const startMin = (cs.getHours() - 6) * 60 + cs.getMinutes(); + const durMin = (ce - cs) / 60000; const cal = state.calendars.find(c => c.id === ev.calendar_id); const color = cal ? cal.color : '#88C0D0'; @@ -406,7 +354,7 @@ function renderDailyEvents() { block.style.borderLeftColor = color; block.style.color = color; block.textContent = ev.title; - block.title = ev.title; + block.title = ev.description ? `${ev.title}\n${ev.description}` : ev.title; block.addEventListener('click', e => { e.stopPropagation(); openEventModal(null, ev); }); overlay.appendChild(block); }); @@ -419,10 +367,10 @@ document.getElementById('btn-add-event').addEventListener('click', () => { // ── Calendar modal (create / edit / invite) ─────────────────────────────────── const calModalOverlay = document.getElementById('modal-overlay'); const calModal = document.getElementById('modal'); -let calModalMode = 'create'; +let calModalMode = 'create'; let calModalTarget = null; -let selectedColor = COLORS[0]; -let inviteEmails = ['']; +let selectedColor = COLORS[0]; +let inviteEmails = ['']; function openCalModal(mode, target = null) { calModalMode = mode; @@ -508,7 +456,9 @@ function 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); } }); + rem.addEventListener('click', () => { + if (inviteEmails.length > 1) { inviteEmails.splice(i, 1); renderInviteRows(list); } + }); row.append(inp, add, rem); list.appendChild(row); @@ -549,66 +499,6 @@ async function leaveCalendar(cal) { document.getElementById('btn-new-cal').addEventListener('click', () => openCalModal('create')); -// ── Category modal ──────────────────────────────────────────────────────────── -const catModalOverlay = document.getElementById('cat-modal-overlay'); -let catModalTarget = null; -let catSelectedColor = COLORS[4]; // green default - -function openCategoryModal(cat = null) { - catModalTarget = cat; - catSelectedColor = cat ? cat.color : COLORS[4]; - renderCategoryModal(); - catModalOverlay.classList.add('open'); -} - -function closeCategoryModal() { catModalOverlay.classList.remove('open'); } - -catModalOverlay.addEventListener('click', e => { if (e.target === catModalOverlay) closeCategoryModal(); }); -document.getElementById('cat-modal-cancel').addEventListener('click', closeCategoryModal); -document.getElementById('cat-modal-submit').addEventListener('click', submitCategoryModal); - -function renderCategoryModal() { - const modal = document.getElementById('cat-modal'); - modal.querySelector('h2').textContent = catModalTarget ? 'Edit category' : 'New category'; - document.getElementById('cat-name-input').value = catModalTarget ? catModalTarget.name : ''; - - const swatches = document.getElementById('cat-color-swatches'); - swatches.innerHTML = ''; - COLORS.forEach(c => { - const sw = document.createElement('div'); - sw.className = 'color-swatch' + (c === catSelectedColor ? ' selected' : ''); - sw.style.background = c; - sw.addEventListener('click', () => { - catSelectedColor = c; - swatches.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected')); - sw.classList.add('selected'); - }); - swatches.appendChild(sw); - }); -} - -async function submitCategoryModal() { - const name = document.getElementById('cat-name-input').value.trim(); - if (!name) return alert('Please enter a name.'); - try { - if (catModalTarget) { - await api('PATCH', `/api/categories/${catModalTarget.id}`, { name, color: catSelectedColor }); - } else { - await api('POST', '/api/categories', { name, color: catSelectedColor }); - } - closeCategoryModal(); - await loadCategories(); - } catch (err) { alert(err.message); } -} - -async function deleteCategoryItem(cat) { - if (!confirm(`Delete category "${cat.name}"?`)) return; - await api('DELETE', `/api/categories/${cat.id}`); - await loadCategories(); -} - -document.getElementById('btn-new-cat').addEventListener('click', () => openCategoryModal()); - // ── Event modal ─────────────────────────────────────────────────────────────── const evModalOverlay = document.getElementById('event-modal-overlay'); let editingEvent = null; @@ -630,7 +520,7 @@ function openEventModal(prefillDate, existingEvent = null) { }); // Dates & times - const d = existingEvent ? new Date(existingEvent.start_dt) : (prefillDate || new Date()); + const d = existingEvent ? new Date(existingEvent.start_dt) : (prefillDate || new Date()); const eD = existingEvent ? new Date(existingEvent.end_dt) : new Date(d.getTime() + 60 * 60 * 1000); @@ -639,24 +529,12 @@ function openEventModal(prefillDate, existingEvent = null) { document.getElementById('ev-end-date').value = localDateStr(eD); document.getElementById('ev-end-time').value = localTimeStr(eD); - // Title - document.getElementById('ev-title').value = existingEvent ? existingEvent.title : ''; - - // Category select - const catSel = document.getElementById('ev-category'); - catSel.innerHTML = '<option value="">— None —</option>'; - state.categories.forEach(cat => { - const opt = document.createElement('option'); - opt.value = cat.id; - opt.textContent = cat.name; - if (existingEvent && existingEvent.category_id === cat.id) opt.selected = true; - catSel.appendChild(opt); - }); + document.getElementById('ev-title').value = existingEvent ? existingEvent.title : ''; + document.getElementById('ev-description').value = existingEvent ? (existingEvent.description || '') : ''; updateEventAttendees(existingEvent); calSel.addEventListener('change', () => updateEventAttendees(existingEvent)); - // Delete button visibility document.getElementById('ev-delete-btn').style.display = existingEvent ? 'inline-flex' : 'none'; evModalOverlay.classList.add('open'); @@ -698,9 +576,9 @@ document.getElementById('event-modal-submit').addEventListener('click', async () const startT = document.getElementById('ev-start-time').value; const endD = document.getElementById('ev-end-date').value; const endT = document.getElementById('ev-end-time').value; - const catId = document.getElementById('ev-category').value || null; + const desc = document.getElementById('ev-description').value.trim() || null; - if (!title) return alert('Please enter a title.'); + if (!title) return alert('Please enter a title.'); if (!startD || !startT || !endD || !endT) return alert('Please fill in start and end times.'); const startDt = `${startD}T${startT}:00`; @@ -715,7 +593,7 @@ document.getElementById('event-modal-submit').addEventListener('click', async () title, start_dt: startDt, end_dt: endDt, - category_id: catId ? parseInt(catId) : null, + description: desc, attendee_usernames: attendees, }; @@ -733,8 +611,7 @@ document.getElementById('event-modal-submit').addEventListener('click', async () }); document.getElementById('ev-delete-btn').addEventListener('click', async () => { - if (!editingEvent) return; - if (!confirm(`Delete "${editingEvent.title}"?`)) return; + if (!editingEvent || !confirm(`Delete "${editingEvent.title}"?`)) return; await api('DELETE', `/api/events/${editingEvent.id}`); closeEventModal(); await fetchEvents(); diff --git a/static/index.html b/static/index.html @@ -14,11 +14,6 @@ <nav class="topbar"> <div class="topbar-inner"> <div class="topbar-left"> - <button class="btn-sidebar-toggle" id="btn-sidebar-toggle" 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> - </button> <a href="/" class="topbar-name">Calendar</a> <a href="https://hub.piribauer.ch" class="topbar-back">Hub</a> </div> @@ -39,35 +34,16 @@ <!-- Sidebar --> <aside class="sidebar"> - - <div class="sidebar-section"> - <div class="sidebar-header">Calendars</div> - <div class="calendar-list" id="calendar-list"></div> - <div class="sidebar-actions"> - <button class="btn-new-cal" id="btn-new-cal"> - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - <path d="M12 5v14M5 12h14" stroke-linecap="round"/> - </svg> - New calendar - </button> - </div> - </div> - - <div class="sidebar-divider"></div> - - <div class="sidebar-section"> - <div class="sidebar-header">Categories</div> - <div class="calendar-list" id="category-list"></div> - <div class="sidebar-actions"> - <button class="btn-new-cal" id="btn-new-cat"> - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - <path d="M12 5v14M5 12h14" stroke-linecap="round"/> - </svg> - New category - </button> - </div> + <div class="sidebar-header">Calendars</div> + <div class="calendar-list" id="calendar-list"></div> + <div class="sidebar-actions"> + <button class="btn-new-cal" id="btn-new-cal"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M12 5v14M5 12h14" stroke-linecap="round"/> + </svg> + New calendar + </button> </div> - </aside> <!-- Calendar main --> @@ -76,7 +52,14 @@ <!-- Monthly view --> <section class="monthly-view"> <div class="monthly-header"> - <h2 class="monthly-title" id="month-title"></h2> + <div class="monthly-header-left"> + <button class="btn-sidebar-toggle" id="btn-sidebar-toggle" 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> + </button> + <h2 class="monthly-title" id="month-title"></h2> + </div> <div class="nav-btns"> <button class="nav-btn" id="prev-month" title="Previous month"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> @@ -138,25 +121,6 @@ </div> </div> - <!-- Category modal --> - <div class="modal-overlay" id="cat-modal-overlay"> - <div class="modal" id="cat-modal"> - <h2>New category</h2> - <div class="form-group"> - <label class="form-label">Name</label> - <input class="form-input" id="cat-name-input" type="text" placeholder="Category name"> - </div> - <div class="form-group"> - <label class="form-label">Color</label> - <div class="color-swatches" id="cat-color-swatches"></div> - </div> - <div class="modal-actions"> - <button class="btn" id="cat-modal-cancel">Cancel</button> - <button class="btn btn-primary" id="cat-modal-submit">Save</button> - </div> - </div> - </div> - <!-- Event modal --> <div class="modal-overlay" id="event-modal-overlay"> <div class="modal event-modal" id="event-modal"> @@ -195,8 +159,8 @@ </div> <div class="form-group"> - <label class="form-label">Category</label> - <select class="form-input" id="ev-category"></select> + <label class="form-label">Notes</label> + <textarea class="form-input" id="ev-description" placeholder="Optional notes…"></textarea> </div> <div class="form-group" id="ev-attendees-group" style="display:none"> diff --git a/static/style.css b/static/style.css @@ -78,6 +78,9 @@ body { flex: 1; min-height: 0; overflow: hidden; + max-width: 1600px; + width: 100%; + margin: 0 auto; } /* ── Sidebar ── */ @@ -199,8 +202,7 @@ body { overflow: hidden; } .daily-open .monthly-view { - flex: 0 0 400px; - max-width: 400px; + flex: 1 1 auto; } .monthly-header { display: flex; @@ -209,6 +211,11 @@ body { padding: 1.25rem 1.5rem 1rem; flex-shrink: 0; } +.monthly-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} .monthly-title { font-family: 'EB Garamond', Georgia, serif; font-size: 1.5rem; @@ -275,6 +282,7 @@ body { border-color: var(--polar4); } .day-cell.other-month { opacity: 0.3; } +.day-cell.weekend { background: rgba(255,255,255,0.025); } .day-cell.today .day-number { background: var(--lightblue); color: var(--polar1); @@ -315,7 +323,8 @@ body { border-color 0.35s; } .daily-open .daily-view { - flex: 1 1 auto; + flex: 0 0 520px; + max-width: 520px; opacity: 1; border-color: var(--polar3); } @@ -488,6 +497,12 @@ body { .color-swatch:hover { transform: scale(1.1); } .color-swatch.selected { border-color: var(--snow3); } +textarea.form-input { + resize: vertical; + min-height: 72px; + line-height: 1.5; +} + .invite-list { display: flex; flex-direction: column; gap: 0.4rem; } .invite-row { display: flex; gap: 0.4rem; } .invite-row .form-input { flex: 1; }