initialer commit, Grundcode

This commit is contained in:
2026-03-26 11:20:48 +01:00
commit f029ed1544
25 changed files with 3530 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
venv/
data/
__pycache__/
*.pyc
.env
*.db
.DS_Store

0
backend/__init__.py Normal file
View File

66
backend/auth.py Normal file
View File

@@ -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

267
backend/caldav_client.py Normal file
View File

@@ -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

24
backend/database.py Normal file
View File

@@ -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()

43
backend/main.py Normal file
View File

@@ -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)

65
backend/models.py Normal file
View File

@@ -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")

View File

View File

@@ -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,
}

View File

@@ -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}")

View File

@@ -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}

View File

@@ -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}

17
calendarr.service Normal file
View File

@@ -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

574
frontend/css/app.css Normal file
View File

@@ -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; }
}

364
frontend/index.html Normal file
View File

@@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendarr</title>
<link rel="stylesheet" href="/static/css/app.css" />
</head>
<body>
<!-- ─── SETUP SCREEN ─────────────────────────────────────── -->
<div id="screen-setup" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-logo">
<span class="logo-icon">📅</span>
<h1>Calendarr</h1>
</div>
<h2>Ersteinrichtung</h2>
<p class="auth-sub">Erstelle den Administrator-Account</p>
<form id="setup-form">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="setup-username" required autocomplete="username" />
</div>
<div class="form-group">
<label>E-Mail (optional)</label>
<input type="email" id="setup-email" autocomplete="email" />
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="setup-password" required autocomplete="new-password" />
</div>
<div class="form-group">
<label>Passwort wiederholen</label>
<input type="password" id="setup-password2" required autocomplete="new-password" />
</div>
<div id="setup-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Admin-Account erstellen</button>
</form>
</div>
</div>
<!-- ─── LOGIN SCREEN ─────────────────────────────────────── -->
<div id="screen-login" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-logo">
<span class="logo-icon">📅</span>
<h1>Calendarr</h1>
</div>
<h2>Anmelden</h2>
<form id="login-form">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="login-username" required autocomplete="username" />
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="login-password" required autocomplete="current-password" />
</div>
<div id="login-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
</div>
</div>
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
<div id="app" class="hidden">
<!-- TOP BAR -->
<header class="topbar">
<div class="topbar-left">
<button class="icon-btn" id="sidebar-toggle" title="Seitenleiste">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
</button>
<div class="topbar-logo">
<span>📅</span>
<span class="logo-text">Calendarr</span>
</div>
</div>
<div class="topbar-center">
<button class="btn btn-ghost" id="btn-today">Heute</button>
<button class="icon-btn" id="btn-prev">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<button class="icon-btn" id="btn-next">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
<h2 id="view-title" class="view-title"></h2>
</div>
<div class="topbar-right">
<div class="view-switcher">
<button class="view-btn" data-view="month">Monat</button>
<button class="view-btn" data-view="week">Woche</button>
<button class="view-btn" data-view="day">Tag</button>
<button class="view-btn" data-view="agenda">Termine</button>
</div>
<button class="icon-btn" id="btn-settings" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
</button>
<div class="user-avatar" id="user-avatar" title="Benutzer"></div>
</div>
</header>
<!-- CONTENT -->
<div class="content-wrapper">
<!-- SIDEBAR -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-inner">
<button class="btn btn-fab" id="btn-create-event">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<span>Erstellen</span>
</button>
<!-- Mini Calendar -->
<div class="mini-cal" id="mini-cal">
<div class="mini-cal-header">
<button class="icon-btn mini-btn" id="mini-prev">&#8249;</button>
<span id="mini-title" class="mini-title"></span>
<button class="icon-btn mini-btn" id="mini-next">&#8250;</button>
</div>
<div class="mini-cal-grid">
<div class="mini-dow">So</div><div class="mini-dow">Mo</div>
<div class="mini-dow">Di</div><div class="mini-dow">Mi</div>
<div class="mini-dow">Do</div><div class="mini-dow">Fr</div>
<div class="mini-dow">Sa</div>
</div>
<div class="mini-cal-days" id="mini-days"></div>
</div>
<!-- Calendar List -->
<div class="cal-list" id="cal-list">
<div class="cal-list-header">
<span>Meine Kalender</span>
<button class="icon-btn mini-btn" id="btn-add-account" title="CalDAV-Konto hinzufügen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
</div>
<div id="cal-list-items"></div>
</div>
</div>
</aside>
<!-- MAIN VIEW -->
<main class="main-view" id="main-view">
<div id="view-container"></div>
</main>
</div><!-- content-wrapper -->
</div><!-- app -->
<!-- ─── MODALS ────────────────────────────────────────────── -->
<!-- Event Modal -->
<div id="modal-event" class="modal-overlay hidden">
<div class="modal-card" style="max-width:520px">
<div class="modal-header">
<h3 id="modal-event-title-label">Termin erstellen</h3>
<button class="icon-btn modal-close" data-modal="modal-event">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<input type="text" id="ev-title" placeholder="Titel hinzufügen" class="input-title" />
</div>
<div class="form-row">
<label class="toggle-label">
<input type="checkbox" id="ev-allday" /> Ganztägig
</label>
</div>
<div class="form-row" id="ev-time-row">
<div class="form-group half">
<label>Start</label>
<input type="datetime-local" id="ev-start" />
</div>
<div class="form-group half">
<label>Ende</label>
<input type="datetime-local" id="ev-end" />
</div>
</div>
<div class="form-row" id="ev-date-row" style="display:none">
<div class="form-group half">
<label>Start</label>
<input type="date" id="ev-start-date" />
</div>
<div class="form-group half">
<label>Ende</label>
<input type="date" id="ev-end-date" />
</div>
</div>
<div class="form-group">
<label>Kalender</label>
<select id="ev-calendar"></select>
</div>
<div class="form-group">
<label>Ort</label>
<input type="text" id="ev-location" placeholder="Ort hinzufügen" />
</div>
<div class="form-group">
<label>Beschreibung</label>
<textarea id="ev-description" placeholder="Beschreibung hinzufügen" rows="3"></textarea>
</div>
<div class="form-group">
<label>Farbe</label>
<div class="color-picker" id="ev-color-picker">
<div class="color-swatch active" data-color="" style="background:var(--cal-color,#4285f4)" title="Kalenderfarbe"></div>
<div class="color-swatch" data-color="#4285f4" style="background:#4285f4"></div>
<div class="color-swatch" data-color="#ea4335" style="background:#ea4335"></div>
<div class="color-swatch" data-color="#fbbc04" style="background:#fbbc04"></div>
<div class="color-swatch" data-color="#34a853" style="background:#34a853"></div>
<div class="color-swatch" data-color="#ff6d00" style="background:#ff6d00"></div>
<div class="color-swatch" data-color="#46bdc6" style="background:#46bdc6"></div>
<div class="color-swatch" data-color="#8e24aa" style="background:#8e24aa"></div>
<div class="color-swatch" data-color="#e67c73" style="background:#e67c73"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger hidden" id="ev-delete">Löschen</button>
<div style="flex:1"></div>
<button class="btn btn-ghost" data-modal="modal-event">Abbrechen</button>
<button class="btn btn-primary" id="ev-save">Speichern</button>
</div>
</div>
</div>
<!-- Event Detail Popup -->
<div id="popup-event" class="event-popup hidden">
<div class="popup-header">
<div class="popup-color-dot" id="popup-color-dot"></div>
<h4 id="popup-title"></h4>
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="icon-btn popup-action" id="popup-delete" title="Löschen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
<button class="icon-btn popup-close" id="popup-close">&times;</button>
</div>
<div class="popup-body">
<div class="popup-time" id="popup-time"></div>
<div class="popup-location" id="popup-location"></div>
<div class="popup-description" id="popup-description"></div>
<div class="popup-calendar" id="popup-calendar"></div>
</div>
</div>
<!-- Add CalDAV Account Modal -->
<div id="modal-account" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3>CalDAV-Konto hinzufügen</h3>
<button class="icon-btn modal-close" data-modal="modal-account">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Anzeigename</label>
<input type="text" id="acc-name" placeholder="z.B. Mein Nextcloud" />
</div>
<div class="form-group">
<label>CalDAV-URL</label>
<input type="url" id="acc-url" placeholder="https://cloud.example.com/remote.php/dav" />
</div>
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="acc-username" autocomplete="username" />
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="acc-password" autocomplete="current-password" />
</div>
<div class="form-group">
<label>Farbe</label>
<input type="color" id="acc-color" value="#4285f4" class="color-input" />
</div>
<div id="acc-error" class="form-error hidden"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-account">Abbrechen</button>
<button class="btn btn-primary" id="acc-save">Verbinden</button>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="modal-settings" class="modal-overlay hidden">
<div class="modal-card" style="max-width:520px">
<div class="modal-header">
<h3>Einstellungen</h3>
<button class="icon-btn modal-close" data-modal="modal-settings">&times;</button>
</div>
<div class="modal-body">
<div class="settings-section">
<h4>Darstellung</h4>
<div class="form-group">
<label>Standardansicht</label>
<select id="cfg-default-view">
<option value="month">Monat</option>
<option value="week">Woche</option>
<option value="day">Tag</option>
<option value="agenda">Termine</option>
</select>
</div>
<div class="form-group">
<label>Primärfarbe</label>
<div class="color-row">
<input type="color" id="cfg-primary-color" class="color-input" />
<span class="color-label" id="cfg-primary-label">#4285f4</span>
</div>
</div>
<div class="form-group">
<label>Akzentfarbe</label>
<div class="color-row">
<input type="color" id="cfg-accent-color" class="color-input" />
<span class="color-label" id="cfg-accent-label">#ea4335</span>
</div>
</div>
<div class="form-group">
<label>Heutige-Tag-Farbe</label>
<div class="color-row">
<input type="color" id="cfg-today-color" class="color-input" />
<span class="color-label" id="cfg-today-label">#4285f4</span>
</div>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="cfg-dim-past" />
Vergangene Termine ausgrauen
</label>
</div>
</div>
<div class="settings-section" id="settings-users-section">
<h4>Benutzerverwaltung <span class="badge-admin">Admin</span></h4>
<div id="users-list"></div>
<button class="btn btn-secondary" id="btn-add-user">Benutzer hinzufügen</button>
<div id="add-user-form" class="hidden" style="margin-top:12px">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="new-username" />
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="new-password" />
</div>
<label class="toggle-label" style="margin-bottom:8px">
<input type="checkbox" id="new-is-admin" /> Administrator
</label>
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-settings">Schließen</button>
<button class="btn btn-primary" id="settings-save">Speichern</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>

49
frontend/js/api.js Normal file
View File

@@ -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),
};

129
frontend/js/app.js Normal file
View File

@@ -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();

719
frontend/js/calendar.js Normal file
View File

@@ -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 =
`<div class="loading-view"><div class="spinner"></div></div>`;
}
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 `<div class="${cls}" data-date="${dayKey(day)}">${day.getDate()}</div>`;
}).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 = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Kein CalDAV-Konto</div>`;
return;
}
const html = state.accounts.map(acc =>
`<div class="cal-account-name">${escHtml(acc.name)}</div>` +
acc.calendars.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" />
<div class="cal-item-dot" style="background:${cal.color}"></div>
<span class="cal-item-name">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" title="Konto entfernen">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`
).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 =>
`<div class="users-list-item">
<div>
<div class="uname">${escHtml(u.username)}</div>
${u.email ? `<div class="uemail">${escHtml(u.email)}</div>` : ''}
</div>
<div style="display:flex;gap:8px;align-items:center">
${u.is_admin ? '<span class="ubadge">Admin</span>' : ''}
${u.id !== JSON.parse(localStorage.getItem('user')||'{}').id
? `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" data-del-user="${u.id}">Löschen</button>`
: ''}
</div>
</div>`
).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

53
frontend/js/utils.js Normal file
View File

@@ -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})`;
}

View File

@@ -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 = `<div class="agenda-view"><div class="agenda-empty">Keine Termine im angezeigten Zeitraum</div></div>`;
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
? `<span class="agenda-ev-meta"> · ${escHtml(ev.location)}</span>`
: '';
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
<div class="agenda-ev-color" style="background:${color}"></div>
<div class="agenda-ev-info">
<div class="agenda-ev-title">${escHtml(ev.title)}</div>
<div class="agenda-ev-meta">${timeStr}${locHtml}</div>
</div>
</div>`;
}).join('');
return `<div class="agenda-day">
<div class="agenda-date ${todayCls}">
<div class="agenda-date-num">${date.getDate()}</div>
<div class="agenda-date-label">
<span class="wd">${DOW[date.getDay()]}</span>
<span class="mo">${MON[date.getMonth()]} ${date.getFullYear()}</span>
</div>
</div>
${evHtml}
</div>`;
}).join('');
container.innerHTML = `<div class="agenda-view">${html}</div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

121
frontend/js/views/month.js Normal file
View File

@@ -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 => `<div class="month-dow">${d}</div>`).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 `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
style="background:${color};color:#fff"
title="${escAttr(ev.title)}">${escHtml(title)}</div>`;
}).join('');
const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
: '';
return `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
<div class="cell-day ${numClass}">${cell.getDate()}</div>
${evHtml}${moreHtml}
</div>`;
}).join('');
container.innerHTML = `<div class="month-view">
<div class="month-header">${headerHtml}</div>
<div class="month-grid">${cellsHtml}</div>
</div>`;
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

244
frontend/js/views/week.js Normal file
View File

@@ -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 `<div class="week-day-header ${todayCls}" data-date="${dayKey(day)}">
<div class="day-name">${DOW_SHORT[day.getDay()]}</div>
<div class="day-num">${day.getDate()}</div>
</div>`;
}).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 `<div class="allday-event" style="background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(ev.title)}</div>`;
}).join('');
return `<div class="allday-col" data-date="${key}">${inner}</div>`;
}).join('');
// ── Time column labels ────────────────────────────────
const timeLabels = Array.from({length: 24}, (_, h) =>
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
).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) =>
`<div class="hour-line" style="top:${h * 60}px"><div class="half-line"></div></div>`
).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 ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
return `<div class="timed-event ${pastCls}"
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
<div class="ev-time">${startStr}</div>
<div class="ev-title">${escHtml(ev.title)}</div>
${locHtml}
</div>`;
}).join('');
return `<div class="week-day-col" data-date="${key}" style="height:${60*24}px">
${hourLines}
${evHtml}
</div>`;
}).join('');
const viewClass = isSingleDay ? 'day-view' : 'week-view';
container.innerHTML = `<div class="${viewClass}">
<div class="week-header-row">
<div class="week-time-gutter"></div>
${headerCols}
</div>
<div class="week-allday-row">
<div class="allday-gutter">ganztägig</div>
<div class="allday-cols">${alldayCols}</div>
</div>
<div class="week-body">
<div class="week-time-col">${timeLabels}</div>
<div class="week-days-col">${dayCols}</div>
</div>
</div>`;
// 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 = '<div class="now-dot"></div>';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

53
install.sh Normal file
View File

@@ -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 <<EOF
# Calendarr Konfiguration
SECRET_KEY=${SECRET}
PORT=8080
HOST=0.0.0.0
DATA_DIR=./data
EOF
echo " .env Datei mit zufälligem Secret erstellt"
else
echo " .env Datei bereits vorhanden — wird nicht überschrieben"
fi
chmod +x start.sh
echo ""
echo "=== Installation abgeschlossen ==="
echo ""
echo "Starten mit: ./start.sh"
echo " oder: PORT=9000 ./start.sh"
echo ""
echo "Für systemd-Dienst:"
echo " sudo cp calendarr.service /etc/systemd/system/"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable --now calendarr"
echo ""

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.12
caldav==1.3.9
icalendar==5.0.12
requests==2.32.3

26
start.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Load .env if present
if [ -f .env ]; then
set -a
source .env
set +a
fi
export PORT="${PORT:-8080}"
export HOST="${HOST:-0.0.0.0}"
export DATA_DIR="${DATA_DIR:-./data}"
echo "Starte Calendarr auf http://${HOST}:${PORT}"
# Activate venv
if [ -f venv/bin/activate ]; then
source venv/bin/activate
else
echo "Fehler: Virtualenv nicht gefunden. Bitte zuerst ./install.sh ausführen." >&2
exit 1
fi
cd "$(dirname "$0")"
python3 backend/main.py