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 | +++++ |
| M | app/main.py | | | 186 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | app/models.py | | | 43 | ++++++++++++++++++++++++++++++++++++++++++- |
| M | app/schemas.py | | | 43 | +++++++++++++++++++++++++++++++++++++++++++ |
| M | static/app.js | | | 718 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- |
| M | static/index.html | | | 132 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
| M | static/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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+}
+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; }
}