initialer commit, Grundcode
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
venv/
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.db
|
||||
.DS_Store
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
66
backend/auth.py
Normal file
66
backend/auth.py
Normal 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
267
backend/caldav_client.py
Normal 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
24
backend/database.py
Normal 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
43
backend/main.py
Normal 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
65
backend/models.py
Normal 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")
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
73
backend/routers/auth_router.py
Normal file
73
backend/routers/auth_router.py
Normal 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,
|
||||
}
|
||||
375
backend/routers/caldav_router.py
Normal file
375
backend/routers/caldav_router.py
Normal 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}")
|
||||
69
backend/routers/settings_router.py
Normal file
69
backend/routers/settings_router.py
Normal 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}
|
||||
89
backend/routers/users_router.py
Normal file
89
backend/routers/users_router.py
Normal 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
17
calendarr.service
Normal 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
574
frontend/css/app.css
Normal 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
364
frontend/index.html
Normal 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">‹</button>
|
||||
<span id="mini-title" class="mini-title"></span>
|
||||
<button class="icon-btn mini-btn" id="mini-next">›</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">×</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">×</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">×</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">×</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
49
frontend/js/api.js
Normal 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
129
frontend/js/app.js
Normal 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
719
frontend/js/calendar.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
53
frontend/js/utils.js
Normal file
53
frontend/js/utils.js
Normal 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})`;
|
||||
}
|
||||
94
frontend/js/views/agenda.js
Normal file
94
frontend/js/views/agenda.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
121
frontend/js/views/month.js
Normal file
121
frontend/js/views/month.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
244
frontend/js/views/week.js
Normal file
244
frontend/js/views/week.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
53
install.sh
Normal file
53
install.sh
Normal 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
9
requirements.txt
Normal 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
26
start.sh
Normal 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
|
||||
Reference in New Issue
Block a user