From f029ed15447144a26f4c7ebb43af009e202b59f3 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 26 Mar 2026 11:20:48 +0100 Subject: [PATCH] initialer commit, Grundcode --- .gitignore | 7 + backend/__init__.py | 0 backend/auth.py | 66 +++ backend/caldav_client.py | 267 +++++++++++ backend/database.py | 24 + backend/main.py | 43 ++ backend/models.py | 65 +++ backend/routers/__init__.py | 0 backend/routers/auth_router.py | 73 +++ backend/routers/caldav_router.py | 375 +++++++++++++++ backend/routers/settings_router.py | 69 +++ backend/routers/users_router.py | 89 ++++ calendarr.service | 17 + frontend/css/app.css | 574 +++++++++++++++++++++++ frontend/index.html | 364 +++++++++++++++ frontend/js/api.js | 49 ++ frontend/js/app.js | 129 ++++++ frontend/js/calendar.js | 719 +++++++++++++++++++++++++++++ frontend/js/utils.js | 53 +++ frontend/js/views/agenda.js | 94 ++++ frontend/js/views/month.js | 121 +++++ frontend/js/views/week.js | 244 ++++++++++ install.sh | 53 +++ requirements.txt | 9 + start.sh | 26 ++ 25 files changed, 3530 insertions(+) create mode 100644 .gitignore create mode 100644 backend/__init__.py create mode 100644 backend/auth.py create mode 100644 backend/caldav_client.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/auth_router.py create mode 100644 backend/routers/caldav_router.py create mode 100644 backend/routers/settings_router.py create mode 100644 backend/routers/users_router.py create mode 100644 calendarr.service create mode 100644 frontend/css/app.css create mode 100644 frontend/index.html create mode 100644 frontend/js/api.js create mode 100644 frontend/js/app.js create mode 100644 frontend/js/calendar.js create mode 100644 frontend/js/utils.js create mode 100644 frontend/js/views/agenda.js create mode 100644 frontend/js/views/month.js create mode 100644 frontend/js/views/week.js create mode 100644 install.sh create mode 100644 requirements.txt create mode 100644 start.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8870d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ +data/ +__pycache__/ +*.pyc +.env +*.db +.DS_Store diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..94656ec --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,66 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +import os + +import models +from database import get_db + +SECRET_KEY = os.environ.get( + "SECRET_KEY", "insecure-default-key-change-in-production-use-env-var" +) +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + ( + expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode["exp"] = expire + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> models.User: + exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if not username: + raise exc + except JWTError: + raise exc + user = db.query(models.User).filter(models.User.username == username).first() + if not user: + raise exc + return user + + +def get_current_admin( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + return current_user diff --git a/backend/caldav_client.py b/backend/caldav_client.py new file mode 100644 index 0000000..23c9bdd --- /dev/null +++ b/backend/caldav_client.py @@ -0,0 +1,267 @@ +import logging +import uuid +from datetime import date, datetime, timedelta, timezone +from typing import Dict, List, Optional + +import caldav +from icalendar import Calendar, Event + +logger = logging.getLogger(__name__) + +PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"] + + +def _client(url: str, username: str, password: str) -> caldav.DAVClient: + return caldav.DAVClient(url=url, username=username, password=password) + + +def fetch_calendars(url: str, username: str, password: str) -> List[Dict]: + """Return list of {url, name, color} dicts for all calendars in the account.""" + try: + client = _client(url, username, password) + principal = client.principal() + calendars = principal.calendars() + except Exception as e: + raise ValueError(f"Cannot connect to CalDAV server: {e}") from e + + result = [] + for idx, cal in enumerate(calendars): + try: + try: + name = cal.get_properties([caldav.elements.dav.DisplayName()])[ + "{DAV:}displayname" + ] + except Exception: + name = str(cal.url).rstrip("/").split("/")[-1] or "Calendar" + + color = None + try: + props = cal.get_properties( + [caldav.elements.cdav.CalendarColor()] + ) + raw = props.get("{http://apple.com/ns/ical/}calendar-color", "") + if raw and len(raw) >= 7: + color = raw[:7] + except Exception: + pass + + result.append( + { + "url": str(cal.url), + "name": name or "Calendar", + "color": color or PALETTE[idx % len(PALETTE)], + } + ) + except Exception as exc: + logger.warning("Could not inspect calendar %s: %s", cal.url, exc) + result.append( + { + "url": str(cal.url), + "name": "Calendar", + "color": PALETTE[idx % len(PALETTE)], + } + ) + return result + + +def fetch_events( + url: str, + username: str, + password: str, + calendar_url: str, + start: datetime, + end: datetime, +) -> List[Dict]: + """Fetch events from a calendar within [start, end].""" + try: + client = _client(url, username, password) + cal = client.calendar(url=calendar_url) + resources = cal.date_search(start=start, end=end, expand=True) + except Exception as exc: + logger.error("Error fetching events from %s: %s", calendar_url, exc) + return [] + + events = [] + for resource in resources: + try: + parsed = _parse_ics(resource.data, str(resource.url)) + if parsed: + events.extend(parsed) + except Exception as exc: + logger.warning("Could not parse event %s: %s", resource.url, exc) + return events + + +def _parse_ics(raw: str, event_url: str) -> List[Dict]: + """Parse raw ICS data and return a list of event dicts.""" + cal = Calendar.from_ical(raw) + results = [] + for component in cal.walk(): + if component.name != "VEVENT": + continue + try: + uid = str(component.get("UID", uuid.uuid4())) + title = str(component.get("SUMMARY", "Untitled")) + location = str(component.get("LOCATION", "") or "") + description = str(component.get("DESCRIPTION", "") or "") + color = str(component.get("X-CALENDARR-COLOR", "") or "") + + dtstart_prop = component.get("DTSTART") + dtend_prop = component.get("DTEND") + duration_prop = component.get("DURATION") + + if dtstart_prop is None: + continue + + dtstart = dtstart_prop.dt + all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime) + + if all_day: + if dtend_prop: + dtend = dtend_prop.dt + elif duration_prop: + dtend = dtstart + duration_prop.dt + else: + dtend = dtstart + timedelta(days=1) + start_str = dtstart.isoformat() + end_str = dtend.isoformat() if isinstance(dtend, date) else dtstart.isoformat() + else: + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=timezone.utc) + if dtend_prop: + dtend = dtend_prop.dt + if isinstance(dtend, date) and not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time()).replace( + tzinfo=timezone.utc + ) + elif dtend.tzinfo is None: + dtend = dtend.replace(tzinfo=timezone.utc) + elif duration_prop: + dtend = dtstart + duration_prop.dt + else: + dtend = dtstart + timedelta(hours=1) + start_str = dtstart.isoformat() + end_str = dtend.isoformat() + + results.append( + { + "id": uid, + "url": event_url, + "title": title, + "start": start_str, + "end": end_str, + "allDay": all_day, + "location": location, + "description": description, + "color": color or None, + } + ) + except Exception as exc: + logger.warning("Skipping malformed VEVENT: %s", exc) + return results + + +def create_event( + url: str, + username: str, + password: str, + calendar_url: str, + data: Dict, +) -> str: + client = _client(url, username, password) + cal_obj = client.calendar(url=calendar_url) + + cal = Calendar() + cal.add("prodid", "-//Calendarr//EN") + cal.add("version", "2.0") + + event = Event() + uid = str(uuid.uuid4()) + event.add("uid", uid) + event.add("summary", data.get("title", "New Event")) + event.add("dtstamp", datetime.now(timezone.utc)) + + if data.get("allDay"): + start = date.fromisoformat(data["start"][:10]) + end_raw = data.get("end", data["start"])[:10] + end = date.fromisoformat(end_raw) + if end <= start: + end = start + timedelta(days=1) + event.add("dtstart", start) + event.add("dtend", end) + else: + start = _parse_dt(data["start"]) + end = _parse_dt(data["end"]) + event.add("dtstart", start) + event.add("dtend", end) + + if data.get("location"): + event.add("location", data["location"]) + if data.get("description"): + event.add("description", data["description"]) + if data.get("color"): + event.add("x-calendarr-color", data["color"]) + + cal.add_component(event) + cal_obj.save_event(cal.to_ical().decode("utf-8")) + return uid + + +def update_event( + url: str, username: str, password: str, event_url: str, data: Dict +): + client = _client(url, username, password) + resource = client.event(url=event_url) + raw = resource.data + + cal = Calendar.from_ical(raw) + new_cal = Calendar() + for key, val in cal.items(): + new_cal.add(key, val) + + for component in cal.walk(): + if component.name != "VEVENT": + if component.name != "VCALENDAR": + new_cal.add_component(component) + continue + + if "title" in data or "summary" in data: + component["SUMMARY"] = data.get("title", data.get("summary", "")) + + if "start" in data: + if data.get("allDay"): + component["DTSTART"] = date.fromisoformat(data["start"][:10]) + else: + component["DTSTART"] = _parse_dt(data["start"]) + + if "end" in data: + if data.get("allDay"): + component["DTEND"] = date.fromisoformat(data["end"][:10]) + else: + component["DTEND"] = _parse_dt(data["end"]) + + if "location" in data: + component["LOCATION"] = data["location"] + if "description" in data: + component["DESCRIPTION"] = data["description"] + if "color" in data: + component["X-CALENDARR-COLOR"] = data["color"] + + new_cal.add_component(component) + + resource.data = new_cal.to_ical().decode("utf-8") + resource.save() + + +def delete_event(url: str, username: str, password: str, event_url: str): + client = _client(url, username, password) + resource = client.event(url=event_url) + resource.delete() + + +def _parse_dt(s: str) -> datetime: + s = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..04cd28a --- /dev/null +++ b/backend/database.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from pathlib import Path +import os + +DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent.parent / "data")) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +DATABASE_URL = f"sqlite:///{DATA_DIR}/calendarr.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..729459a --- /dev/null +++ b/backend/main.py @@ -0,0 +1,43 @@ +import logging +import os +import sys +from pathlib import Path + +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +sys.path.insert(0, str(Path(__file__).parent)) + +from database import Base, engine +from routers import auth_router, caldav_router, settings_router, users_router + +logging.basicConfig(level=logging.INFO) + +# Create DB tables on startup +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) + +app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) +app.include_router(users_router.router, prefix="/api/users", tags=["users"]) +app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) +app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) + +FRONTEND_DIR = Path(__file__).parent.parent / "frontend" +app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") + + +@app.get("/{full_path:path}") +async def spa_fallback(full_path: str): + if full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="API endpoint not found") + index = FRONTEND_DIR / "index.html" + return FileResponse(str(index)) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 8080)) + host = os.environ.get("HOST", "0.0.0.0") + uvicorn.run(app, host=host, port=port) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..4e119fa --- /dev/null +++ b/backend/models.py @@ -0,0 +1,65 @@ +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey +from sqlalchemy.orm import relationship +from database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), unique=True, nullable=True) + password_hash = Column(String(255), nullable=False) + is_admin = Column(Boolean, default=False) + + caldav_accounts = relationship( + "CalDAVAccount", back_populates="user", cascade="all, delete-orphan" + ) + settings = relationship( + "UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan" + ) + + +class CalDAVAccount(Base): + __tablename__ = "caldav_accounts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + username = Column(String(100), nullable=False) + password = Column(String(255), nullable=False) + color = Column(String(7), default="#4285f4") + enabled = Column(Boolean, default=True) + + user = relationship("User", back_populates="caldav_accounts") + calendars = relationship( + "Calendar", back_populates="account", cascade="all, delete-orphan" + ) + + +class Calendar(Base): + __tablename__ = "calendars" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("caldav_accounts.id"), nullable=False) + cal_id = Column(String(500), nullable=False) + name = Column(String(100), nullable=False) + color = Column(String(7), nullable=True) + enabled = Column(Boolean, default=True) + + account = relationship("CalDAVAccount", back_populates="calendars") + + +class UserSettings(Base): + __tablename__ = "user_settings" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False) + default_view = Column(String(20), default="month") + primary_color = Column(String(7), default="#4285f4") + accent_color = Column(String(7), default="#ea4335") + today_color = Column(String(7), default="#4285f4") + dim_past_events = Column(Boolean, default=False) + + user = relationship("User", back_populates="settings") diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py new file mode 100644 index 0000000..ccd26dc --- /dev/null +++ b/backend/routers/auth_router.py @@ -0,0 +1,73 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import create_access_token, get_current_user, get_password_hash, verify_password +from database import get_db + +router = APIRouter() + + +class SetupRequest(BaseModel): + username: str + password: str + email: Optional[str] = None + + +def _user_dict(user: models.User) -> dict: + return {"id": user.id, "username": user.username, "is_admin": user.is_admin} + + +@router.get("/setup-required") +def setup_required(db: Session = Depends(get_db)): + return {"required": db.query(models.User).count() == 0} + + +@router.post("/setup") +def setup(req: SetupRequest, db: Session = Depends(get_db)): + if db.query(models.User).count() > 0: + raise HTTPException(400, "Setup already completed") + user = models.User( + username=req.username, + email=req.email, + password_hash=get_password_hash(req.password), + is_admin=True, + ) + db.add(user) + db.flush() + db.add(models.UserSettings(user_id=user.id)) + db.commit() + db.refresh(user) + token = create_access_token({"sub": user.username}) + return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} + + +@router.post("/token") +def login( + form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) +): + user = ( + db.query(models.User).filter(models.User.username == form_data.username).first() + ) + if not user or not verify_password(form_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = create_access_token({"sub": user.username}) + return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} + + +@router.get("/me") +def me(current_user: models.User = Depends(get_current_user)): + return { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "is_admin": current_user.is_admin, + } diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py new file mode 100644 index 0000000..94b1d32 --- /dev/null +++ b/backend/routers/caldav_router.py @@ -0,0 +1,375 @@ +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import caldav_client +import models +from auth import get_current_user +from database import get_db + +logger = logging.getLogger(__name__) +router = APIRouter() + +PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"] + + +class AccountCreate(BaseModel): + name: str + url: str + username: str + password: str + color: str = "#4285f4" + + +class CalendarUpdate(BaseModel): + enabled: Optional[bool] = None + color: Optional[str] = None + + +class EventCreate(BaseModel): + calendar_id: int + title: str + start: str + end: str + allDay: bool = False + location: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + + +class EventUpdate(BaseModel): + title: Optional[str] = None + start: Optional[str] = None + end: Optional[str] = None + allDay: Optional[bool] = None + location: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + + +def _account_dict(a: models.CalDAVAccount) -> dict: + return { + "id": a.id, + "name": a.name, + "url": a.url, + "username": a.username, + "color": a.color, + "enabled": a.enabled, + "calendars": [ + { + "id": c.id, + "name": c.name, + "color": c.color or a.color, + "enabled": c.enabled, + "cal_id": c.cal_id, + } + for c in a.calendars + ], + } + + +def _find_account_for_event_url( + event_url: str, accounts: list[models.CalDAVAccount] +) -> Optional[models.CalDAVAccount]: + for acc in accounts: + if event_url.startswith(acc.url): + return acc + # fallback: check calendar urls + for acc in accounts: + for cal in acc.calendars: + if event_url.startswith(cal.cal_id): + return acc + return None + + +@router.get("/accounts") +def list_accounts( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + accounts = ( + db.query(models.CalDAVAccount) + .filter(models.CalDAVAccount.user_id == current_user.id) + .all() + ) + return [_account_dict(a) for a in accounts] + + +@router.post("/accounts") +def add_account( + data: AccountCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + try: + remote_cals = caldav_client.fetch_calendars(data.url, data.username, data.password) + except ValueError as e: + raise HTTPException(400, str(e)) + + account = models.CalDAVAccount( + user_id=current_user.id, + name=data.name, + url=data.url, + username=data.username, + password=data.password, + color=data.color, + ) + db.add(account) + db.flush() + + for idx, cal in enumerate(remote_cals): + db.add( + models.Calendar( + account_id=account.id, + cal_id=cal["url"], + name=cal["name"], + color=cal.get("color") or PALETTE[idx % len(PALETTE)], + enabled=True, + ) + ) + + db.commit() + db.refresh(account) + return _account_dict(account) + + +@router.delete("/accounts/{account_id}") +def delete_account( + account_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + account = ( + db.query(models.CalDAVAccount) + .filter( + models.CalDAVAccount.id == account_id, + models.CalDAVAccount.user_id == current_user.id, + ) + .first() + ) + if not account: + raise HTTPException(404, "Account not found") + db.delete(account) + db.commit() + return {"ok": True} + + +@router.post("/accounts/{account_id}/sync") +def sync_account( + account_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + account = ( + db.query(models.CalDAVAccount) + .filter( + models.CalDAVAccount.id == account_id, + models.CalDAVAccount.user_id == current_user.id, + ) + .first() + ) + if not account: + raise HTTPException(404, "Account not found") + try: + remote_cals = caldav_client.fetch_calendars( + account.url, account.username, account.password + ) + except ValueError as e: + raise HTTPException(400, str(e)) + + existing = {c.cal_id: c for c in account.calendars} + for idx, cal in enumerate(remote_cals): + if cal["url"] not in existing: + db.add( + models.Calendar( + account_id=account.id, + cal_id=cal["url"], + name=cal["name"], + color=cal.get("color") or PALETTE[idx % len(PALETTE)], + enabled=True, + ) + ) + else: + existing[cal["url"]].name = cal["name"] + db.commit() + db.refresh(account) + return _account_dict(account) + + +@router.put("/calendars/{calendar_id}") +def update_calendar( + calendar_id: int, + data: CalendarUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + calendar = ( + db.query(models.Calendar) + .join(models.CalDAVAccount) + .filter( + models.Calendar.id == calendar_id, + models.CalDAVAccount.user_id == current_user.id, + ) + .first() + ) + if not calendar: + raise HTTPException(404, "Calendar not found") + if data.enabled is not None: + calendar.enabled = data.enabled + if data.color is not None: + calendar.color = data.color + db.commit() + return {"ok": True} + + +@router.get("/events") +def get_events( + start: str = Query(...), + end: str = Query(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + from datetime import datetime, timezone + + try: + start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(400, "Invalid date format — use ISO 8601") + + # Make timezone-aware + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + + all_events = [] + accounts = ( + db.query(models.CalDAVAccount) + .filter( + models.CalDAVAccount.user_id == current_user.id, + models.CalDAVAccount.enabled == True, + ) + .all() + ) + + for account in accounts: + for calendar in account.calendars: + if not calendar.enabled: + continue + try: + events = caldav_client.fetch_events( + account.url, + account.username, + account.password, + calendar.cal_id, + start_dt, + end_dt, + ) + cal_color = calendar.color or account.color + for ev in events: + ev["calendar_id"] = calendar.id + ev["calendar_name"] = calendar.name + ev["calendarColor"] = cal_color + all_events.append(ev) + except Exception as exc: + logger.error( + "Error fetching calendar %s: %s", calendar.id, exc + ) + + return all_events + + +@router.post("/events") +def create_event( + data: EventCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + calendar = ( + db.query(models.Calendar) + .join(models.CalDAVAccount) + .filter( + models.Calendar.id == data.calendar_id, + models.CalDAVAccount.user_id == current_user.id, + ) + .first() + ) + if not calendar: + raise HTTPException(404, "Calendar not found") + account = calendar.account + try: + uid = caldav_client.create_event( + account.url, + account.username, + account.password, + calendar.cal_id, + { + "title": data.title, + "start": data.start, + "end": data.end, + "allDay": data.allDay, + "location": data.location, + "description": data.description, + "color": data.color, + }, + ) + return {"uid": uid, "calendar_id": data.calendar_id} + except Exception as exc: + raise HTTPException(500, f"Could not create event: {exc}") + + +@router.put("/events/{event_id}") +def update_event( + event_id: str, + event_url: str = Query(...), + data: EventUpdate = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + accounts = ( + db.query(models.CalDAVAccount) + .filter(models.CalDAVAccount.user_id == current_user.id) + .all() + ) + account = _find_account_for_event_url(event_url, accounts) + if not account: + raise HTTPException(404, "Event not found or not authorized") + try: + caldav_client.update_event( + account.url, + account.username, + account.password, + event_url, + data.model_dump(exclude_none=True) if data else {}, + ) + return {"ok": True} + except Exception as exc: + raise HTTPException(500, f"Could not update event: {exc}") + + +@router.delete("/events/{event_id}") +def delete_event( + event_id: str, + event_url: str = Query(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + accounts = ( + db.query(models.CalDAVAccount) + .filter(models.CalDAVAccount.user_id == current_user.id) + .all() + ) + account = _find_account_for_event_url(event_url, accounts) + if not account: + raise HTTPException(404, "Event not found or not authorized") + try: + caldav_client.delete_event( + account.url, account.username, account.password, event_url + ) + return {"ok": True} + except Exception as exc: + raise HTTPException(500, f"Could not delete event: {exc}") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py new file mode 100644 index 0000000..6be69f5 --- /dev/null +++ b/backend/routers/settings_router.py @@ -0,0 +1,69 @@ +from typing import Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import get_current_user +from database import get_db + +router = APIRouter() + + +class SettingsUpdate(BaseModel): + default_view: Optional[str] = None + primary_color: Optional[str] = None + accent_color: Optional[str] = None + today_color: Optional[str] = None + dim_past_events: Optional[bool] = None + + +def _settings_dict(s: models.UserSettings) -> dict: + return { + "default_view": s.default_view, + "primary_color": s.primary_color, + "accent_color": s.accent_color, + "today_color": s.today_color, + "dim_past_events": s.dim_past_events, + } + + +@router.get("/") +def get_settings( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + settings = ( + db.query(models.UserSettings) + .filter(models.UserSettings.user_id == current_user.id) + .first() + ) + if not settings: + settings = models.UserSettings(user_id=current_user.id) + db.add(settings) + db.commit() + db.refresh(settings) + return _settings_dict(settings) + + +@router.put("/") +def update_settings( + data: SettingsUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + settings = ( + db.query(models.UserSettings) + .filter(models.UserSettings.user_id == current_user.id) + .first() + ) + if not settings: + settings = models.UserSettings(user_id=current_user.id) + db.add(settings) + + for field, value in data.model_dump(exclude_none=True).items(): + setattr(settings, field, value) + + db.commit() + return {"ok": True} diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py new file mode 100644 index 0000000..b1a3551 --- /dev/null +++ b/backend/routers/users_router.py @@ -0,0 +1,89 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import get_current_admin, get_current_user, get_password_hash +from database import get_db + +router = APIRouter() + + +class CreateUserRequest(BaseModel): + username: str + password: str + email: Optional[str] = None + is_admin: bool = False + + +class ChangePasswordRequest(BaseModel): + password: str + + +def _user_dict(u: models.User) -> dict: + return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin} + + +@router.get("/") +def list_users( + db: Session = Depends(get_db), + _: models.User = Depends(get_current_admin), +): + return [_user_dict(u) for u in db.query(models.User).all()] + + +@router.post("/") +def create_user( + req: CreateUserRequest, + db: Session = Depends(get_db), + _: models.User = Depends(get_current_admin), +): + if db.query(models.User).filter(models.User.username == req.username).first(): + raise HTTPException(400, "Username already taken") + user = models.User( + username=req.username, + email=req.email, + password_hash=get_password_hash(req.password), + is_admin=req.is_admin, + ) + db.add(user) + db.flush() + db.add(models.UserSettings(user_id=user.id)) + db.commit() + db.refresh(user) + return _user_dict(user) + + +@router.delete("/{user_id}") +def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_admin), +): + if user_id == current_user.id: + raise HTTPException(400, "Cannot delete yourself") + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(404, "User not found") + db.delete(user) + db.commit() + return {"ok": True} + + +@router.put("/{user_id}/password") +def change_password( + user_id: int, + req: ChangePasswordRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + if not current_user.is_admin and current_user.id != user_id: + raise HTTPException(403, "Not authorized") + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(404, "User not found") + user.password_hash = get_password_hash(req.password) + db.commit() + return {"ok": True} diff --git a/calendarr.service b/calendarr.service new file mode 100644 index 0000000..5b79409 --- /dev/null +++ b/calendarr.service @@ -0,0 +1,17 @@ +[Unit] +Description=Calendarr – Self-hosted CalDAV Calendar UI +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/calendarr +EnvironmentFile=/opt/calendarr/.env +ExecStart=/opt/calendarr/venv/bin/python /opt/calendarr/backend/main.py +Restart=on-failure +RestartSec=5s +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/frontend/css/app.css b/frontend/css/app.css new file mode 100644 index 0000000..20a94d7 --- /dev/null +++ b/frontend/css/app.css @@ -0,0 +1,574 @@ +/* ═══════════════════════════════════════════════════════ + Calendarr — Dark-Mode-first CSS + ═══════════════════════════════════════════════════════ */ + +/* ── Variables ─────────────────────────────────────────── */ +:root { + --primary: #4285f4; + --primary-dim: rgba(66,133,244,.15); + --accent: #ea4335; + --today-color: #4285f4; + + --bg-app: #0e0e14; + --bg-topbar: #18181f; + --bg-sidebar: #18181f; + --bg-surface: #22222c; + --bg-hover: #2a2a38; + --bg-active: #32324a; + + --text-1: #e8e8f0; + --text-2: #9090aa; + --text-3: #55556a; + --border: #2e2e40; + --border-light: #242438; + --scrollbar: #30303c; + + --topbar-h: 64px; + --sidebar-w: 256px; + --shadow: 0 2px 12px rgba(0,0,0,.45); + --shadow-lg: 0 8px 28px rgba(0,0,0,.55); + --radius: 8px; + --radius-sm: 4px; + --transition: .15s ease; +} + +/* ── Reset ──────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; overflow: hidden; } +body { + font-family: 'Google Sans', 'Roboto', system-ui, sans-serif; + background: var(--bg-app); + color: var(--text-1); + font-size: 14px; + line-height: 1.5; +} +input, select, textarea, button { font-family: inherit; font-size: inherit; color: inherit; } +button { cursor: pointer; border: none; background: none; } +a { color: var(--primary); text-decoration: none; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; } + +/* ── Utilities ──────────────────────────────────────────── */ +.hidden { display: none !important; } +.flex { display: flex; } +.flex-col { display: flex; flex-direction: column; } +.gap-8 { gap: 8px; } + +/* ── Buttons ────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 20px; + font-weight: 500; transition: background var(--transition), color var(--transition); + white-space: nowrap; +} +.btn-primary { + background: var(--primary); + color: #fff; +} +.btn-primary:hover { filter: brightness(1.12); } +.btn-secondary { + background: var(--bg-surface); + color: var(--text-1); + border: 1px solid var(--border); +} +.btn-secondary:hover { background: var(--bg-hover); } +.btn-ghost { color: var(--primary); } +.btn-ghost:hover { background: var(--primary-dim); } +.btn-danger { background: var(--accent); color: #fff; } +.btn-danger:hover { filter: brightness(1.1); } +.btn-full { width: 100%; justify-content: center; } +.btn-fab { + display: flex; align-items: center; gap: 10px; + padding: 12px 20px; border-radius: 24px; + background: var(--bg-surface); + color: var(--text-1); + font-weight: 600; + box-shadow: var(--shadow); + margin: 16px 12px 8px; + transition: background var(--transition), box-shadow var(--transition); +} +.btn-fab:hover { background: var(--bg-hover); box-shadow: var(--shadow-lg); } + +.icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 50%; + color: var(--text-2); transition: background var(--transition); + flex-shrink: 0; +} +.icon-btn svg { width: 20px; height: 20px; fill: currentColor; } +.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); } + +/* ── Auth Screens ───────────────────────────────────────── */ +.auth-screen { + position: fixed; inset: 0; + display: flex; align-items: center; justify-content: center; + background: var(--bg-app); + z-index: 1000; +} +.auth-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 40px; + width: 100%; max-width: 400px; + box-shadow: var(--shadow-lg); +} +.auth-logo { + display: flex; align-items: center; gap: 10px; + margin-bottom: 24px; +} +.auth-logo .logo-icon { font-size: 32px; } +.auth-logo h1 { font-size: 24px; font-weight: 600; color: var(--text-1); } +.auth-card h2 { font-size: 18px; margin-bottom: 4px; } +.auth-sub { color: var(--text-2); margin-bottom: 24px; } + +/* ── Forms ──────────────────────────────────────────────── */ +.form-group { + display: flex; flex-direction: column; gap: 6px; + margin-bottom: 16px; +} +.form-group label { color: var(--text-2); font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; } +.form-group input, .form-group select, .form-group textarea { + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + color: var(--text-1); + outline: none; + transition: border-color var(--transition); + width: 100%; +} +.form-group input:focus, .form-group select:focus, .form-group textarea:focus { + border-color: var(--primary); +} +.form-group textarea { resize: vertical; } +.form-row { + display: flex; gap: 12px; margin-bottom: 16px; align-items: center; +} +.form-group.half { flex: 1; margin-bottom: 0; } +.form-error { + background: rgba(234,67,53,.15); + border: 1px solid rgba(234,67,53,.4); + border-radius: var(--radius-sm); + padding: 10px 12px; + color: #f28b82; + margin-bottom: 12px; +} +.input-title { + font-size: 20px !important; font-weight: 500; + background: transparent !important; + border: none !important; + border-bottom: 2px solid var(--border) !important; + border-radius: 0 !important; + padding: 8px 0 !important; + margin-bottom: 8px; +} +.input-title:focus { border-bottom-color: var(--primary) !important; } +.toggle-label { + display: flex; align-items: center; gap: 8px; + cursor: pointer; color: var(--text-1); +} +.toggle-label input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary); } +.color-input { width: 44px; height: 36px; padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-app); cursor: pointer; } +.color-row { display: flex; align-items: center; gap: 10px; } +.color-label { font-size: 12px; color: var(--text-2); font-family: monospace; } + +/* ── Color Picker ───────────────────────────────────────── */ +.color-picker { display: flex; gap: 8px; flex-wrap: wrap; } +.color-swatch { + width: 28px; height: 28px; border-radius: 50%; cursor: pointer; + border: 3px solid transparent; + transition: border-color var(--transition), transform var(--transition); +} +.color-swatch:hover { transform: scale(1.15); } +.color-swatch.active { border-color: var(--text-1); } + +/* ── Top Bar ────────────────────────────────────────────── */ +.topbar { + position: fixed; top: 0; left: 0; right: 0; z-index: 100; + height: var(--topbar-h); + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; + padding: 0 8px; + gap: 8px; +} +.topbar-left { display: flex; align-items: center; gap: 4px; width: var(--sidebar-w); flex-shrink: 0; } +.topbar-logo { display: flex; align-items: center; gap: 8px; user-select: none; } +.topbar-logo span:first-child { font-size: 22px; } +.logo-text { font-size: 20px; font-weight: 400; color: var(--text-1); } +.topbar-center { display: flex; align-items: center; gap: 4px; flex: 1; } +.view-title { font-size: 20px; font-weight: 400; color: var(--text-1); white-space: nowrap; padding-left: 8px; } +.topbar-right { display: flex; align-items: center; gap: 4px; } + +.view-switcher { display: flex; background: var(--bg-surface); border-radius: 20px; overflow: hidden; border: 1px solid var(--border); } +.view-btn { + padding: 6px 14px; font-size: 13px; font-weight: 500; + color: var(--text-2); + transition: background var(--transition), color var(--transition); + border-radius: 20px; +} +.view-btn:hover { background: var(--bg-hover); color: var(--text-1); } +.view-btn.active { background: var(--primary-dim); color: var(--primary); } + +.user-avatar { + width: 34px; height: 34px; border-radius: 50%; + background: var(--primary); + display: flex; align-items: center; justify-content: center; + font-weight: 600; font-size: 14px; color: #fff; + cursor: pointer; user-select: none; flex-shrink: 0; +} + +/* ── Layout ─────────────────────────────────────────────── */ +#app { + display: flex; flex-direction: column; height: 100vh; +} +.content-wrapper { + margin-top: var(--topbar-h); + display: flex; flex: 1; overflow: hidden; height: calc(100vh - var(--topbar-h)); +} +.sidebar { + width: var(--sidebar-w); + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + flex-shrink: 0; + overflow-y: auto; + transition: transform var(--transition); +} +.sidebar.collapsed { transform: translateX(calc(-1 * var(--sidebar-w))); margin-right: calc(-1 * var(--sidebar-w)); } +.sidebar-inner { padding-bottom: 24px; } +.main-view { flex: 1; overflow: auto; display: flex; flex-direction: column; } + +/* ── Mini Calendar ──────────────────────────────────────── */ +.mini-cal { padding: 12px 16px; } +.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); } +.mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); } +.mini-btn:hover { color: var(--text-1); background: var(--bg-hover); } +.mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; } +.mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; } +.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; } +.mini-day { + font-size: 12px; padding: 3px 0; border-radius: 50%; + cursor: pointer; transition: background var(--transition); + color: var(--text-2); width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + margin: 1px auto; position: relative; +} +.mini-day:hover { background: var(--bg-hover); color: var(--text-1); } +.mini-day.other-month { color: var(--text-3); } +.mini-day.today { + background: var(--today-color); + color: #fff; font-weight: 700; +} +.mini-day.selected:not(.today) { background: var(--primary-dim); color: var(--primary); font-weight: 600; } +.mini-day.has-events::after { + content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); + width: 4px; height: 4px; border-radius: 50%; background: var(--primary); +} +.mini-day.today.has-events::after { background: #fff; } + +/* ── Calendar List ──────────────────────────────────────── */ +.cal-list { padding: 8px 0; } +.cal-list-header { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 16px; + font-size: 12px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--text-2); +} +.cal-item { + display: flex; align-items: center; gap: 10px; + padding: 6px 16px; cursor: pointer; + transition: background var(--transition); + border-radius: 0 20px 20px 0; + margin-right: 12px; +} +.cal-item:hover { background: var(--bg-hover); } +.cal-item-dot { + width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; +} +.cal-item input[type=checkbox] { accent-color: var(--primary); width: 14px; height: 14px; } +.cal-item-name { font-size: 13px; flex: 1; color: var(--text-1); } +.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; } +.cal-item-remove { opacity: 0; } +.cal-item:hover .cal-item-remove { opacity: 1; } + +/* ── Month View ─────────────────────────────────────────── */ +.month-view { display: flex; flex-direction: column; height: 100%; } +.month-header { + display: grid; grid-template-columns: repeat(7, 1fr); + border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.month-dow { + padding: 8px 0; text-align: center; + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--text-2); +} +.month-grid { + display: grid; grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(6, 1fr); + flex: 1; overflow: hidden; +} +.month-cell { + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: 4px; + overflow: hidden; + cursor: pointer; + transition: background var(--transition); + min-height: 0; +} +.month-cell:nth-child(7n) { border-right: none; } +.month-cell:hover { background: var(--bg-hover); } +.month-cell.today { background: rgba(66,133,244,.08); } +.month-cell.other-month .cell-day { color: var(--text-3); } +.cell-day { + font-size: 12px; font-weight: 500; color: var(--text-2); + width: 26px; height: 26px; + display: flex; align-items: center; justify-content: center; + border-radius: 50%; margin-bottom: 2px; flex-shrink: 0; +} +.cell-day.today { + background: var(--today-color); + color: #fff; font-weight: 700; +} +.month-event { + font-size: 11px; font-weight: 500; + padding: 1px 6px; border-radius: 3px; + margin-bottom: 1px; cursor: pointer; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: filter var(--transition); +} +.month-event:hover { filter: brightness(1.15); } +.month-event.past { opacity: .45; } +.month-more { + font-size: 11px; color: var(--text-2); padding: 1px 6px; + cursor: pointer; font-weight: 500; +} +.month-more:hover { color: var(--primary); } + +/* ── Week / Day Views ───────────────────────────────────── */ +.week-view, .day-view { display: flex; flex-direction: column; height: 100%; } +.week-header-row { + display: flex; border-bottom: 1px solid var(--border); + flex-shrink: 0; background: var(--bg-app); + position: sticky; top: 0; z-index: 10; +} +.week-time-gutter { width: 56px; flex-shrink: 0; } +.week-day-header { + flex: 1; padding: 8px 4px; text-align: center; + border-left: 1px solid var(--border); cursor: pointer; + transition: background var(--transition); +} +.week-day-header:hover { background: var(--bg-hover); } +.week-day-header .day-name { + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--text-2); +} +.week-day-header .day-num { + font-size: 22px; font-weight: 400; color: var(--text-2); + width: 44px; height: 44px; + display: flex; align-items: center; justify-content: center; + border-radius: 50%; margin: 2px auto; +} +.week-day-header.today .day-num { + background: var(--today-color); + color: #fff; font-weight: 700; +} +.week-day-header.today .day-name { color: var(--today-color); } + +/* All-day row */ +.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 28px; } +.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); } +.allday-cols { display: flex; flex: 1; } +.allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; } +.allday-event { + font-size: 11px; font-weight: 500; padding: 2px 6px; + border-radius: 3px; margin-bottom: 2px; cursor: pointer; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Time grid */ +.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; } +.week-time-col { width: 56px; flex-shrink: 0; position: relative; } +.time-label { + height: 60px; display: flex; align-items: flex-start; justify-content: flex-end; + padding-right: 8px; padding-top: 6px; + font-size: 10px; color: var(--text-3); +} +.week-days-col { flex: 1; display: flex; position: relative; } +.week-day-col { + flex: 1; border-left: 1px solid var(--border); + position: relative; + min-height: calc(60px * 24); +} +.hour-line { + position: absolute; left: 0; right: 0; + border-top: 1px solid var(--border-light); + height: 60px; +} +.hour-line:first-child { border-top: none; } +.half-line { + position: absolute; left: 0; right: 0; + border-top: 1px dashed var(--border-light); + top: 30px; +} + +/* Current time indicator */ +.now-line { + position: absolute; left: 0; right: 0; + border-top: 2px solid var(--accent); + z-index: 5; pointer-events: none; +} +.now-dot { + position: absolute; left: -5px; top: -5px; + width: 10px; height: 10px; border-radius: 50%; + background: var(--accent); +} + +/* Timed events */ +.timed-event { + position: absolute; border-radius: 4px; + padding: 2px 4px; cursor: pointer; overflow: hidden; + font-size: 11px; font-weight: 500; + border-left: 3px solid rgba(0,0,0,.25); + transition: filter var(--transition); + z-index: 3; +} +.timed-event:hover { filter: brightness(1.12); z-index: 4; } +.timed-event.past { opacity: .45; } +.timed-event .ev-time { font-size: 10px; opacity: .85; } +.timed-event .ev-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.timed-event .ev-loc { font-size: 10px; opacity: .75; white-space: nowrap; overflow: hidden; } + +/* Day view specifics */ +.day-view .week-day-col { flex: 1; } + +/* ── Agenda View ────────────────────────────────────────── */ +.agenda-view { padding: 16px; } +.agenda-day { margin-bottom: 16px; } +.agenda-date { + font-size: 13px; font-weight: 600; color: var(--text-2); + padding: 8px 0 4px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; + display: flex; align-items: center; gap: 10px; +} +.agenda-date-num { + font-size: 22px; font-weight: 300; color: var(--text-1); + width: 44px; text-align: center; +} +.agenda-date-label { display: flex; flex-direction: column; } +.agenda-date-label .wd { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); } +.agenda-date-label .mo { font-size: 13px; color: var(--text-2); } +.agenda-date.today .agenda-date-num { color: var(--today-color); font-weight: 600; } +.agenda-event { + display: flex; align-items: flex-start; gap: 12px; + padding: 8px 12px; border-radius: var(--radius-sm); + cursor: pointer; transition: background var(--transition); + margin-left: 54px; margin-bottom: 4px; +} +.agenda-event:hover { background: var(--bg-hover); } +.agenda-event.past { opacity: .45; } +.agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; } +.agenda-ev-info { flex: 1; } +.agenda-ev-title { font-size: 14px; color: var(--text-1); } +.agenda-ev-meta { font-size: 12px; color: var(--text-2); } +.agenda-empty { text-align: center; padding: 48px; color: var(--text-3); font-size: 15px; } + +/* ── Modals ─────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; z-index: 500; + background: rgba(0,0,0,.6); + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.modal-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-height: 90vh; overflow-y: auto; + box-shadow: var(--shadow-lg); +} +.modal-header { + display: flex; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); + gap: 8px; +} +.modal-header h3 { font-size: 18px; font-weight: 500; flex: 1; } +.modal-close { font-size: 24px; } +.modal-body { padding: 20px; } +.modal-footer { + display: flex; align-items: center; gap: 8px; + padding: 12px 20px; border-top: 1px solid var(--border); +} + +/* ── Event Popup ────────────────────────────────────────── */ +.event-popup { + position: fixed; z-index: 600; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 300px; + box-shadow: var(--shadow-lg); +} +.popup-header { + display: flex; align-items: center; gap: 8px; + padding: 12px 16px; border-bottom: 1px solid var(--border); +} +.popup-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } +.popup-header h4 { flex: 1; font-size: 15px; font-weight: 500; } +.popup-action, .popup-close { width: 32px; height: 32px; font-size: 16px; } +.popup-body { padding: 12px 16px; } +.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } +.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } + +/* ── Settings ───────────────────────────────────────────── */ +.settings-section { margin-bottom: 28px; } +.settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } +.badge-admin { + font-size: 10px; padding: 2px 6px; + background: rgba(66,133,244,.2); color: var(--primary); + border-radius: 10px; font-weight: 600; +} +.users-list-item { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 0; border-bottom: 1px solid var(--border-light); +} +.users-list-item .uname { font-weight: 500; } +.users-list-item .uemail { font-size: 12px; color: var(--text-2); } +.users-list-item .ubadge { font-size: 11px; color: var(--text-3); background: var(--bg-hover); padding: 2px 6px; border-radius: 10px; } + +/* ── Toast ──────────────────────────────────────────────── */ +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: 24px; padding: 10px 24px; + font-size: 14px; color: var(--text-1); + box-shadow: var(--shadow-lg); z-index: 9999; + transition: opacity .3s; +} +.toast.error { border-color: rgba(234,67,53,.5); background: rgba(234,67,53,.15); color: #f28b82; } + +/* ── Loading Spinner ────────────────────────────────────── */ +.spinner { + width: 32px; height: 32px; border: 3px solid var(--border); + border-top-color: var(--primary); border-radius: 50%; + animation: spin .7s linear infinite; margin: 32px auto; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.loading-view { display: flex; justify-content: center; align-items: center; height: 200px; } + +/* ── Responsive ─────────────────────────────────────────── */ +@media (max-width: 768px) { + :root { --sidebar-w: 0px; } + .sidebar { position: fixed; left: 0; top: var(--topbar-h); bottom: 0; z-index: 200; width: 256px; transform: translateX(-100%); } + .sidebar.open { transform: translateX(0); } + .topbar-left { width: auto; } + .view-switcher .view-btn { padding: 6px 8px; font-size: 12px; } + .logo-text { display: none; } + .view-title { font-size: 16px; } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..58268b2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,364 @@ + + + + + + Calendarr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..4b0828c --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,49 @@ +const BASE = '/api'; + +async function request(method, path, body = null, formEncoded = false) { + const token = localStorage.getItem('token'); + const headers = {}; + + if (token) headers['Authorization'] = `Bearer ${token}`; + + let bodyStr = null; + if (body !== null) { + if (formEncoded) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + bodyStr = new URLSearchParams(body).toString(); + } else { + headers['Content-Type'] = 'application/json'; + bodyStr = JSON.stringify(body); + } + } + + const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr }); + + if (res.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.reload(); + return null; + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: 'Unbekannter Fehler' })); + throw new Error(err.detail || `HTTP ${res.status}`); + } + + if (res.status === 204) return null; + return res.json(); +} + +export const api = { + get: (path) => request('GET', path), + post: (path, body) => request('POST', path, body), + put: (path, body) => request('PUT', path, body), + delete: (path) => request('DELETE', path), + + login: (username, password) => + request('POST', '/auth/token', { username, password }, true), + + setupRequired: () => request('GET', '/auth/setup-required'), + setup: (data) => request('POST', '/auth/setup', data), +}; diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..74273ee --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,129 @@ +import { api } from './api.js'; +import { initCalendar, showToast } from './calendar.js'; + +// ── Bootstrap ───────────────────────────────────────────── +async function boot() { + // Check if setup is required + let setupRequired = false; + try { + const res = await api.setupRequired(); + setupRequired = res.required; + } catch (e) { + showScreen('login'); + return; + } + + if (setupRequired) { + showScreen('setup'); + bindSetupForm(); + return; + } + + // Check if already logged in + const token = localStorage.getItem('token'); + if (token) { + try { + await api.get('/auth/me'); // validate token + await launchApp(); + return; + } catch (_) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + } + + showScreen('login'); + bindLoginForm(); +} + +function showScreen(name) { + document.getElementById('screen-setup').classList.add('hidden'); + document.getElementById('screen-login').classList.add('hidden'); + document.getElementById('app').classList.add('hidden'); + + if (name === 'setup') document.getElementById('screen-setup').classList.remove('hidden'); + else if (name === 'login') document.getElementById('screen-login').classList.remove('hidden'); + else if (name === 'app') document.getElementById('app').classList.remove('hidden'); +} + +async function launchApp() { + showScreen('app'); + + // Set user avatar initials + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const avatar = document.getElementById('user-avatar'); + if (user.username) { + avatar.textContent = user.username[0].toUpperCase(); + avatar.title = user.username; + } + + // Logout on avatar click (simple UX) + avatar.addEventListener('click', () => { + if (confirm('Abmelden?')) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.reload(); + } + }); + + await initCalendar(); +} + +// ── Setup Form ──────────────────────────────────────────── +function bindSetupForm() { + document.getElementById('setup-form').addEventListener('submit', async e => { + e.preventDefault(); + const username = document.getElementById('setup-username').value.trim(); + const email = document.getElementById('setup-email').value.trim() || null; + const pw1 = document.getElementById('setup-password').value; + const pw2 = document.getElementById('setup-password2').value; + const errEl = document.getElementById('setup-error'); + + errEl.classList.add('hidden'); + + if (pw1 !== pw2) { + errEl.textContent = 'Passwörter stimmen nicht überein'; + errEl.classList.remove('hidden'); + return; + } + if (pw1.length < 6) { + errEl.textContent = 'Passwort muss mindestens 6 Zeichen haben'; + errEl.classList.remove('hidden'); + return; + } + + try { + const res = await api.setup({ username, email, password: pw1 }); + localStorage.setItem('token', res.access_token); + localStorage.setItem('user', JSON.stringify(res.user)); + await launchApp(); + } catch (err) { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } + }); +} + +// ── Login Form ──────────────────────────────────────────── +function bindLoginForm() { + document.getElementById('login-form').addEventListener('submit', async e => { + e.preventDefault(); + const username = document.getElementById('login-username').value.trim(); + const password = document.getElementById('login-password').value; + const errEl = document.getElementById('login-error'); + errEl.classList.add('hidden'); + + try { + const res = await api.login(username, password); + localStorage.setItem('token', res.access_token); + localStorage.setItem('user', JSON.stringify(res.user)); + await launchApp(); + } catch (err) { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } + }); +} + +// ── Start ───────────────────────────────────────────────── +boot(); diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js new file mode 100644 index 0000000..8fcbde3 --- /dev/null +++ b/frontend/js/calendar.js @@ -0,0 +1,719 @@ +import { api } from './api.js'; +import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey } from './utils.js'; +import { renderMonth } from './views/month.js'; +import { renderWeek } from './views/week.js'; +import { renderAgenda } from './views/agenda.js'; + +const MONTHS = ['Januar','Februar','März','April','Mai','Juni', + 'Juli','August','September','Oktober','November','Dezember']; + +let state = { + currentDate: new Date(), + currentView: 'month', + events: [], + accounts: [], + settings: {}, + dimPast: false, + editingEvent: null, // null = new event + selectedEventColor: '', // '' = use calendar color +}; + +// ── Public init ─────────────────────────────────────────── +export async function initCalendar() { + const [settings, accounts] = await Promise.all([ + api.get('/settings/'), + api.get('/caldav/accounts'), + ]); + + state.settings = settings; + state.accounts = accounts; + state.currentView = settings.default_view || 'month'; + state.dimPast = settings.dim_past_events; + + applyTheme(settings); + updateViewButtons(); + renderCalendarList(); + renderMiniCal(); + await fetchAndRender(); + bindTopbar(); + bindSidebar(); + bindEventModal(); + bindAccountModal(); + bindSettingsModal(); +} + +// ── Data fetching ───────────────────────────────────────── +async function fetchAndRender() { + const { start, end } = getViewRange(); + showLoading(); + try { + const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`); + state.events = events; + } catch (e) { + showToast('Fehler beim Laden der Termine: ' + e.message, true); + state.events = []; + } + renderView(); + updateTitle(); + renderMiniCal(); +} + +function getViewRange() { + const d = state.currentDate; + let start, end; + + if (state.currentView === 'month') { + start = new Date(d.getFullYear(), d.getMonth(), 1); + start.setDate(start.getDate() - start.getDay() - 1); + end = new Date(d.getFullYear(), d.getMonth() + 1, 0); + end.setDate(end.getDate() + (6 - end.getDay()) + 1); + } else if (state.currentView === 'week') { + start = new Date(d); + start.setDate(d.getDate() - d.getDay()); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(start.getDate() + 7); + } else if (state.currentView === 'day') { + start = new Date(d); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(end.getDate() + 1); + } else { // agenda + start = new Date(d); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(end.getDate() + 60); + } + return { start, end }; +} + +// ── Rendering ───────────────────────────────────────────── +function renderView() { + const container = document.getElementById('view-container'); + const evs = filterEvents(state.events); + + if (state.currentView === 'month') { + renderMonth(container, state.currentDate, evs, + date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, + showEventPopup + ); + } else if (state.currentView === 'week') { + renderWeek(container, state.currentDate, evs, + (date, switchDay) => { + if (switchDay) { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); } + else openNewEventModal(date); + }, + showEventPopup + ); + } else if (state.currentView === 'day') { + renderWeek(container, state.currentDate, evs, + (date, switchDay) => { if (!switchDay) openNewEventModal(date); }, + showEventPopup, + true + ); + } else { + renderAgenda(container, state.currentDate, evs, showEventPopup); + } +} + +function filterEvents(events) { + // If dimPast is enabled, events are still shown but CSS handles opacity via .past class + return events; +} + +function showLoading() { + document.getElementById('view-container').innerHTML = + `
`; +} + +function updateTitle() { + const d = state.currentDate; + let title = ''; + if (state.currentView === 'month') { + title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; + } else if (state.currentView === 'week') { + const sun = new Date(d); + sun.setDate(d.getDate() - d.getDay()); + const sat = new Date(sun); + sat.setDate(sun.getDate() + 6); + const sameMonth = sun.getMonth() === sat.getMonth(); + title = sameMonth + ? `${sun.getDate()}. – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}` + : `${sun.getDate()}. ${MONTHS[sun.getMonth()]} – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}`; + } else if (state.currentView === 'day') { + title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; + } else { + title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; + } + document.getElementById('view-title').textContent = title; +} + +function updateViewButtons() { + document.querySelectorAll('.view-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.view === state.currentView); + }); +} + +// ── Mini Calendar ───────────────────────────────────────── +function renderMiniCal() { + const d = state.currentDate; + const miniD = new Date(d.getFullYear(), d.getMonth(), 1); + document.getElementById('mini-title').textContent = + `${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`; + + const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1); + const lastDay = new Date(miniD.getFullYear(), miniD.getMonth() + 1, 0); + const gridStart = new Date(firstDay); + gridStart.setDate(gridStart.getDate() - firstDay.getDay()); + + // Build event date set + const eventDates = new Set(state.events.map(ev => { + const s = new Date(ev.start); + return `${s.getFullYear()}-${s.getMonth()}-${s.getDate()}`; + })); + + const days = []; + const cur = new Date(gridStart); + for (let i = 0; i < 42; i++) { + days.push(new Date(cur)); + cur.setDate(cur.getDate() + 1); + } + + const html = days.map(day => { + const isOther = day.getMonth() !== miniD.getMonth(); + const isToday_ = isToday(day); + const isSelected = isSameDay(day, state.currentDate); + const hasEvs = eventDates.has(`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`); + const cls = [ + 'mini-day', + isOther ? 'other-month' : '', + isToday_ ? 'today' : '', + isSelected && !isToday_ ? 'selected' : '', + hasEvs ? 'has-events' : '', + ].filter(Boolean).join(' '); + return `
${day.getDate()}
`; + }).join(''); + + document.getElementById('mini-days').innerHTML = html; + + document.querySelectorAll('.mini-day').forEach(el => { + el.addEventListener('click', () => { + state.currentDate = new Date(el.dataset.date + 'T00:00:00'); + if (state.currentView === 'agenda' || state.currentView === 'month') { + // Stay in current view but update date + } + fetchAndRender(); + }); + }); + + document.getElementById('mini-prev').onclick = () => { + state.currentDate = new Date(state.currentDate.getFullYear(), state.currentDate.getMonth() - 1, 1); + renderMiniCal(); + fetchAndRender(); + }; + document.getElementById('mini-next').onclick = () => { + state.currentDate = new Date(state.currentDate.getFullYear(), state.currentDate.getMonth() + 1, 1); + renderMiniCal(); + fetchAndRender(); + }; +} + +// ── Calendar List ───────────────────────────────────────── +function renderCalendarList() { + const container = document.getElementById('cal-list-items'); + if (!state.accounts.length) { + container.innerHTML = `
Kein CalDAV-Konto
`; + return; + } + + const html = state.accounts.map(acc => + `
${escHtml(acc.name)}
` + + acc.calendars.map(cal => + `
+ +
+ ${escHtml(cal.name)} + +
` + ).join('') + ).join(''); + + container.innerHTML = html; + + container.querySelectorAll('input[type=checkbox]').forEach(cb => { + cb.addEventListener('change', async () => { + const calId = parseInt(cb.dataset.calId); + await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked }); + // Update local state + for (const acc of state.accounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) cal.enabled = cb.checked; + } + } + fetchAndRender(); + }); + }); + + container.querySelectorAll('.cal-item-remove').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + if (!confirm('CalDAV-Konto wirklich entfernen?')) return; + const accId = parseInt(btn.dataset.accId); + await api.delete(`/caldav/accounts/${accId}`); + state.accounts = state.accounts.filter(a => a.id !== accId); + renderCalendarList(); + fetchAndRender(); + }); + }); +} + +// ── Navigation ──────────────────────────────────────────── +function navigate(dir) { + const d = state.currentDate; + if (state.currentView === 'month') { + state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1); + } else if (state.currentView === 'week') { + state.currentDate = new Date(d); + state.currentDate.setDate(d.getDate() + dir * 7); + } else if (state.currentView === 'day') { + state.currentDate = new Date(d); + state.currentDate.setDate(d.getDate() + dir); + } else { + state.currentDate = new Date(d); + state.currentDate.setDate(d.getDate() + dir * 30); + } + fetchAndRender(); +} + +// ── Topbar bindings ─────────────────────────────────────── +function bindTopbar() { + document.getElementById('btn-today').onclick = () => { + state.currentDate = new Date(); + fetchAndRender(); + }; + document.getElementById('btn-prev').onclick = () => navigate(-1); + document.getElementById('btn-next').onclick = () => navigate(1); + + document.querySelectorAll('.view-btn').forEach(btn => { + btn.addEventListener('click', () => { + state.currentView = btn.dataset.view; + updateViewButtons(); + fetchAndRender(); + }); + }); + + document.getElementById('btn-settings').onclick = openSettingsModal; + document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); +} + +// ── Sidebar toggle ──────────────────────────────────────── +function bindSidebar() { + document.getElementById('sidebar-toggle').onclick = () => { + document.getElementById('sidebar').classList.toggle('collapsed'); + }; + document.getElementById('btn-add-account').onclick = openAccountModal; +} + +// ── Event Popup ─────────────────────────────────────────── +function showEventPopup(ev, anchor) { + const popup = document.getElementById('popup-event'); + popup.classList.remove('hidden'); + + const color = ev.color || ev.calendarColor || '#4285f4'; + document.getElementById('popup-color-dot').style.background = color; + document.getElementById('popup-title').textContent = ev.title; + + // Time + if (ev.allDay) { + document.getElementById('popup-time').textContent = 'Ganztägig'; + } else { + const s = new Date(ev.start); + const e = new Date(ev.end); + document.getElementById('popup-time').textContent = + `${fmtDatetime(s)} – ${fmtTime(e)}`; + } + + document.getElementById('popup-location').textContent = ev.location || ''; + document.getElementById('popup-location').style.display = ev.location ? '' : 'none'; + document.getElementById('popup-description').textContent = ev.description || ''; + document.getElementById('popup-description').style.display = ev.description ? '' : 'none'; + document.getElementById('popup-calendar').textContent = ev.calendar_name || ''; + + // Position near anchor + const rect = anchor.getBoundingClientRect(); + const pw = 300, ph = 200; + let left = rect.right + 8; + let top = rect.top; + if (left + pw > window.innerWidth) left = rect.left - pw - 8; + if (top + ph > window.innerHeight) top = window.innerHeight - ph - 16; + popup.style.left = Math.max(8, left) + 'px'; + popup.style.top = Math.max(8, top) + 'px'; + + document.getElementById('popup-edit').onclick = () => { + popup.classList.add('hidden'); + openEditEventModal(ev); + }; + document.getElementById('popup-delete').onclick = async () => { + if (!confirm(`"${ev.title}" wirklich löschen?`)) return; + popup.classList.add('hidden'); + try { + await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); + showToast('Termin gelöscht'); + fetchAndRender(); + } catch (e) { showToast(e.message, true); } + }; + document.getElementById('popup-close').onclick = () => popup.classList.add('hidden'); +} + +// Close popup on outside click +document.addEventListener('click', e => { + const popup = document.getElementById('popup-event'); + if (!popup.classList.contains('hidden') && !popup.contains(e.target)) { + popup.classList.add('hidden'); + } +}); + +// ── Event Modal ─────────────────────────────────────────── +function populateCalendarSelect(selectedId) { + const sel = document.getElementById('ev-calendar'); + sel.innerHTML = ''; + state.accounts.forEach(acc => { + acc.calendars.filter(c => c.enabled).forEach(cal => { + const opt = document.createElement('option'); + opt.value = cal.id; + opt.textContent = `${acc.name} / ${cal.name}`; + if (cal.id === selectedId) opt.selected = true; + sel.appendChild(opt); + }); + }); +} + +function openNewEventModal(date) { + state.editingEvent = null; + state.selectedEventColor = ''; + + document.getElementById('modal-event-title-label').textContent = 'Termin erstellen'; + document.getElementById('ev-title').value = ''; + document.getElementById('ev-location').value = ''; + document.getElementById('ev-description').value = ''; + document.getElementById('ev-allday').checked = false; + + const start = new Date(date); + const end = new Date(date); + end.setHours(end.getHours() + 1); + document.getElementById('ev-start').value = toLocalDatetimeInput(start); + document.getElementById('ev-end').value = toLocalDatetimeInput(end); + document.getElementById('ev-start-date').value = toDateInput(start); + document.getElementById('ev-end-date').value = toDateInput(start); + + toggleAlldayFields(false); + populateCalendarSelect(null); + resetColorPicker(''); + document.getElementById('ev-delete').classList.add('hidden'); + openModal('modal-event'); +} + +function openEditEventModal(ev) { + state.editingEvent = ev; + state.selectedEventColor = ev.color || ''; + + document.getElementById('modal-event-title-label').textContent = 'Termin bearbeiten'; + document.getElementById('ev-title').value = ev.title; + document.getElementById('ev-location').value = ev.location || ''; + document.getElementById('ev-description').value = ev.description || ''; + document.getElementById('ev-allday').checked = ev.allDay; + + if (ev.allDay) { + document.getElementById('ev-start-date').value = ev.start.slice(0, 10); + document.getElementById('ev-end-date').value = ev.end.slice(0, 10); + toggleAlldayFields(true); + } else { + const s = new Date(ev.start); + const e = new Date(ev.end); + document.getElementById('ev-start').value = toLocalDatetimeInput(s); + document.getElementById('ev-end').value = toLocalDatetimeInput(e); + toggleAlldayFields(false); + } + + populateCalendarSelect(ev.calendar_id); + resetColorPicker(ev.color || ''); + document.getElementById('ev-delete').classList.remove('hidden'); + openModal('modal-event'); +} + +function toggleAlldayFields(allDay) { + document.getElementById('ev-time-row').style.display = allDay ? 'none' : ''; + document.getElementById('ev-date-row').style.display = allDay ? '' : 'none'; +} + +function resetColorPicker(color) { + state.selectedEventColor = color; + document.querySelectorAll('#ev-color-picker .color-swatch').forEach(sw => { + sw.classList.toggle('active', (sw.dataset.color || '') === color); + }); +} + +function bindEventModal() { + document.getElementById('ev-allday').addEventListener('change', e => { + toggleAlldayFields(e.target.checked); + }); + + document.querySelectorAll('#ev-color-picker .color-swatch').forEach(sw => { + sw.addEventListener('click', () => { + state.selectedEventColor = sw.dataset.color || ''; + resetColorPicker(state.selectedEventColor); + }); + }); + + document.getElementById('ev-save').onclick = async () => { + const title = document.getElementById('ev-title').value.trim(); + if (!title) { showToast('Bitte Titel eingeben', true); return; } + + const allDay = document.getElementById('ev-allday').checked; + const calId = parseInt(document.getElementById('ev-calendar').value); + const loc = document.getElementById('ev-location').value.trim(); + const desc = document.getElementById('ev-description').value.trim(); + const color = state.selectedEventColor; + + let start, end; + if (allDay) { + start = document.getElementById('ev-start-date').value; + end = document.getElementById('ev-end-date').value; + if (!start) { showToast('Bitte Datum eingeben', true); return; } + if (!end || end < start) end = start; + } else { + const sv = document.getElementById('ev-start').value; + const ev2 = document.getElementById('ev-end').value; + if (!sv) { showToast('Bitte Start-Zeit eingeben', true); return; } + start = new Date(sv).toISOString(); + end = ev2 ? new Date(ev2).toISOString() : new Date(new Date(sv).getTime() + 3600000).toISOString(); + } + + try { + if (state.editingEvent) { + await api.put( + `/caldav/events/${encodeURIComponent(state.editingEvent.id)}?event_url=${encodeURIComponent(state.editingEvent.url)}`, + { title, start, end, allDay, location: loc, description: desc, color: color || null } + ); + showToast('Termin aktualisiert'); + } else { + await api.post('/caldav/events', { + calendar_id: calId, title, start, end, allDay, + location: loc, description: desc, color: color || null, + }); + showToast('Termin erstellt'); + } + closeModal('modal-event'); + fetchAndRender(); + } catch (e) { + showToast(e.message, true); + } + }; + + document.getElementById('ev-delete').onclick = async () => { + const ev = state.editingEvent; + if (!ev) return; + if (!confirm(`"${ev.title}" wirklich löschen?`)) return; + try { + await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); + showToast('Termin gelöscht'); + closeModal('modal-event'); + fetchAndRender(); + } catch (e) { showToast(e.message, true); } + }; +} + +// ── Account Modal ───────────────────────────────────────── +function openAccountModal() { + document.getElementById('acc-name').value = ''; + document.getElementById('acc-url').value = ''; + document.getElementById('acc-username').value = ''; + document.getElementById('acc-password').value = ''; + document.getElementById('acc-color').value = '#4285f4'; + document.getElementById('acc-error').classList.add('hidden'); + openModal('modal-account'); +} + +function bindAccountModal() { + document.getElementById('acc-save').onclick = async () => { + const name = document.getElementById('acc-name').value.trim(); + const url = document.getElementById('acc-url').value.trim(); + const username = document.getElementById('acc-username').value.trim(); + const password = document.getElementById('acc-password').value; + const color = document.getElementById('acc-color').value; + const errEl = document.getElementById('acc-error'); + + if (!name || !url || !username || !password) { + errEl.textContent = 'Bitte alle Felder ausfüllen'; + errEl.classList.remove('hidden'); + return; + } + errEl.classList.add('hidden'); + document.getElementById('acc-save').disabled = true; + document.getElementById('acc-save').textContent = 'Verbinde…'; + + try { + const acc = await api.post('/caldav/accounts', { name, url, username, password, color }); + state.accounts.push(acc); + renderCalendarList(); + closeModal('modal-account'); + showToast(`Konto "${name}" hinzugefügt`); + fetchAndRender(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove('hidden'); + } finally { + document.getElementById('acc-save').disabled = false; + document.getElementById('acc-save').textContent = 'Verbinden'; + } + }; +} + +// ── Settings Modal ──────────────────────────────────────── +function openSettingsModal() { + const s = state.settings; + document.getElementById('cfg-default-view').value = s.default_view || 'month'; + document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4'; + document.getElementById('cfg-accent-color').value = s.accent_color || '#ea4335'; + document.getElementById('cfg-today-color').value = s.today_color || '#4285f4'; + document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; + + document.getElementById('cfg-primary-label').textContent = s.primary_color || '#4285f4'; + document.getElementById('cfg-accent-label').textContent = s.accent_color || '#ea4335'; + document.getElementById('cfg-today-label').textContent = s.today_color || '#4285f4'; + + // Show users section only for admins + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const usersSection = document.getElementById('settings-users-section'); + if (user.is_admin) { + usersSection.classList.remove('hidden'); + loadUsers(); + } else { + usersSection.classList.add('hidden'); + } + + openModal('modal-settings'); +} + +async function loadUsers() { + try { + const users = await api.get('/users/'); + const list = document.getElementById('users-list'); + list.innerHTML = users.map(u => + `
+
+
${escHtml(u.username)}
+ ${u.email ? `
${escHtml(u.email)}
` : ''} +
+
+ ${u.is_admin ? 'Admin' : ''} + ${u.id !== JSON.parse(localStorage.getItem('user')||'{}').id + ? `` + : ''} +
+
` + ).join(''); + + list.querySelectorAll('[data-del-user]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Benutzer löschen?')) return; + try { + await api.delete(`/users/${btn.dataset.delUser}`); + loadUsers(); + } catch (e) { showToast(e.message, true); } + }); + }); + } catch (e) { /* not admin */ } +} + +function bindSettingsModal() { + ['cfg-primary-color','cfg-accent-color','cfg-today-color'].forEach(id => { + document.getElementById(id).addEventListener('input', e => { + const labelId = id.replace('color', 'label'); + document.getElementById(labelId).textContent = e.target.value; + }); + }); + + document.getElementById('btn-add-user').onclick = () => { + document.getElementById('add-user-form').classList.toggle('hidden'); + }; + + document.getElementById('new-user-save').onclick = async () => { + const username = document.getElementById('new-username').value.trim(); + const password = document.getElementById('new-password').value; + const is_admin = document.getElementById('new-is-admin').checked; + if (!username || !password) { showToast('Benutzername und Passwort erforderlich', true); return; } + try { + await api.post('/users/', { username, password, is_admin }); + showToast(`Benutzer "${username}" erstellt`); + document.getElementById('add-user-form').classList.add('hidden'); + document.getElementById('new-username').value = ''; + document.getElementById('new-password').value = ''; + loadUsers(); + } catch (e) { showToast(e.message, true); } + }; + + document.getElementById('settings-save').onclick = async () => { + const settings = { + default_view: document.getElementById('cfg-default-view').value, + primary_color: document.getElementById('cfg-primary-color').value, + accent_color: document.getElementById('cfg-accent-color').value, + today_color: document.getElementById('cfg-today-color').value, + dim_past_events: document.getElementById('cfg-dim-past').checked, + }; + try { + await api.put('/settings/', settings); + state.settings = { ...state.settings, ...settings }; + state.dimPast = settings.dim_past_events; + applyTheme(settings); + showToast('Einstellungen gespeichert'); + closeModal('modal-settings'); + renderView(); + } catch (e) { showToast(e.message, true); } + }; +} + +// ── Modal helpers ───────────────────────────────────────── +function openModal(id) { + document.getElementById(id).classList.remove('hidden'); +} +function closeModal(id) { + document.getElementById(id).classList.add('hidden'); +} + +// Close button bindings (added once) +document.querySelectorAll('.modal-close, [data-modal]').forEach(el => { + el.addEventListener('click', () => { + const target = el.dataset.modal || el.closest('.modal-overlay')?.id; + if (target) closeModal(target); + }); +}); +document.querySelectorAll('.modal-overlay').forEach(overlay => { + overlay.addEventListener('click', e => { + if (e.target === overlay) closeModal(overlay.id); + }); +}); + +// ── Toast ───────────────────────────────────────────────── +let toastTimer = null; +export function showToast(msg, isError = false) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.className = 'toast' + (isError ? ' error' : ''); + el.classList.remove('hidden'); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.add('hidden'), 3500); +} + +// ── Helpers ─────────────────────────────────────────────── +function fmtTime(d) { + return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); +} +function fmtDatetime(d) { + return d.toLocaleString('de', { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' }); +} +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} diff --git a/frontend/js/utils.js b/frontend/js/utils.js new file mode 100644 index 0000000..aafc823 --- /dev/null +++ b/frontend/js/utils.js @@ -0,0 +1,53 @@ +export function isToday(d) { + const now = new Date(); + return d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); +} + +export function isSameDay(a, b) { + return a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); +} + +export function isPast(ev) { + const end = new Date(ev.end); + return end < new Date(); +} + +export function formatDate(d, opts = {}) { + return d.toLocaleDateString('de', opts); +} + +export function dateKey(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +export function toLocalDatetimeInput(d) { + const Y = d.getFullYear(); + const M = String(d.getMonth()+1).padStart(2,'0'); + const D = String(d.getDate()).padStart(2,'0'); + const h = String(d.getHours()).padStart(2,'0'); + const m = String(d.getMinutes()).padStart(2,'0'); + return `${Y}-${M}-${D}T${h}:${m}`; +} + +export function toDateInput(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +export function applyTheme(settings) { + const root = document.documentElement; + root.style.setProperty('--primary', settings.primary_color || '#4285f4'); + root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15)); + root.style.setProperty('--accent', settings.accent_color || '#ea4335'); + root.style.setProperty('--today-color', settings.today_color || '#4285f4'); +} + +function hexToRgba(hex, alpha) { + const r = parseInt(hex.slice(1,3), 16); + const g = parseInt(hex.slice(3,5), 16); + const b = parseInt(hex.slice(5,7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} diff --git a/frontend/js/views/agenda.js b/frontend/js/views/agenda.js new file mode 100644 index 0000000..240431e --- /dev/null +++ b/frontend/js/views/agenda.js @@ -0,0 +1,94 @@ +import { isPast } from '../utils.js'; + +const DOW = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; +const MON = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; + +export function renderAgenda(container, currentDate, events, onEventClick) { + if (!events.length) { + container.innerHTML = `
Keine Termine im angezeigten Zeitraum
`; + return; + } + + // Group events by date + const groups = {}; + events.forEach(ev => { + const d = new Date(ev.start); + const key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + if (!groups[key]) groups[key] = []; + groups[key].push(ev); + }); + + // Sort groups + const sortedKeys = Object.keys(groups).sort(); + + const html = sortedKeys.map(key => { + const date = new Date(key + 'T00:00:00'); + const isToday = isTodayDate(date); + const todayCls = isToday ? 'today' : ''; + + const evHtml = groups[key] + .sort((a, b) => { + if (a.allDay && !b.allDay) return -1; + if (!a.allDay && b.allDay) return 1; + return new Date(a.start) - new Date(b.start); + }) + .map(ev => { + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastCls = isPast(ev) ? 'past' : ''; + let timeStr = 'Ganztägig'; + if (!ev.allDay) { + const s = new Date(ev.start); + const e = new Date(ev.end); + timeStr = `${fmtTime(s)} – ${fmtTime(e)}`; + } + const locHtml = ev.location + ? ` · ${escHtml(ev.location)}` + : ''; + return `
+
+
+
${escHtml(ev.title)}
+
${timeStr}${locHtml}
+
+
`; + }).join(''); + + return `
+
+
${date.getDate()}
+
+ ${DOW[date.getDay()]} + ${MON[date.getMonth()]} ${date.getFullYear()} +
+
+ ${evHtml} +
`; + }).join(''); + + container.innerHTML = `
${html}
`; + + container.querySelectorAll('.agenda-event').forEach(el => { + el.addEventListener('click', () => { + const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); + if (ev) onEventClick(ev, el); + }); + }); +} + +function isTodayDate(d) { + const now = new Date(); + return d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); +} + +function fmtTime(d) { + return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); +} + +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} +function escAttr(s) { + return String(s).replace(/"/g,'"').replace(/'/g,'''); +} diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js new file mode 100644 index 0000000..6ff1450 --- /dev/null +++ b/frontend/js/views/month.js @@ -0,0 +1,121 @@ +import { formatDate, isSameDay, isToday, isPast } from '../utils.js'; + +const DOW = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + +export function renderMonth(container, currentDate, events, onDayClick, onEventClick) { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + // Start grid on Sunday of the week containing the 1st + const gridStart = new Date(firstDay); + gridStart.setDate(gridStart.getDate() - firstDay.getDay()); + + const cells = []; + const d = new Date(gridStart); + for (let i = 0; i < 42; i++) { + cells.push(new Date(d)); + d.setDate(d.getDate() + 1); + } + + // Build event map keyed by date string + const evMap = {}; + events.forEach(ev => { + const s = new Date(ev.start); + const e = ev.allDay ? new Date(ev.end) : new Date(ev.end); + // Spread multi-day events across cells + const cur = new Date(s); + cur.setHours(0, 0, 0, 0); + const endNorm = new Date(e); + endNorm.setHours(0, 0, 0, 0); + if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1); + while (cur <= endNorm) { + const key = dateKey(cur); + if (!evMap[key]) evMap[key] = []; + evMap[key].push(ev); + cur.setDate(cur.getDate() + 1); + } + }); + + // Header + const headerHtml = DOW.map(d => `
${d}
`).join(''); + + // Cells + const cellsHtml = cells.map(cell => { + const key = dateKey(cell); + const cellEvs = (evMap[key] || []).slice().sort((a, b) => { + if (a.allDay && !b.allDay) return -1; + if (!a.allDay && b.allDay) return 1; + return new Date(a.start) - new Date(b.start); + }); + + const isOther = cell.getMonth() !== month; + const todayClass = isToday(cell) ? 'today' : ''; + const otherClass = isOther ? 'other-month' : ''; + const numClass = isToday(cell) ? 'today' : ''; + + const MAX_VISIBLE = 3; + const visible = cellEvs.slice(0, MAX_VISIBLE); + const hiddenCount = cellEvs.length - MAX_VISIBLE; + + const evHtml = visible.map(ev => { + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastClass = isPast(ev) ? 'past' : ''; + const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`; + return `
${escHtml(title)}
`; + }).join(''); + + const moreHtml = hiddenCount > 0 + ? `
+${hiddenCount} weitere
` + : ''; + + return `
+
${cell.getDate()}
+ ${evHtml}${moreHtml} +
`; + }).join(''); + + container.innerHTML = `
+
${headerHtml}
+
${cellsHtml}
+
`; + + // Events + container.querySelectorAll('.month-cell').forEach(cell => { + cell.addEventListener('click', e => { + const evEl = e.target.closest('.month-event'); + if (evEl) { + e.stopPropagation(); + const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url); + if (ev) onEventClick(ev, evEl); + return; + } + const moreEl = e.target.closest('.month-more'); + if (moreEl) { + e.stopPropagation(); + onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); + return; + } + onDayClick(new Date(cell.dataset.date + 'T00:00:00')); + }); + }); +} + +function dateKey(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +function fmtTime(d) { + return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); +} + +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} +function escAttr(s) { + return String(s).replace(/"/g,'"').replace(/'/g,'''); +} diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js new file mode 100644 index 0000000..27018d8 --- /dev/null +++ b/frontend/js/views/week.js @@ -0,0 +1,244 @@ +import { isToday, isPast } from '../utils.js'; + +const DOW_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + +export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false) { + // Build the days array (7 days for week, 1 for day) + const days = []; + if (isSingleDay) { + days.push(new Date(currentDate)); + } else { + const sunday = new Date(currentDate); + sunday.setDate(sunday.getDate() - sunday.getDay()); + for (let i = 0; i < 7; i++) { + const d = new Date(sunday); + d.setDate(d.getDate() + i); + days.push(d); + } + } + + // Separate all-day and timed events + const allDayEvs = events.filter(ev => ev.allDay); + const timedEvs = events.filter(ev => !ev.allDay); + + // ── Header ──────────────────────────────────────────── + const headerCols = days.map(day => { + const todayCls = isToday(day) ? 'today' : ''; + return `
+
${DOW_SHORT[day.getDay()]}
+
${day.getDate()}
+
`; + }).join(''); + + // ── All-day row ─────────────────────────────────────── + const alldayCols = days.map(day => { + const key = dayKey(day); + const dayEvs = allDayEvs.filter(ev => { + const s = new Date(ev.start); s.setHours(0,0,0,0); + const e = new Date(ev.end); e.setHours(0,0,0,0); + const d = new Date(day); d.setHours(0,0,0,0); + return d >= s && d < e || isSameDay(d, s); + }); + const inner = dayEvs.map(ev => { + const color = ev.color || ev.calendarColor || '#4285f4'; + return `
${escHtml(ev.title)}
`; + }).join(''); + return `
${inner}
`; + }).join(''); + + // ── Time column labels ──────────────────────────────── + const timeLabels = Array.from({length: 24}, (_, h) => + `
${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}
` + ).join(''); + + // ── Day columns ─────────────────────────────────────── + // For each day, lay out timed events + const dayCols = days.map(day => { + const key = dayKey(day); + const dayEvs = timedEvs.filter(ev => { + const s = new Date(ev.start); + return isSameDay(s, day); + }); + + // Compute layout columns for overlapping events + const positioned = layoutEvents(dayEvs); + + const hourLines = Array.from({length: 24}, (_, h) => + `
` + ).join(''); + + const evHtml = positioned.map(({ ev, col, cols }) => { + const s = new Date(ev.start); + const e = new Date(ev.end); + const top = (s.getHours() * 60 + s.getMinutes()); + const height = Math.max(20, (e - s) / 60000); + const left = (col / cols) * 100; + const width = (1 / cols) * 100 - 0.5; + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastCls = isPast(ev) ? 'past' : ''; + const startStr = fmtTime(s); + const locHtml = ev.location ? `
${escHtml(ev.location)}
` : ''; + return `
+
${startStr}
+
${escHtml(ev.title)}
+ ${locHtml} +
`; + }).join(''); + + return `
+ ${hourLines} + ${evHtml} +
`; + }).join(''); + + const viewClass = isSingleDay ? 'day-view' : 'week-view'; + + container.innerHTML = `
+
+
+ ${headerCols} +
+
+
ganztägig
+
${alldayCols}
+
+
+
${timeLabels}
+
${dayCols}
+
+
`; + + // Scroll to ~8:00 + const body = container.querySelector('.week-body'); + if (body) body.scrollTop = 8 * 60 - 20; + + // Render current-time line + renderNowLine(container, days); + + // Click: slot + container.querySelectorAll('.week-day-col').forEach(col => { + col.addEventListener('click', e => { + if (e.target.closest('.timed-event')) return; + const rect = col.getBoundingClientRect(); + const y = e.clientY - rect.top + (container.querySelector('.week-body')?.scrollTop || 0); + const mins = Math.floor(y); + const h = Math.floor(mins / 60); + const m = Math.round((mins % 60) / 15) * 15; + const date = new Date(col.dataset.date + 'T00:00:00'); + date.setHours(h, m, 0, 0); + onSlotClick(date); + }); + }); + + // Click: header (navigate to day) + container.querySelectorAll('.week-day-header').forEach(el => { + el.addEventListener('click', () => { + onSlotClick(new Date(el.dataset.date + 'T09:00:00'), true); + }); + }); + + // Click: timed event + container.querySelectorAll('.timed-event').forEach(el => { + el.addEventListener('click', e => { + e.stopPropagation(); + const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); + if (ev) onEventClick(ev, el); + }); + }); + + // Click: all-day event + container.querySelectorAll('.allday-event').forEach(el => { + el.addEventListener('click', e => { + e.stopPropagation(); + const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); + if (ev) onEventClick(ev, el); + }); + }); +} + +function renderNowLine(container, days) { + const now = new Date(); + const todayCol = container.querySelector(`.week-day-col[data-date="${dayKey(now)}"]`); + if (!todayCol) return; + + const top = now.getHours() * 60 + now.getMinutes(); + const line = document.createElement('div'); + line.className = 'now-line'; + line.style.top = top + 'px'; + line.innerHTML = '
'; + todayCol.appendChild(line); + + // Update every minute + setTimeout(() => renderNowLine(container, days), 60000); +} + +function layoutEvents(events) { + if (!events.length) return []; + + // Sort by start time + const sorted = events.slice().sort((a, b) => new Date(a.start) - new Date(b.start)); + const columns = []; // each column is an array of events + + const result = sorted.map(ev => { + const start = new Date(ev.start); + const end = new Date(ev.end); + + // Find the first column where the event doesn't overlap + let placed = false; + for (let c = 0; c < columns.length; c++) { + const lastInCol = columns[c][columns[c].length - 1]; + if (new Date(lastInCol.end) <= start) { + columns[c].push(ev); + placed = true; + ev._col = c; + break; + } + } + if (!placed) { + ev._col = columns.length; + columns.push([ev]); + } + return ev; + }); + + // Calculate how many columns each event spans + return result.map(ev => { + const start = new Date(ev.start); + const end = new Date(ev.end); + // Count overlapping events + let maxCol = ev._col; + sorted.forEach(other => { + if (other === ev) return; + const os = new Date(other.start); + const oe = new Date(other.end); + if (os < end && oe > start) { + maxCol = Math.max(maxCol, other._col ?? 0); + } + }); + return { ev, col: ev._col, cols: maxCol + 1 }; + }); +} + +function dayKey(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +function isSameDay(a, b) { + return a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); +} + +function fmtTime(d) { + return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); +} + +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} +function escAttr(s) { + return String(s).replace(/"/g,'"').replace(/'/g,'''); +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..36bc342 --- /dev/null +++ b/install.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Calendarr Installer ===" +echo "Debian/Ubuntu kompatibel" +echo "" + +# ── System dependencies ─────────────────────────────────── +echo "[1/4] Installiere Systemabhängigkeiten..." +apt-get update -q +apt-get install -y -q python3 python3-pip python3-venv python3-dev libxml2-dev libxslt1-dev + +# ── Virtual environment ─────────────────────────────────── +echo "[2/4] Erstelle Python-Virtualenv..." +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip -q + +# ── Python packages ─────────────────────────────────────── +echo "[3/4] Installiere Python-Pakete..." +pip install -r requirements.txt -q + +# ── Configuration ───────────────────────────────────────── +echo "[4/4] Konfiguration..." +mkdir -p data + +if [ ! -f .env ]; then + SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))") + cat > .env <&2 + exit 1 +fi + +cd "$(dirname "$0")" +python3 backend/main.py