calendar

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

commit 80241ab49fd0fac9457a7ce7acd3d59d83717c7a
parent f587bebee51ad5cf2caeb292aa2356221d1ae0af
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Fri,  1 May 2026 20:55:37 +0000

Add events, categories, collapsible sidebar, Hub topbar link

Backend:
- Category model: per-user, name + color + visibility toggle
- Event model: title, start/end datetime, calendar, category, attendees
- Full CRUD for categories and events
- Events query filtered by user calendar membership

Frontend:
- Sidebar collapsible via hamburger toggle (collapsed by default on mobile)
- Hub back link in topbar (text + home icon)
- Categories section in sidebar below calendars, with visibility toggle
- Right-click context menu for category: edit / delete
- Event dots on monthly grid (colored by calendar, up to 3 + overflow count)
- Daily view: 60px/hour time grid with absolute-positioned event blocks
- Clicking a time slot pre-fills start time in event modal
- Event modal: title, calendar, start/end date+time, category, attendees
- Clicking an event block opens edit modal with delete button
- Event visibility filtered by calendar + category toggles

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

Diffstat:
A.gitignore | 5+++++
Mapp/main.py | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/models.py | 43++++++++++++++++++++++++++++++++++++++++++-
Mapp/schemas.py | 43+++++++++++++++++++++++++++++++++++++++++++
Mstatic/app.js | 718+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mstatic/index.html | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mstatic/style.css | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
7 files changed, 1071 insertions(+), 226 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,5 @@ +venv/ +app/__pycache__/ +*.pyc +*.db +.env diff --git a/app/main.py b/app/main.py @@ -3,11 +3,15 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError +from datetime import datetime import os from .database import get_db, engine -from .models import Base, Calendar, CalendarMembership, PendingInvitation -from .schemas import CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest +from .models import Base, Calendar, CalendarMembership, PendingInvitation, Category, Event, EventAttendee +from .schemas import ( + CalendarCreate, CalendarUpdate, VisibilityUpdate, InviteRequest, + CategoryCreate, CategoryUpdate, EventCreate, EventUpdate, +) Base.metadata.create_all(bind=engine) @@ -283,3 +287,181 @@ def leave_calendar(calendar_id: int, request: Request, db: Session = Depends(get if m: db.delete(m) 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: + try: + return datetime.fromisoformat(s) + except ValueError: + raise HTTPException(status_code=422, detail=f"Invalid datetime: {s!r}") + + +@app.get("/api/events") +def list_events(year: int, month: int, request: Request, db: Session = Depends(get_db)): + username, _ = _get_identity(request) + memberships = db.query(CalendarMembership).filter(CalendarMembership.username == username).all() + cal_ids = [m.calendar_id for m in memberships] + + start = datetime(year, month, 1) + end = datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1) + + events = ( + db.query(Event) + .filter(Event.calendar_id.in_(cal_ids), Event.start_dt < end, Event.end_dt >= start) + .all() + ) + return [ + { + "id": e.id, + "calendar_id": e.calendar_id, + "title": e.title, + "start_dt": e.start_dt.isoformat(), + "end_dt": e.end_dt.isoformat(), + "category_id": e.category_id, + "attendees": [a.username for a in e.attendees], + } + for e in events + ] + + +@app.post("/api/events", status_code=201) +def create_event(body: EventCreate, request: Request, db: Session = Depends(get_db)): + username, _ = _get_identity(request) + _require_member(db, body.calendar_id, username) + start = _parse_dt(body.start_dt) + end = _parse_dt(body.end_dt) + if end <= start: + raise HTTPException(status_code=422, detail="end_dt must be after start_dt") + + ev = Event( + calendar_id=body.calendar_id, + title=body.title, + start_dt=start, + end_dt=end, + category_id=body.category_id, + ) + db.add(ev) + db.flush() + for uname in body.attendee_usernames: + db.add(EventAttendee(event_id=ev.id, username=uname)) + db.commit() + db.refresh(ev) + return { + "id": ev.id, + "calendar_id": ev.calendar_id, + "title": ev.title, + "start_dt": ev.start_dt.isoformat(), + "end_dt": ev.end_dt.isoformat(), + "category_id": ev.category_id, + "attendees": [a.username for a in ev.attendees], + } + + +@app.patch("/api/events/{event_id}") +def update_event(event_id: int, body: EventUpdate, request: Request, db: Session = Depends(get_db)): + username, _ = _get_identity(request) + ev = db.query(Event).filter(Event.id == event_id).first() + if not ev: + raise HTTPException(status_code=404) + _require_member(db, ev.calendar_id, username) + + if body.title is not None: + ev.title = body.title + if body.start_dt is not None: + 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.attendee_usernames is not None: + for a in ev.attendees: + db.delete(a) + db.flush() + for uname in body.attendee_usernames: + db.add(EventAttendee(event_id=ev.id, username=uname)) + db.commit() + db.refresh(ev) + return { + "id": ev.id, + "calendar_id": ev.calendar_id, + "title": ev.title, + "start_dt": ev.start_dt.isoformat(), + "end_dt": ev.end_dt.isoformat(), + "category_id": ev.category_id, + "attendees": [a.username for a in ev.attendees], + } + + +@app.delete("/api/events/{event_id}", status_code=204) +def delete_event(event_id: int, request: Request, db: Session = Depends(get_db)): + username, _ = _get_identity(request) + ev = db.query(Event).filter(Event.id == event_id).first() + if not ev: + raise HTTPException(status_code=404) + _require_member(db, ev.calendar_id, username) + db.delete(ev) + db.commit() diff --git a/app/models.py b/app/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, UniqueConstraint +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, UniqueConstraint, DateTime from sqlalchemy.orm import relationship from .database import Base @@ -43,3 +43,44 @@ class PendingInvitation(Base): __table_args__ = (UniqueConstraint("calendar_id", "invitee_email"),) 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" + + id = Column(Integer, primary_key=True) + calendar_id = Column(Integer, ForeignKey("calendars.id"), nullable=False) + 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) + + calendar = relationship("Calendar") + category = relationship("Category") + attendees = relationship( + "EventAttendee", back_populates="event", cascade="all, delete-orphan" + ) + + +class EventAttendee(Base): + __tablename__ = "event_attendees" + + id = Column(Integer, primary_key=True) + event_id = Column(Integer, ForeignKey("events.id"), nullable=False) + username = Column(String, nullable=False) + + __table_args__ = (UniqueConstraint("event_id", "username"),) + + event = relationship("Event", back_populates="attendees") diff --git a/app/schemas.py b/app/schemas.py @@ -33,3 +33,46 @@ class VisibilityUpdate(BaseModel): 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 + attendee_usernames: list[str] = [] + + +class EventUpdate(BaseModel): + title: str | None = None + start_dt: str | None = None + end_dt: str | None = None + category_id: int | None = None + attendee_usernames: list[str] | None = None diff --git a/static/app.js b/static/app.js @@ -5,8 +5,10 @@ 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 + month: new Date().getMonth(), // 0-based selectedDay: null, }; @@ -15,12 +17,13 @@ const COLORS = [ '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A', '#B48EAD', ]; -const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; +const HOUR_PX = 60; // pixels per hour in the daily view + // ── API ─────────────────────────────────────────────────────────────────────── async function api(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; @@ -39,85 +42,163 @@ async function init() { const me = await api('GET', '/api/me'); state.username = me.username; state.email = me.email; - document.getElementById('username-display').textContent = me.username; - await loadCalendars(); + await Promise.all([loadCalendars(), loadCategories()]); + await fetchEvents(); renderMonthly(); + + // Collapse sidebar on mobile by default + if (window.innerWidth <= 700) { + document.querySelector('.sidebar').classList.add('collapsed'); + } } +// ── Data loading ────────────────────────────────────────────────────────────── async function loadCalendars() { state.calendars = await api('GET', '/api/calendars'); renderSidebar(); } -// ── Sidebar ─────────────────────────────────────────────────────────────────── +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); +} + +function indexEvents(arr) { + state.events = {}; + arr.forEach(ev => { + const start = new Date(ev.start_dt); + const end = new Date(ev.end_dt); + const d = new Date(start); d.setHours(0, 0, 0, 0); + const last = new Date(end); last.setHours(0, 0, 0, 0); + while (d <= last) { + const k = dateKey(d); + (state.events[k] = state.events[k] || []).push(ev); + d.setDate(d.getDate() + 1); + } + }); +} + +function dateKey(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +// ── Sidebar toggle ──────────────────────────────────────────────────────────── +document.getElementById('btn-sidebar-toggle').addEventListener('click', () => { + document.querySelector('.sidebar').classList.toggle('collapsed'); +}); + +// ── Sidebar: Calendars ──────────────────────────────────────────────────────── function renderSidebar() { const list = document.getElementById('calendar-list'); list.innerHTML = ''; - state.calendars.forEach(cal => { - const item = document.createElement('div'); - item.className = 'calendar-item'; - item.dataset.id = cal.id; - - const dot = document.createElement('span'); - dot.className = 'cal-dot'; - dot.style.background = cal.color; - - const name = document.createElement('span'); - name.className = 'cal-name'; - name.textContent = cal.name; - - const toggle = document.createElement('button'); - toggle.className = 'cal-toggle' + (cal.is_visible ? ' visible' : ''); - toggle.title = cal.is_visible ? 'Hide' : 'Show'; - toggle.addEventListener('click', e => { - e.stopPropagation(); - toggleVisibility(cal); - }); + list.appendChild(makeCalendarItem(cal)); + }); +} - item.append(dot, name, toggle); - item.addEventListener('contextmenu', e => { - e.preventDefault(); - showContextMenu(e, cal); - }); +function makeCalendarItem(cal) { + const item = document.createElement('div'); + item.className = 'calendar-item'; + item.dataset.id = cal.id; - list.appendChild(item); - }); + const dot = document.createElement('span'); + dot.className = 'cal-dot'; + dot.style.background = cal.color; + + const name = document.createElement('span'); + name.className = 'cal-name'; + name.textContent = cal.name; + + const toggle = document.createElement('button'); + toggle.className = 'cal-toggle' + (cal.is_visible ? ' visible' : ''); + toggle.title = cal.is_visible ? 'Hide' : 'Show'; + toggle.addEventListener('click', e => { e.stopPropagation(); toggleCalVisibility(cal); }); + + item.append(dot, name, toggle); + item.addEventListener('contextmenu', e => { e.preventDefault(); showContextMenu(e, cal, 'calendar'); }); + return item; } -async function toggleVisibility(cal) { - const newVal = !cal.is_visible; - await api('PATCH', `/api/calendars/${cal.id}/visibility`, { is_visible: newVal }); - cal.is_visible = newVal; +async function toggleCalVisibility(cal) { + await api('PATCH', `/api/calendars/${cal.id}/visibility`, { is_visible: !cal.is_visible }); + cal.is_visible = !cal.is_visible; renderSidebar(); renderMonthly(); + 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'); -let ctxCalendar = null; -function showContextMenu(e, cal) { - ctxCalendar = cal; +function showContextMenu(e, item, type) { ctxMenu.innerHTML = ''; - if (cal.is_owner && !cal.is_personal) { - addCtxItem('Edit', () => openEditModal(cal)); - addCtxItem('Invite', () => openInviteModal(cal)); - addCtxItem('Delete', () => deleteCalendar(cal), true); - } else if (!cal.is_personal) { - addCtxItem('Leave calendar', () => leaveCalendar(cal), true); - } - if (cal.is_personal) { - addCtxItem('Edit name / color', () => openEditModal(cal)); + 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)); + } + } else { + addCtxItem('Edit', () => openCategoryModal(item)); + addCtxItem('Delete', () => deleteCategoryItem(item), true); } + if (!ctxMenu.children.length) return; const x = Math.min(e.clientX, window.innerWidth - 180); const y = Math.min(e.clientY, window.innerHeight - ctxMenu.scrollHeight - 10); ctxMenu.style.left = x + 'px'; - ctxMenu.style.top = y + 'px'; + ctxMenu.style.top = y + 'px'; ctxMenu.classList.add('open'); } @@ -132,63 +213,77 @@ function addCtxItem(label, action, danger = false) { function closeCtxMenu() { ctxMenu.classList.remove('open'); } document.addEventListener('click', closeCtxMenu); -document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeCtxMenu(); closeModal(); closeDailyView(); } }); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { closeCtxMenu(); closeCalModal(); closeEventModal(); closeCategoryModal(); closeDailyView(); } +}); -// ── Monthly calendar ────────────────────────────────────────────────────────── +// ── Monthly view ────────────────────────────────────────────────────────────── function renderMonthly() { - document.getElementById('month-title').textContent = - `${MONTHS[state.month]} ${state.year}`; + document.getElementById('month-title').textContent = `${MONTHS[state.month]} ${state.year}`; const grid = document.getElementById('day-grid'); grid.innerHTML = ''; const firstDay = new Date(state.year, state.month, 1); - // Weekday of first day (0=Sun..6=Sat → convert to Mon-first: 0=Mon..6=Sun) - let startDow = firstDay.getDay(); // 0=Sun + let startDow = firstDay.getDay(); startDow = startDow === 0 ? 6 : startDow - 1; const daysInMonth = new Date(state.year, state.month + 1, 0).getDate(); - const daysInPrev = new Date(state.year, state.month, 0).getDate(); - - const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7; - - const today = new Date(); + const daysInPrev = new Date(state.year, state.month, 0).getDate(); + const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7; + const today = new Date(); for (let i = 0; i < totalCells; i++) { - let day, isCurrentMonth = true; + let day, isCurrent = true; if (i < startDow) { - day = daysInPrev - startDow + 1 + i; - isCurrentMonth = false; + day = daysInPrev - startDow + 1 + i; isCurrent = false; } else if (i >= startDow + daysInMonth) { - day = i - startDow - daysInMonth + 1; - isCurrentMonth = false; + day = i - startDow - daysInMonth + 1; isCurrent = false; } else { day = i - startDow + 1; } const cell = document.createElement('div'); - cell.className = 'day-cell'; - if (!isCurrentMonth) cell.classList.add('other-month'); + cell.className = 'day-cell' + (!isCurrent ? ' other-month' : ''); - const isToday = isCurrentMonth && - day === today.getDate() && - state.month === today.getMonth() && - state.year === today.getFullYear(); - if (isToday) cell.classList.add('today'); - - if (isCurrentMonth && state.selectedDay === day) { - cell.classList.add('selected'); - } + const isToday = isCurrent && day === today.getDate() && + state.month === today.getMonth() && state.year === today.getFullYear(); + if (isToday) cell.classList.add('today'); + if (isCurrent && state.selectedDay === day) cell.classList.add('selected'); const num = document.createElement('div'); num.className = 'day-number'; num.textContent = day; cell.appendChild(num); - if (isCurrentMonth) { - cell.addEventListener('click', () => selectDay(day)); + // Event dots + 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 => { + const cal = state.calendars.find(c => c.id === ev.calendar_id); + return cal && cal.is_visible; + }); + if (dayEvs.length) { + const dots = document.createElement('div'); + dots.className = 'event-dots'; + dayEvs.slice(0, 3).forEach(ev => { + const cal = state.calendars.find(c => c.id === ev.calendar_id); + const d = document.createElement('span'); + d.className = 'event-dot'; + d.style.background = cal ? cal.color : '#88C0D0'; + dots.appendChild(d); + }); + if (dayEvs.length > 3) { + const more = document.createElement('span'); + more.className = 'event-dot-more'; + more.textContent = '+' + (dayEvs.length - 3); + dots.appendChild(more); + } + cell.appendChild(dots); + } } + if (isCurrent) cell.addEventListener('click', () => selectDay(day)); grid.appendChild(cell); } } @@ -200,31 +295,29 @@ function selectDay(day) { } function prevMonth() { - if (state.month === 0) { state.month = 11; state.year--; } - else state.month--; + if (state.month === 0) { state.month = 11; state.year--; } else state.month--; state.selectedDay = null; - renderMonthly(); + fetchEvents().then(renderMonthly); closeDailyView(); } function nextMonth() { - if (state.month === 11) { state.month = 0; state.year++; } - else state.month++; + if (state.month === 11) { state.month = 0; state.year++; } else state.month++; state.selectedDay = null; - renderMonthly(); + fetchEvents().then(renderMonthly); closeDailyView(); } +document.getElementById('prev-month').addEventListener('click', prevMonth); +document.getElementById('next-month').addEventListener('click', nextMonth); + // ── Daily view ──────────────────────────────────────────────────────────────── function openDailyView(day) { - const date = new Date(state.year, state.month, day); - const label = date.toLocaleDateString('en-GB', { + 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 = label; - renderTimeGrid(); - document.querySelector('.calendar-main').classList.add('daily-open'); } @@ -234,86 +327,141 @@ function closeDailyView() { renderMonthly(); } +document.getElementById('btn-close-daily').addEventListener('click', closeDailyView); + function renderTimeGrid() { const grid = document.getElementById('time-grid'); grid.innerHTML = ''; + + const inner = document.createElement('div'); + inner.className = 'time-grid-inner'; + + const hours = document.createElement('div'); + hours.className = 'time-hours'; + for (let h = 6; h <= 22; h++) { const row = document.createElement('div'); row.className = 'time-row'; const label = document.createElement('div'); label.className = 'time-label'; - label.textContent = h.toString().padStart(2, '0') + ':00'; + label.textContent = String(h).padStart(2, '0') + ':00'; const slot = document.createElement('div'); slot.className = 'time-slot'; + slot.dataset.hour = h; + slot.addEventListener('click', () => { + if (state.selectedDay) openEventModal(new Date(state.year, state.month, state.selectedDay, h, 0)); + }); row.append(label, slot); - grid.appendChild(row); + hours.appendChild(row); } -} -// ── Create calendar modal ───────────────────────────────────────────────────── -const modal = document.getElementById('modal'); -const modalOverlay = document.getElementById('modal-overlay'); -let modalMode = 'create'; // 'create' | 'edit' | 'invite' -let modalCalendar = null; -let selectedColor = COLORS[0]; -let inviteEmails = ['']; + const overlay = document.createElement('div'); + overlay.className = 'events-overlay'; + overlay.id = 'events-overlay'; -function openCreateModal() { - modalMode = 'create'; - modalCalendar = null; - selectedColor = COLORS[0]; - inviteEmails = ['']; - renderModal(); - modalOverlay.classList.add('open'); + inner.append(hours, overlay); + grid.appendChild(inner); + renderDailyEvents(); } -function openEditModal(cal) { - modalMode = 'edit'; - modalCalendar = cal; - selectedColor = cal.color; - renderModal(); - modalOverlay.classList.add('open'); -} +function renderDailyEvents() { + const overlay = document.getElementById('events-overlay'); + if (!overlay || !state.selectedDay) return; + overlay.innerHTML = ''; + + 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; + }); -function openInviteModal(cal) { - modalMode = 'invite'; - modalCalendar = cal; - inviteEmails = ['']; - renderModal(); - modalOverlay.classList.add('open'); + 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 => { + 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 startMin = (clamped_start.getHours() - 6) * 60 + clamped_start.getMinutes(); + const durMin = (clamped_end - clamped_start) / 60000; + const cal = state.calendars.find(c => c.id === ev.calendar_id); + const color = cal ? cal.color : '#88C0D0'; + + const block = document.createElement('div'); + block.className = 'event-block'; + block.style.top = startMin + 'px'; + block.style.height = Math.max(durMin, 20) + 'px'; + block.style.background = color + '22'; + block.style.borderLeftColor = color; + block.style.color = color; + block.textContent = ev.title; + block.title = ev.title; + block.addEventListener('click', e => { e.stopPropagation(); openEventModal(null, ev); }); + overlay.appendChild(block); + }); } -function closeModal() { modalOverlay.classList.remove('open'); } +document.getElementById('btn-add-event').addEventListener('click', () => { + if (state.selectedDay) openEventModal(new Date(state.year, state.month, state.selectedDay, 9, 0)); +}); -modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeModal(); }); +// ── Calendar modal (create / edit / invite) ─────────────────────────────────── +const calModalOverlay = document.getElementById('modal-overlay'); +const calModal = document.getElementById('modal'); +let calModalMode = 'create'; +let calModalTarget = null; +let selectedColor = COLORS[0]; +let inviteEmails = ['']; + +function openCalModal(mode, target = null) { + calModalMode = mode; + calModalTarget = target; + selectedColor = target ? target.color : COLORS[0]; + inviteEmails = ['']; + renderCalModal(); + calModalOverlay.classList.add('open'); +} -function renderModal() { - const title = { create: 'New calendar', edit: 'Edit calendar', invite: 'Invite members' }[modalMode]; - modal.querySelector('h2').textContent = title; +function closeCalModal() { calModalOverlay.classList.remove('open'); } - const body = modal.querySelector('.modal-body'); - body.innerHTML = ''; +calModalOverlay.addEventListener('click', e => { if (e.target === calModalOverlay) closeCalModal(); }); +document.getElementById('modal-cancel').addEventListener('click', closeCalModal); +document.getElementById('modal-submit').addEventListener('click', submitCalModal); - if (modalMode === 'create' || modalMode === 'edit') { - // Name - const nameGroup = makeFormGroup('Name', - `<input class="form-input" id="cal-name-input" type="text" placeholder="Calendar name" - value="${modalMode === 'edit' ? escHtml(modalCalendar.name) : ''}">` - ); - body.appendChild(nameGroup); +function renderCalModal() { + const titles = { create: 'New calendar', edit: 'Edit calendar', invite: 'Invite members' }; + calModal.querySelector('h2').textContent = titles[calModalMode]; + const body = calModal.querySelector('.modal-body'); + body.innerHTML = ''; - // Color - const colorGroup = makeFormGroup('Color', ''); + 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" + 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>'; const swatches = document.createElement('div'); swatches.className = 'color-swatches'; COLORS.forEach(c => { const sw = document.createElement('div'); sw.className = 'color-swatch' + (c === selectedColor ? ' selected' : ''); sw.style.background = c; - sw.title = c; sw.addEventListener('click', () => { selectedColor = c; swatches.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected')); @@ -321,27 +469,16 @@ function renderModal() { }); swatches.appendChild(sw); }); - colorGroup.appendChild(swatches); - body.appendChild(colorGroup); - } - - if (modalMode === 'create') { - body.appendChild(makeInviteSection()); + cg.appendChild(swatches); + body.appendChild(cg); } - if (modalMode === 'invite') { - body.appendChild(makeInviteSection()); + if (calModalMode === 'create' || calModalMode === 'invite') { + body.appendChild(buildInviteSection()); } } -function makeFormGroup(label, innerHtml) { - const g = document.createElement('div'); - g.className = 'form-group'; - g.innerHTML = `<label class="form-label">${label}</label>${innerHtml}`; - return g; -} - -function makeInviteSection() { +function buildInviteSection() { const g = document.createElement('div'); g.className = 'form-group'; g.innerHTML = '<label class="form-label">Invite by email</label>'; @@ -359,61 +496,43 @@ function renderInviteRows(list) { 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.type = 'email'; inp.className = 'form-input'; + inp.placeholder = 'user@example.com'; inp.value = email; inp.addEventListener('input', () => { inviteEmails[i] = inp.value; }); - const addBtn = document.createElement('button'); - addBtn.className = 'btn-icon'; - addBtn.title = 'Add another'; - addBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>'; - addBtn.addEventListener('click', () => { - inviteEmails.push(''); - renderInviteRows(list); - }); + const add = document.createElement('button'); + add.className = 'btn-icon'; + add.innerHTML = svgPlus(); + add.addEventListener('click', () => { inviteEmails.push(''); renderInviteRows(list); }); - const removeBtn = document.createElement('button'); - removeBtn.className = 'btn-icon danger'; - removeBtn.title = 'Remove'; - removeBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M5 12h14" stroke-linecap="round"/></svg>'; - removeBtn.addEventListener('click', () => { - if (inviteEmails.length > 1) { - inviteEmails.splice(i, 1); - renderInviteRows(list); - } - }); + 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); } }); - row.append(inp, addBtn, removeBtn); + row.append(inp, add, rem); list.appendChild(row); }); } -async function submitModal() { +async function submitCalModal() { try { - if (modalMode === 'create') { + 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 }); - } else if (modalMode === 'edit') { + } else if (calModalMode === 'edit') { const name = document.getElementById('cal-name-input')?.value?.trim(); - await api('PATCH', `/api/calendars/${modalCalendar.id}`, { - name: name || undefined, - color: selectedColor, - }); - } else if (modalMode === 'invite') { - const emails = inviteEmails.map(e => e.trim()).filter(Boolean); - for (const email of emails) { - await api('POST', `/api/calendars/${modalCalendar.id}/invite`, { email }).catch(() => {}); + 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(() => {}); } } - closeModal(); + closeCalModal(); await loadCalendars(); - } catch (err) { - alert(err.message); - } + } catch (err) { alert(err.message); } } async function deleteCalendar(cal) { @@ -428,18 +547,217 @@ async function leaveCalendar(cal) { await loadCalendars(); } -// ── Helpers ─────────────────────────────────────────────────────────────────── -function escHtml(str) { - return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); +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'); } -// ── Wire up global buttons ──────────────────────────────────────────────────── -document.getElementById('prev-month').addEventListener('click', prevMonth); -document.getElementById('next-month').addEventListener('click', nextMonth); -document.getElementById('btn-new-cal').addEventListener('click', openCreateModal); -document.getElementById('modal-cancel').addEventListener('click', closeModal); -document.getElementById('modal-submit').addEventListener('click', submitModal); -document.getElementById('btn-close-daily').addEventListener('click', closeDailyView); +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; + +function openEventModal(prefillDate, existingEvent = null) { + editingEvent = existingEvent; + const modal = document.getElementById('event-modal'); + modal.querySelector('h2').textContent = existingEvent ? 'Edit event' : 'New event'; + + // Calendar select + const calSel = document.getElementById('ev-calendar'); + calSel.innerHTML = ''; + state.calendars.forEach(cal => { + const opt = document.createElement('option'); + opt.value = cal.id; + opt.textContent = cal.name; + if (existingEvent && existingEvent.calendar_id === cal.id) opt.selected = true; + calSel.appendChild(opt); + }); + + // Dates & times + 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); + + document.getElementById('ev-start-date').value = localDateStr(d); + document.getElementById('ev-start-time').value = localTimeStr(d); + 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); + }); + + 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'); +} + +function updateEventAttendees(existingEvent) { + const calId = parseInt(document.getElementById('ev-calendar').value); + const cal = state.calendars.find(c => c.id === calId); + const group = document.getElementById('ev-attendees-group'); + const cont = document.getElementById('ev-attendees'); + cont.innerHTML = ''; + + if (!cal || cal.members.length <= 1) { group.style.display = 'none'; return; } + group.style.display = 'block'; + + const list = document.createElement('div'); + list.className = 'attendee-list'; + cal.members.forEach(uname => { + const label = document.createElement('label'); + label.className = 'attendee-item'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; cb.value = uname; + cb.checked = existingEvent ? existingEvent.attendees.includes(uname) : true; + label.append(cb, document.createTextNode(uname)); + list.appendChild(label); + }); + cont.appendChild(list); +} + +function closeEventModal() { evModalOverlay.classList.remove('open'); editingEvent = null; } + +evModalOverlay.addEventListener('click', e => { if (e.target === evModalOverlay) closeEventModal(); }); +document.getElementById('event-modal-cancel').addEventListener('click', closeEventModal); + +document.getElementById('event-modal-submit').addEventListener('click', async () => { + const title = document.getElementById('ev-title').value.trim(); + const calId = parseInt(document.getElementById('ev-calendar').value); + const startD = document.getElementById('ev-start-date').value; + 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; + + 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`; + const endDt = `${endD}T${endT}:00`; + if (endDt <= startDt) return alert('End must be after start.'); + + const attendees = [...document.querySelectorAll('#ev-attendees input[type=checkbox]:checked')] + .map(cb => cb.value); + + const payload = { + calendar_id: calId, + title, + start_dt: startDt, + end_dt: endDt, + category_id: catId ? parseInt(catId) : null, + attendee_usernames: attendees, + }; + + try { + if (editingEvent) { + await api('PATCH', `/api/events/${editingEvent.id}`, payload); + } else { + await api('POST', '/api/events', payload); + } + closeEventModal(); + await fetchEvents(); + renderMonthly(); + if (state.selectedDay) renderDailyEvents(); + } catch (err) { alert(err.message); } +}); + +document.getElementById('ev-delete-btn').addEventListener('click', async () => { + if (!editingEvent) return; + if (!confirm(`Delete "${editingEvent.title}"?`)) return; + await api('DELETE', `/api/events/${editingEvent.id}`); + closeEventModal(); + await fetchEvents(); + renderMonthly(); + if (state.selectedDay) renderDailyEvents(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function localDateStr(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} +function localTimeStr(d) { + return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; +} +function escHtml(s) { + return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); +} +function svgPlus() { + return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>'; +} +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>'; +} // ── Start ───────────────────────────────────────────────────────────────────── init().catch(err => console.error('Init failed:', err)); diff --git a/static/index.html b/static/index.html @@ -14,7 +14,13 @@ <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> <div class="topbar-right"> <span class="nav-link" id="username-display"></span> @@ -33,16 +39,35 @@ <!-- Sidebar --> <aside class="sidebar"> - <div class="sidebar-header">Calendars</div> - <div class="calendar-list" id="calendar-list"></div> - <div class="sidebar-actions"> - <button class="btn-new-cal" id="btn-new-cal"> - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - <path d="M12 5v14M5 12h14" stroke-linecap="round"/> - </svg> - New calendar - </button> + + <div 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> + </aside> <!-- Calendar main --> @@ -83,11 +108,14 @@ <section class="daily-view" id="daily-view"> <div class="daily-header"> <h3 class="daily-title" id="daily-title"></h3> - <button class="btn-close" id="btn-close-daily" title="Close"> - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - <path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/> - </svg> - </button> + <div 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"> + <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="time-grid" id="time-grid"></div> </section> @@ -98,7 +126,7 @@ <!-- Context menu --> <div class="ctx-menu" id="ctx-menu"></div> - <!-- Modal --> + <!-- Calendar modal (create / edit / invite) --> <div class="modal-overlay" id="modal-overlay"> <div class="modal" id="modal"> <h2></h2> @@ -110,6 +138,80 @@ </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"> + <h2>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"> + </div> + + <div class="form-group"> + <label class="form-label">Calendar</label> + <select class="form-input" id="ev-calendar"></select> + </div> + + <div class="form-row"> + <div class="form-group"> + <label class="form-label">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> + <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> + <input class="form-input" id="ev-end-date" type="date"> + </div> + <div class="form-group"> + <label class="form-label">End time</label> + <input class="form-input" id="ev-end-time" type="time"> + </div> + </div> + + <div class="form-group"> + <label class="form-label">Category</label> + <select class="form-input" id="ev-category"></select> + </div> + + <div class="form-group" id="ev-attendees-group" style="display:none"> + <label class="form-label">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> + </div> + </div> + </div> + <script src="/static/app.js"></script> </body> </html> diff --git a/static/style.css b/static/style.css @@ -353,14 +353,15 @@ body { .time-grid { flex: 1; overflow-y: auto; - padding: 0.5rem 0; + padding: 0; +} +.time-grid-inner { + position: relative; } .time-row { display: flex; align-items: flex-start; - gap: 0; - min-height: 48px; - position: relative; + height: 60px; } .time-label { width: 56px; @@ -370,16 +371,44 @@ body { opacity: 0.35; text-align: right; padding-right: 0.75rem; - padding-top: 0.1rem; + padding-top: 0.25rem; line-height: 1; } .time-slot { flex: 1; border-top: 1px solid var(--polar3); - min-height: 48px; + height: 100%; margin-right: 1rem; - position: relative; + cursor: pointer; + transition: background 0.1s; } +.time-slot:hover { background: rgba(136,192,208,0.05); } + +.events-overlay { + position: absolute; + top: 0; + left: 56px; + right: 1rem; + bottom: 0; + pointer-events: none; +} +.event-block { + position: absolute; + left: 4px; + right: 0; + 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; + white-space: nowrap; + text-overflow: ellipsis; +} +.event-block:hover { filter: brightness(1.15); } /* ── Modal ── */ .modal-overlay { @@ -540,8 +569,133 @@ body { ::-webkit-scrollbar-thumb { background: var(--polar4); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--snow1); opacity: 0.3; } +/* ── Topbar back link ── */ +.topbar-back { + font-size: 0.8rem; + color: var(--snow1); + opacity: 0.4; + text-decoration: none; + letter-spacing: 0.02em; + transition: opacity 0.15s; +} +.topbar-back:hover { opacity: 0.8; } + +/* ── Sidebar toggle ── */ +.sidebar { + transition: width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.sidebar.collapsed { + width: 0 !important; + overflow: hidden; +} +.btn-sidebar-toggle { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--polar3); + background: transparent; + color: var(--snow1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.15s, color 0.15s; +} +.btn-sidebar-toggle:hover { border-color: var(--lightblue); color: var(--lightblue); } +.btn-sidebar-toggle svg { width: 14px; height: 14px; } + +/* ── Sidebar sections ── */ +.sidebar-section { display: flex; flex-direction: column; } +.sidebar-divider { + height: 1px; + background: var(--polar3); + margin: 0.25rem 0.75rem; + flex-shrink: 0; +} + +/* ── Daily header with add-event button ── */ +.daily-header-right { + display: flex; + align-items: center; + gap: 0.5rem; +} +.btn-add-event { + padding: 0.3rem 0.7rem; + border-radius: 6px; + border: 1px solid var(--polar3); + background: transparent; + color: var(--snow1); + font-family: inherit; + font-size: 0.78rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + white-space: nowrap; +} +.btn-add-event:hover { border-color: var(--lightblue); color: var(--lightblue); } + +/* ── Event dots (monthly) ── */ +.event-dots { + display: flex; + gap: 2px; + flex-wrap: wrap; + padding: 2px 2px 0; +} +.event-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; +} +.event-dot-more { + font-size: 0.55rem; + color: var(--snow1); + opacity: 0.45; + line-height: 1; + align-self: center; +} + +/* ── Event modal ── */ +.event-modal { width: 480px; } +.form-row { + display: flex; + gap: 0.75rem; + margin-bottom: 1.1rem; +} +.form-row .form-group { flex: 1; margin-bottom: 0; } + +select.form-input { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23D8DEE9' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + cursor: pointer; +} +select.form-input option { background: var(--polar2); color: var(--snow1); } + +.attendee-list { display: flex; flex-direction: column; gap: 0.3rem; } +.attendee-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--snow1); + cursor: pointer; +} +.attendee-item input[type="checkbox"] { accent-color: var(--lightblue); cursor: pointer; } + +/* ── Responsive ── */ +.btn-danger { + background: transparent; + border-color: var(--red); + color: var(--red); +} +.btn-danger:hover { background: rgba(191,97,106,0.15); } + /* ── Responsive ── */ @media (max-width: 700px) { - .sidebar { width: 180px; } + .sidebar { width: 0; overflow: hidden; } + .sidebar.collapsed { width: 0 !important; } + .sidebar:not(.collapsed) { width: 220px; } .daily-open .monthly-view { flex-basis: 0; max-width: 0; overflow: hidden; } }