Lokale Kalender und iCal-URL-Abonnements

Neue Features:
- Lokale Kalender erstellen mit vollem Event-CRUD (in SQLite gespeichert)
- iCal-URLs abonnieren mit Auto-Refresh und lokalem Caching
- iCal-Events sind editierbar/löschbar (Änderungen als lokale Overrides)
- Sidebar zeigt alle 3 Kalendertypen mit Farbe, Umbenennen, Löschen
- Dropdown "Kalender hinzufügen" mit 3 Optionen (Lokal, CalDAV, iCal)
Backend: models.py (4 neue Tabellen), local_router.py, ical_router.py
Frontend: Neue Modals, erweiterte Sidebar, Source-basiertes Event-Routing
This commit is contained in:
2026-03-27 07:39:41 +01:00
parent b2bc107d47
commit cd46b45ec6
8 changed files with 1129 additions and 45 deletions

View File

@@ -12,7 +12,7 @@ from sqlalchemy import text
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
from routers import auth_router, caldav_router, profile_router, settings_router, users_router from routers import auth_router, caldav_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -41,6 +41,8 @@ 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(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -21,6 +21,12 @@ class User(Base):
settings = relationship( settings = relationship(
"UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan" "UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan"
) )
local_calendars = relationship(
"LocalCalendar", back_populates="user", cascade="all, delete-orphan"
)
ical_subscriptions = relationship(
"ICalSubscription", back_populates="user", cascade="all, delete-orphan"
)
class CalDAVAccount(Base): class CalDAVAccount(Base):
@@ -67,3 +73,68 @@ class UserSettings(Base):
dim_past_events = Column(Boolean, default=False) dim_past_events = Column(Boolean, default=False)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
class LocalCalendar(Base):
__tablename__ = "local_calendars"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
color = Column(String(7), default="#34a853")
enabled = Column(Boolean, default=True)
user = relationship("User", back_populates="local_calendars")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
class LocalEvent(Base):
__tablename__ = "local_events"
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
uid = Column(String(255), nullable=False, unique=True)
title = Column(String(255), nullable=False)
start = Column(String(50), nullable=False)
end = Column(String(50), nullable=False)
all_day = Column(Boolean, default=False)
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
calendar = relationship("LocalCalendar", back_populates="events")
class ICalSubscription(Base):
__tablename__ = "ical_subscriptions"
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(1000), nullable=False)
color = Column(String(7), default="#46bdc6")
enabled = Column(Boolean, default=True)
refresh_minutes = Column(Integer, default=60)
last_fetched = Column(DateTime, nullable=True)
cached_ics = Column(Text, nullable=True)
user = relationship("User", back_populates="ical_subscriptions")
overrides = relationship("ICalOverride", back_populates="subscription", cascade="all, delete-orphan")
class ICalOverride(Base):
__tablename__ = "ical_overrides"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("ical_subscriptions.id"), nullable=False)
event_uid = Column(String(500), nullable=False)
hidden = Column(Boolean, default=False)
title = Column(String(255), nullable=True)
start = Column(String(50), nullable=True)
end = Column(String(50), nullable=True)
all_day = Column(Boolean, nullable=True)
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
subscription = relationship("ICalSubscription", back_populates="overrides")

View File

@@ -9,6 +9,7 @@ import caldav_client
import models import models
from auth import get_current_user from auth import get_current_user
from database import get_db from database import get_db
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -283,6 +284,58 @@ def get_events(
"Error fetching calendar %s: %s", calendar.id, exc "Error fetching calendar %s: %s", calendar.id, exc
) )
# ── Local calendar events ─────────────────────────────
local_calendars = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.enabled == True,
)
.all()
)
for local_cal in local_calendars:
local_events = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == local_cal.id,
models.LocalEvent.start < end,
models.LocalEvent.end > start,
)
.all()
)
for ev in local_events:
all_events.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": ev.start,
"end": ev.end,
"allDay": ev.all_day,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
# ── iCal subscription events ──────────────────────────
ical_subs = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.user_id == current_user.id,
models.ICalSubscription.enabled == True,
)
.all()
)
for sub in ical_subs:
try:
_refresh_if_needed(sub, db)
all_events.extend(get_events_for_subscription(sub, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching iCal subscription %s: %s", sub.id, exc)
return all_events return all_events

View File

@@ -0,0 +1,359 @@
import logging
from datetime import datetime, timezone
from typing import Optional
import requests as http_requests
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from caldav_client import _parse_ics
from database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
class SubscriptionCreate(BaseModel):
name: str
url: str
color: str = "#46bdc6"
refresh_minutes: int = 60
class SubscriptionUpdate(BaseModel):
name: Optional[str] = None
url: Optional[str] = None
color: Optional[str] = None
enabled: Optional[bool] = None
refresh_minutes: Optional[int] = None
def _sub_dict(sub: models.ICalSubscription) -> dict:
return {
"id": sub.id,
"name": sub.name,
"url": sub.url,
"color": sub.color,
"enabled": sub.enabled,
"refresh_minutes": sub.refresh_minutes,
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
}
def _fetch_ics(url: str) -> str:
"""Download .ics content from a URL."""
try:
resp = http_requests.get(url, timeout=30, allow_redirects=True)
resp.raise_for_status()
return resp.text
except http_requests.RequestException as e:
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
def _refresh_if_needed(sub: models.ICalSubscription, db: Session, force: bool = False):
"""Refresh cached ICS data if stale or forced."""
now = datetime.now(timezone.utc)
if (
not force
and sub.cached_ics
and sub.last_fetched
and (now - sub.last_fetched.replace(tzinfo=timezone.utc)).total_seconds()
< sub.refresh_minutes * 60
):
return # Cache still fresh
try:
sub.cached_ics = _fetch_ics(sub.url)
sub.last_fetched = now
db.commit()
except ValueError:
logger.warning("Could not refresh iCal subscription %s", sub.id)
if not sub.cached_ics:
raise
def get_events_for_subscription(
sub: models.ICalSubscription, start_dt: datetime, end_dt: datetime, db: Session = None
) -> list:
"""Parse cached ICS and filter events within the date range, applying local overrides."""
if not sub.cached_ics:
return []
# Load overrides for this subscription
overrides = {}
if db:
for ov in (
db.query(models.ICalOverride)
.filter(models.ICalOverride.subscription_id == sub.id)
.all()
):
overrides[ov.event_uid] = ov
all_events = _parse_ics(sub.cached_ics, f"ical://{sub.id}")
filtered = []
for ev in all_events:
uid = ev["id"]
# Skip hidden (deleted) events
ov = overrides.get(uid)
if ov and ov.hidden:
continue
# Apply overrides
if ov:
if ov.title is not None:
ev["title"] = ov.title
if ov.start is not None:
ev["start"] = ov.start
if ov.end is not None:
ev["end"] = ov.end
if ov.all_day is not None:
ev["allDay"] = ov.all_day
if ov.location is not None:
ev["location"] = ov.location
if ov.description is not None:
ev["description"] = ov.description
if ov.color is not None:
ev["color"] = ov.color
try:
ev_start = ev["start"]
ev_end = ev["end"]
if ev["allDay"]:
if ev_end > start_dt.strftime("%Y-%m-%d") and ev_start < end_dt.strftime("%Y-%m-%d"):
filtered.append(ev)
else:
ev_s = datetime.fromisoformat(ev_start.replace("Z", "+00:00"))
ev_e = datetime.fromisoformat(ev_end.replace("Z", "+00:00"))
if ev_s.tzinfo is None:
ev_s = ev_s.replace(tzinfo=timezone.utc)
if ev_e.tzinfo is None:
ev_e = ev_e.replace(tzinfo=timezone.utc)
if ev_e > start_dt and ev_s < end_dt:
filtered.append(ev)
except Exception:
filtered.append(ev)
for ev in filtered:
ev["calendar_id"] = f"ical-{sub.id}"
ev["calendar_name"] = sub.name
ev["calendarColor"] = sub.color
ev["source"] = "ical"
return filtered
# ── Subscription CRUD ─────────────────────────────────────
@router.get("/subscriptions")
def list_subscriptions(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
subs = (
db.query(models.ICalSubscription)
.filter(models.ICalSubscription.user_id == current_user.id)
.all()
)
return [_sub_dict(s) for s in subs]
@router.post("/subscriptions")
def create_subscription(
data: SubscriptionCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Validate URL by fetching it
try:
ics_content = _fetch_ics(data.url)
except ValueError as e:
raise HTTPException(400, str(e))
sub = models.ICalSubscription(
user_id=current_user.id,
name=data.name,
url=data.url,
color=data.color,
refresh_minutes=data.refresh_minutes,
cached_ics=ics_content,
last_fetched=datetime.now(timezone.utc),
)
db.add(sub)
db.commit()
db.refresh(sub)
return _sub_dict(sub)
@router.put("/subscriptions/{sub_id}")
def update_subscription(
sub_id: int,
data: SubscriptionUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sub = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.id == sub_id,
models.ICalSubscription.user_id == current_user.id,
)
.first()
)
if not sub:
raise HTTPException(404, "Subscription not found")
if data.name is not None:
sub.name = data.name
if data.url is not None:
sub.url = data.url
if data.color is not None:
sub.color = data.color
if data.enabled is not None:
sub.enabled = data.enabled
if data.refresh_minutes is not None:
sub.refresh_minutes = data.refresh_minutes
db.commit()
return {"ok": True}
@router.delete("/subscriptions/{sub_id}")
def delete_subscription(
sub_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sub = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.id == sub_id,
models.ICalSubscription.user_id == current_user.id,
)
.first()
)
if not sub:
raise HTTPException(404, "Subscription not found")
db.delete(sub)
db.commit()
return {"ok": True}
@router.post("/subscriptions/{sub_id}/refresh")
def refresh_subscription(
sub_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sub = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.id == sub_id,
models.ICalSubscription.user_id == current_user.id,
)
.first()
)
if not sub:
raise HTTPException(404, "Subscription not found")
try:
_refresh_if_needed(sub, db, force=True)
except ValueError as e:
raise HTTPException(400, str(e))
return _sub_dict(sub)
# ── Event overrides (edit/delete iCal events locally) ─────
class ICalEventUpdate(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
@router.put("/events/{sub_id}/{event_uid}")
def update_ical_event(
sub_id: int,
event_uid: str,
data: ICalEventUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sub = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.id == sub_id,
models.ICalSubscription.user_id == current_user.id,
)
.first()
)
if not sub:
raise HTTPException(404, "Subscription not found")
ov = (
db.query(models.ICalOverride)
.filter(
models.ICalOverride.subscription_id == sub_id,
models.ICalOverride.event_uid == event_uid,
)
.first()
)
if not ov:
ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid)
db.add(ov)
if data.title is not None:
ov.title = data.title
if data.start is not None:
ov.start = data.start
if data.end is not None:
ov.end = data.end
if data.allDay is not None:
ov.all_day = data.allDay
if data.location is not None:
ov.location = data.location
if data.description is not None:
ov.description = data.description
if data.color is not None:
ov.color = data.color
db.commit()
return {"ok": True}
@router.delete("/events/{sub_id}/{event_uid}")
def delete_ical_event(
sub_id: int,
event_uid: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sub = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.id == sub_id,
models.ICalSubscription.user_id == current_user.id,
)
.first()
)
if not sub:
raise HTTPException(404, "Subscription not found")
ov = (
db.query(models.ICalOverride)
.filter(
models.ICalOverride.subscription_id == sub_id,
models.ICalOverride.event_uid == event_uid,
)
.first()
)
if not ov:
ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid, hidden=True)
db.add(ov)
else:
ov.hidden = True
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,245 @@
import uuid
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_user
from database import get_db
router = APIRouter()
class CalendarCreate(BaseModel):
name: str
color: str = "#34a853"
class CalendarUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
enabled: Optional[bool] = 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 _cal_dict(cal: models.LocalCalendar) -> dict:
return {
"id": cal.id,
"name": cal.name,
"color": cal.color,
"enabled": cal.enabled,
}
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
return {
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": ev.start,
"end": ev.end,
"allDay": ev.all_day,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"calendar_id": f"local-{cal.id}",
"calendar_name": cal.name,
"calendarColor": cal.color,
"source": "local",
}
# ── Calendar CRUD ─────────────────────────────────────────
@router.get("/calendars")
def list_calendars(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cals = (
db.query(models.LocalCalendar)
.filter(models.LocalCalendar.user_id == current_user.id)
.all()
)
return [_cal_dict(c) for c in cals]
@router.post("/calendars")
def create_calendar(
data: CalendarCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = models.LocalCalendar(
user_id=current_user.id,
name=data.name,
color=data.color,
)
db.add(cal)
db.commit()
db.refresh(cal)
return _cal_dict(cal)
@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),
):
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if data.name is not None:
cal.name = data.name
if data.color is not None:
cal.color = data.color
if data.enabled is not None:
cal.enabled = data.enabled
db.commit()
return {"ok": True}
@router.delete("/calendars/{calendar_id}")
def delete_calendar(
calendar_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
db.delete(cal)
db.commit()
return {"ok": True}
# ── Event CRUD ────────────────────────────────────────────
@router.post("/events")
def create_event(
data: EventCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == data.calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
ev = models.LocalEvent(
calendar_id=cal.id,
uid=str(uuid.uuid4()),
title=data.title,
start=data.start,
end=data.end,
all_day=data.allDay,
location=data.location,
description=data.description,
color=data.color,
)
db.add(ev)
db.commit()
db.refresh(ev)
return _event_dict(ev, cal)
@router.put("/events/{uid}")
def update_event(
uid: str,
data: EventUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ev = (
db.query(models.LocalEvent)
.join(models.LocalCalendar)
.filter(
models.LocalEvent.uid == uid,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not ev:
raise HTTPException(404, "Event not found")
if data.title is not None:
ev.title = data.title
if data.start is not None:
ev.start = data.start
if data.end is not None:
ev.end = data.end
if data.allDay is not None:
ev.all_day = data.allDay
if data.location is not None:
ev.location = data.location
if data.description is not None:
ev.description = data.description
if data.color is not None:
ev.color = data.color
db.commit()
return {"ok": True}
@router.delete("/events/{uid}")
def delete_event(
uid: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ev = (
db.query(models.LocalEvent)
.join(models.LocalCalendar)
.filter(
models.LocalEvent.uid == uid,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not ev:
raise HTTPException(404, "Event not found")
db.delete(ev)
db.commit()
return {"ok": True}

View File

@@ -379,6 +379,19 @@ a { color: var(--primary); text-decoration: none; }
font-size: 12px; font-weight: 600; text-transform: uppercase; font-size: 12px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-2); letter-spacing: .5px; color: var(--text-2);
} }
.add-cal-dropdown-wrap { position: relative; }
.add-cal-dropdown {
position: absolute; top: 100%; right: 0; z-index: 200;
min-width: 180px; padding: 4px 0;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg);
}
.add-cal-dropdown button {
display: block; width: 100%; padding: 8px 14px;
text-align: left; font-size: 13px; color: var(--text-1);
background: none; border: none; cursor: pointer;
}
.add-cal-dropdown button:hover { background: var(--bg-hover); }
.cal-item { .cal-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 6px 16px; cursor: pointer; padding: 6px 16px; cursor: pointer;

View File

@@ -151,9 +151,16 @@
<div class="cal-list" id="cal-list"> <div class="cal-list" id="cal-list">
<div class="cal-list-header"> <div class="cal-list-header">
<span>Meine Kalender</span> <span>Meine Kalender</span>
<button class="icon-btn mini-btn" id="btn-add-account" title="CalDAV-Konto hinzufügen"> <div class="add-cal-dropdown-wrap">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
</button> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
<button data-action="local">Lokaler Kalender</button>
<button data-action="caldav">CalDAV-Konto</button>
<button data-action="ical">iCal-URL abonnieren</button>
</div>
</div>
</div> </div>
<div id="cal-list-items"></div> <div id="cal-list-items"></div>
</div> </div>
@@ -296,6 +303,75 @@
</div> </div>
</div> </div>
<!-- Local Calendar Modal -->
<div id="modal-local-cal" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3>Lokalen Kalender erstellen</h3>
<button class="icon-btn modal-close" data-modal="modal-local-cal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input type="text" id="local-cal-name" placeholder="z.B. Persönlich" />
</div>
<div class="form-group">
<label>Farbe</label>
<div class="ev-color-row">
<input type="text" id="local-cal-color-hex" class="ev-color-hex" maxlength="7" value="#34a853" spellcheck="false" />
<div class="ev-color-preview" id="local-cal-color-preview" style="background:#34a853" title="Farbe wählen"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-local-cal">Abbrechen</button>
<button class="btn btn-primary" id="local-cal-save">Erstellen</button>
</div>
</div>
</div>
<!-- iCal Subscription Modal -->
<div id="modal-ical-sub" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3>iCal-URL abonnieren</h3>
<button class="icon-btn modal-close" data-modal="modal-ical-sub">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input type="text" id="ical-sub-name" placeholder="z.B. Feiertage" />
</div>
<div class="form-group">
<label>iCal-URL</label>
<input type="url" id="ical-sub-url" placeholder="https://example.com/calendar.ics" />
</div>
<div class="form-group">
<label>Farbe</label>
<div class="ev-color-row">
<input type="text" id="ical-sub-color-hex" class="ev-color-hex" maxlength="7" value="#46bdc6" spellcheck="false" />
<div class="ev-color-preview" id="ical-sub-color-preview" style="background:#46bdc6" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label>Aktualisierung</label>
<select id="ical-sub-refresh">
<option value="15">Alle 15 Minuten</option>
<option value="30">Alle 30 Minuten</option>
<option value="60" selected>Stündlich</option>
<option value="360">Alle 6 Stunden</option>
<option value="1440">Täglich</option>
</select>
</div>
<div id="ical-sub-error" class="form-error hidden"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-ical-sub">Abbrechen</button>
<button class="btn btn-primary" id="ical-sub-save">Abonnieren</button>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="modal-settings" class="modal-overlay hidden"> <div id="modal-settings" class="modal-overlay hidden">
<div class="modal-card" style="max-width:520px"> <div class="modal-card" style="max-width:520px">

View File

@@ -27,6 +27,8 @@ let state = {
currentView: 'month', currentView: 'month',
events: [], events: [],
accounts: [], accounts: [],
localCalendars: [],
icalSubscriptions: [],
settings: {}, settings: {},
dimPast: false, dimPast: false,
editingEvent: null, // null = new event editingEvent: null, // null = new event
@@ -35,13 +37,17 @@ let state = {
// ── Public init ─────────────────────────────────────────── // ── Public init ───────────────────────────────────────────
export async function initCalendar() { export async function initCalendar() {
const [settings, accounts] = await Promise.all([ const [settings, accounts, localCalendars, icalSubscriptions] = await Promise.all([
api.get('/settings/'), api.get('/settings/'),
api.get('/caldav/accounts'), api.get('/caldav/accounts'),
api.get('/local/calendars'),
api.get('/ical/subscriptions'),
]); ]);
state.settings = settings; state.settings = settings;
state.accounts = accounts; state.accounts = accounts;
state.localCalendars = localCalendars;
state.icalSubscriptions = icalSubscriptions;
state.currentView = settings.default_view || 'month'; state.currentView = settings.default_view || 'month';
state.dimPast = settings.dim_past_events; state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day || 'monday'; weekStartDay = settings.week_start_day || 'monday';
@@ -55,6 +61,8 @@ export async function initCalendar() {
bindSidebar(); bindSidebar();
bindEventModal(); bindEventModal();
bindAccountModal(); bindAccountModal();
bindLocalCalModal();
bindICalSubModal();
bindSettingsModal(); bindSettingsModal();
bindProfileModal(); bindProfileModal();
} }
@@ -246,53 +254,126 @@ function renderMiniCal() {
// ── Calendar List ───────────────────────────────────────── // ── Calendar List ─────────────────────────────────────────
function renderCalendarList() { function renderCalendarList() {
const container = document.getElementById('cal-list-items'); const container = document.getElementById('cal-list-items');
if (!state.accounts.length) { let html = '';
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Kein CalDAV-Konto</div>`;
return; // ── CalDAV accounts ────────────────────────────────────
if (state.accounts.length) {
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}" data-source="caldav">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" />
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="Farbe ändern"></div>
<span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" data-source="caldav" 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('');
} }
const html = state.accounts.map(acc => // ── Local calendars ────────────────────────────────────
`<div class="cal-account-name">${escHtml(acc.name)}</div>` + if (state.localCalendars.length) {
acc.calendars.map(cal => html += `<div class="cal-account-name">Lokale Kalender</div>`;
`<div class="cal-item" data-cal-id="${cal.id}"> html += state.localCalendars.map(cal =>
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" /> `<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" title="Farbe ändern"></div> <input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" />
<span class="cal-item-name">${escHtml(cal.name)}</span> <div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="Farbe ändern"></div>
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" title="Konto entfernen"> <span class="cal-item-name" data-source="local">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="local" title="Kalender 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> <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> </button>
</div>` </div>`
).join('') ).join('');
).join(''); }
// ── iCal subscriptions ─────────────────────────────────
if (state.icalSubscriptions.length) {
html += `<div class="cal-account-name">Abonnements</div>`;
html += state.icalSubscriptions.map(sub =>
`<div class="cal-item" data-sub-id="${sub.id}" data-source="ical">
<input type="checkbox" ${sub.enabled ? 'checked' : ''} data-sub-id="${sub.id}" data-source="ical" />
<div class="cal-item-dot" style="background:${sub.color}" data-sub-id="${sub.id}" data-source="ical" title="Farbe ändern"></div>
<span class="cal-item-name" data-source="ical">${escHtml(sub.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-sub-id="${sub.id}" data-source="ical" title="Abo 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('');
}
if (!html) {
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Keine Kalender</div>`;
return;
}
container.innerHTML = html; container.innerHTML = html;
// ── Checkbox handlers ──────────────────────────────────
container.querySelectorAll('input[type=checkbox]').forEach(cb => { container.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', async () => { cb.addEventListener('change', async () => {
const calId = parseInt(cb.dataset.calId); const source = cb.dataset.source;
await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked }); if (source === 'caldav') {
// Update local state const calId = parseInt(cb.dataset.calId);
for (const acc of state.accounts) { await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked });
for (const cal of acc.calendars) { for (const acc of state.accounts) {
if (cal.id === calId) cal.enabled = cb.checked; for (const cal of acc.calendars) {
if (cal.id === calId) cal.enabled = cb.checked;
}
} }
} else if (source === 'local') {
const calId = parseInt(cb.dataset.calId);
await api.put(`/local/calendars/${calId}`, { enabled: cb.checked });
const cal = state.localCalendars.find(c => c.id === calId);
if (cal) cal.enabled = cb.checked;
} else if (source === 'ical') {
const subId = parseInt(cb.dataset.subId);
await api.put(`/ical/subscriptions/${subId}`, { enabled: cb.checked });
const sub = state.icalSubscriptions.find(s => s.id === subId);
if (sub) sub.enabled = cb.checked;
} }
fetchAndRender(); fetchAndRender();
}); });
}); });
// ── Color dot handlers ─────────────────────────────────
container.querySelectorAll('.cal-item-dot').forEach(dot => { container.querySelectorAll('.cal-item-dot').forEach(dot => {
dot.addEventListener('click', e => { dot.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
const calId = parseInt(dot.dataset.calId); const source = dot.dataset.source;
openCalColorPicker(dot, calId); if (source === 'caldav') {
openCalColorPicker(dot, parseInt(dot.dataset.calId));
} else if (source === 'local') {
const calId = parseInt(dot.dataset.calId);
const cal = state.localCalendars.find(c => c.id === calId);
const picked = await openColorPicker(dot, cal?.color || '#34a853');
if (picked) {
await api.put(`/local/calendars/${calId}`, { color: picked });
if (cal) cal.color = picked;
renderCalendarList();
fetchAndRender();
}
} else if (source === 'ical') {
const subId = parseInt(dot.dataset.subId);
const sub = state.icalSubscriptions.find(s => s.id === subId);
const picked = await openColorPicker(dot, sub?.color || '#46bdc6');
if (picked) {
await api.put(`/ical/subscriptions/${subId}`, { color: picked });
if (sub) sub.color = picked;
renderCalendarList();
fetchAndRender();
}
}
}); });
}); });
// ── Rename on double-click ─────────────────────────────
container.querySelectorAll('.cal-item-name').forEach(nameEl => { container.querySelectorAll('.cal-item-name').forEach(nameEl => {
nameEl.addEventListener('dblclick', e => { nameEl.addEventListener('dblclick', e => {
e.stopPropagation(); e.stopPropagation();
const calId = parseInt(nameEl.closest('.cal-item').dataset.calId); const item = nameEl.closest('.cal-item');
const source = nameEl.dataset.source;
const currentName = nameEl.textContent; const currentName = nameEl.textContent;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
@@ -305,11 +386,22 @@ function renderCalendarList() {
const save = async () => { const save = async () => {
const newName = input.value.trim(); const newName = input.value.trim();
if (newName && newName !== currentName) { if (newName && newName !== currentName) {
await api.put(`/caldav/calendars/${calId}`, { name: newName }); if (source === 'caldav') {
for (const acc of state.accounts) { const calId = parseInt(item.dataset.calId);
for (const cal of acc.calendars) { await api.put(`/caldav/calendars/${calId}`, { name: newName });
if (cal.id === calId) cal.name = newName; for (const acc of state.accounts) {
for (const cal of acc.calendars) { if (cal.id === calId) cal.name = newName; }
} }
} else if (source === 'local') {
const calId = parseInt(item.dataset.calId);
await api.put(`/local/calendars/${calId}`, { name: newName });
const cal = state.localCalendars.find(c => c.id === calId);
if (cal) cal.name = newName;
} else if (source === 'ical') {
const subId = parseInt(item.dataset.subId);
await api.put(`/ical/subscriptions/${subId}`, { name: newName });
const sub = state.icalSubscriptions.find(s => s.id === subId);
if (sub) sub.name = newName;
} }
} }
renderCalendarList(); renderCalendarList();
@@ -323,13 +415,27 @@ function renderCalendarList() {
}); });
}); });
// ── Remove handlers ────────────────────────────────────
container.querySelectorAll('.cal-item-remove').forEach(btn => { container.querySelectorAll('.cal-item-remove').forEach(btn => {
btn.addEventListener('click', async e => { btn.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
if (!confirm('CalDAV-Konto wirklich entfernen?')) return; const source = btn.dataset.source;
const accId = parseInt(btn.dataset.accId); if (source === 'caldav') {
await api.delete(`/caldav/accounts/${accId}`); if (!confirm('CalDAV-Konto wirklich entfernen?')) return;
state.accounts = state.accounts.filter(a => a.id !== accId); const accId = parseInt(btn.dataset.accId);
await api.delete(`/caldav/accounts/${accId}`);
state.accounts = state.accounts.filter(a => a.id !== accId);
} else if (source === 'local') {
if (!confirm('Lokalen Kalender wirklich löschen?')) return;
const calId = parseInt(btn.dataset.calId);
await api.delete(`/local/calendars/${calId}`);
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
} else if (source === 'ical') {
if (!confirm('Abonnement wirklich entfernen?')) return;
const subId = parseInt(btn.dataset.subId);
await api.delete(`/ical/subscriptions/${subId}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
}
renderCalendarList(); renderCalendarList();
fetchAndRender(); fetchAndRender();
}); });
@@ -380,7 +486,33 @@ function bindSidebar() {
document.getElementById('sidebar-toggle').onclick = () => { document.getElementById('sidebar-toggle').onclick = () => {
document.getElementById('sidebar').classList.toggle('collapsed'); document.getElementById('sidebar').classList.toggle('collapsed');
}; };
document.getElementById('btn-add-account').onclick = openAccountModal;
// Add calendar dropdown
const addBtn = document.getElementById('btn-add-cal');
const dropdown = document.getElementById('add-cal-dropdown');
addBtn.onclick = e => {
e.stopPropagation();
dropdown.classList.toggle('hidden');
};
document.addEventListener('click', e => {
if (!dropdown.contains(e.target) && e.target !== addBtn) {
dropdown.classList.add('hidden');
}
});
dropdown.querySelector('[data-action="caldav"]').onclick = () => {
dropdown.classList.add('hidden');
openAccountModal();
};
dropdown.querySelector('[data-action="local"]').onclick = () => {
dropdown.classList.add('hidden');
openLocalCalModal();
};
dropdown.querySelector('[data-action="ical"]').onclick = () => {
dropdown.classList.add('hidden');
openICalSubModal();
};
} }
// ── Event Popup ─────────────────────────────────────────── // ── Event Popup ───────────────────────────────────────────
@@ -426,7 +558,14 @@ function showEventPopup(ev, anchor) {
if (!confirm(`"${ev.title}" wirklich löschen?`)) return; if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
popup.classList.add('hidden'); popup.classList.add('hidden');
try { try {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); if (ev.source === 'local') {
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'ical') {
const subId = ev.calendar_id.replace('ical-', '');
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
} else {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
}
showToast('Termin gelöscht'); showToast('Termin gelöscht');
fetchAndRender(); fetchAndRender();
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
@@ -446,6 +585,7 @@ document.addEventListener('click', e => {
function populateCalendarSelect(selectedId) { function populateCalendarSelect(selectedId) {
const sel = document.getElementById('ev-calendar'); const sel = document.getElementById('ev-calendar');
sel.innerHTML = ''; sel.innerHTML = '';
// CalDAV calendars
state.accounts.forEach(acc => { state.accounts.forEach(acc => {
acc.calendars.filter(c => c.enabled).forEach(cal => { acc.calendars.filter(c => c.enabled).forEach(cal => {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -455,6 +595,15 @@ function populateCalendarSelect(selectedId) {
sel.appendChild(opt); sel.appendChild(opt);
}); });
}); });
// Local calendars
state.localCalendars.filter(c => c.enabled).forEach(cal => {
const opt = document.createElement('option');
opt.value = `local-${cal.id}`;
opt.textContent = cal.name;
if (`local-${cal.id}` === selectedId) opt.selected = true;
sel.appendChild(opt);
});
// iCal subscriptions are read-only, not shown here
} }
function openNewEventModal(date) { function openNewEventModal(date) {
@@ -551,7 +700,8 @@ function bindEventModal() {
if (!title) { showToast('Bitte Titel eingeben', true); return; } if (!title) { showToast('Bitte Titel eingeben', true); return; }
const allDay = document.getElementById('ev-allday').checked; const allDay = document.getElementById('ev-allday').checked;
const calId = parseInt(document.getElementById('ev-calendar').value); const calVal = document.getElementById('ev-calendar').value;
const isLocal = calVal.startsWith('local-');
const loc = document.getElementById('ev-location').value.trim(); const loc = document.getElementById('ev-location').value.trim();
const desc = document.getElementById('ev-description').value.trim(); const desc = document.getElementById('ev-description').value.trim();
const color = state.selectedEventColor; const color = state.selectedEventColor;
@@ -572,12 +722,32 @@ function bindEventModal() {
try { try {
if (state.editingEvent) { if (state.editingEvent) {
await api.put( const ev = state.editingEvent;
`/caldav/events/${encodeURIComponent(state.editingEvent.id)}?event_url=${encodeURIComponent(state.editingEvent.url)}`, if (ev.source === 'local') {
{ title, start, end, allDay, location: loc, description: desc, color: color || null } await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
); { title, start, end, allDay, location: loc, description: desc, color: color || null }
);
} else if (ev.source === 'ical') {
const subId = ev.calendar_id.replace('ical-', '');
await api.put(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`,
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
);
} else {
await api.put(
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`,
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
);
}
showToast('Termin aktualisiert'); showToast('Termin aktualisiert');
} else if (isLocal) {
const calId = parseInt(calVal.replace('local-', ''));
await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
});
showToast('Termin erstellt');
} else { } else {
const calId = parseInt(calVal);
await api.post('/caldav/events', { await api.post('/caldav/events', {
calendar_id: calId, title, start, end, allDay, calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null, location: loc, description: desc, color: color || null,
@@ -596,7 +766,14 @@ function bindEventModal() {
if (!ev) return; if (!ev) return;
if (!confirm(`"${ev.title}" wirklich löschen?`)) return; if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
try { try {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); if (ev.source === 'local') {
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'ical') {
const subId = ev.calendar_id.replace('ical-', '');
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
} else {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
}
showToast('Termin gelöscht'); showToast('Termin gelöscht');
closeModal('modal-event'); closeModal('modal-event');
fetchAndRender(); fetchAndRender();
@@ -663,6 +840,94 @@ function bindAccountModal() {
}; };
} }
// ── Local Calendar Modal ──────────────────────────────────
function openLocalCalModal() {
document.getElementById('local-cal-name').value = '';
document.getElementById('local-cal-color-hex').value = '#34a853';
document.getElementById('local-cal-color-preview').style.background = '#34a853';
openModal('modal-local-cal');
}
function bindLocalCalModal() {
const preview = document.getElementById('local-cal-color-preview');
const hex = document.getElementById('local-cal-color-hex');
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || '#34a853');
if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
});
hex.addEventListener('change', () => {
let val = hex.value.trim();
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
});
document.getElementById('local-cal-save').onclick = async () => {
const name = document.getElementById('local-cal-name').value.trim();
if (!name) { showToast('Bitte Name eingeben', true); return; }
const color = hex.value;
try {
const cal = await api.post('/local/calendars', { name, color });
state.localCalendars.push(cal);
renderCalendarList();
closeModal('modal-local-cal');
showToast(`Kalender "${name}" erstellt`);
} catch (e) { showToast(e.message, true); }
};
}
// ── iCal Subscription Modal ──────────────────────────────
function openICalSubModal() {
document.getElementById('ical-sub-name').value = '';
document.getElementById('ical-sub-url').value = '';
document.getElementById('ical-sub-color-hex').value = '#46bdc6';
document.getElementById('ical-sub-color-preview').style.background = '#46bdc6';
document.getElementById('ical-sub-refresh').value = '60';
document.getElementById('ical-sub-error').classList.add('hidden');
openModal('modal-ical-sub');
}
function bindICalSubModal() {
const preview = document.getElementById('ical-sub-color-preview');
const hex = document.getElementById('ical-sub-color-hex');
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || '#46bdc6');
if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
});
hex.addEventListener('change', () => {
let val = hex.value.trim();
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
});
document.getElementById('ical-sub-save').onclick = async () => {
const name = document.getElementById('ical-sub-name').value.trim();
const url = document.getElementById('ical-sub-url').value.trim();
const errEl = document.getElementById('ical-sub-error');
if (!name || !url) { errEl.textContent = 'Bitte Name und URL eingeben'; errEl.classList.remove('hidden'); return; }
errEl.classList.add('hidden');
const color = hex.value;
const refresh_minutes = parseInt(document.getElementById('ical-sub-refresh').value);
document.getElementById('ical-sub-save').disabled = true;
document.getElementById('ical-sub-save').textContent = 'Lade…';
try {
const sub = await api.post('/ical/subscriptions', { name, url, color, refresh_minutes });
state.icalSubscriptions.push(sub);
renderCalendarList();
closeModal('modal-ical-sub');
showToast(`"${name}" abonniert`);
fetchAndRender();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
document.getElementById('ical-sub-save').disabled = false;
document.getElementById('ical-sub-save').textContent = 'Abonnieren';
}
};
}
// ── Settings Modal ──────────────────────────────────────── // ── Settings Modal ────────────────────────────────────────
function openSettingsModal() { function openSettingsModal() {
const s = state.settings; const s = state.settings;