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:
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
359
backend/routers/ical_router.py
Normal file
359
backend/routers/ical_router.py
Normal 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}
|
||||||
245
backend/routers/local_router.py
Normal file
245
backend/routers/local_router.py
Normal 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}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
</button>
|
</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">×</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">×</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">
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = state.accounts.map(acc =>
|
// ── CalDAV accounts ────────────────────────────────────
|
||||||
|
if (state.accounts.length) {
|
||||||
|
html += state.accounts.map(acc =>
|
||||||
`<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
`<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
||||||
acc.calendars.map(cal =>
|
acc.calendars.map(cal =>
|
||||||
`<div class="cal-item" data-cal-id="${cal.id}">
|
`<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav">
|
||||||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" />
|
<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}" title="Farbe ändern"></div>
|
<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">${escHtml(cal.name)}</span>
|
<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}" title="Konto entfernen">
|
<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>
|
<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('');
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local calendars ────────────────────────────────────
|
||||||
|
if (state.localCalendars.length) {
|
||||||
|
html += `<div class="cal-account-name">Lokale Kalender</div>`;
|
||||||
|
html += state.localCalendars.map(cal =>
|
||||||
|
`<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
|
||||||
|
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" />
|
||||||
|
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="Farbe ändern"></div>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
).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 source = cb.dataset.source;
|
||||||
|
if (source === 'caldav') {
|
||||||
const calId = parseInt(cb.dataset.calId);
|
const calId = parseInt(cb.dataset.calId);
|
||||||
await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked });
|
await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked });
|
||||||
// Update local state
|
|
||||||
for (const acc of state.accounts) {
|
for (const acc of state.accounts) {
|
||||||
for (const cal of acc.calendars) {
|
for (const cal of acc.calendars) {
|
||||||
if (cal.id === calId) cal.enabled = cb.checked;
|
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 source = dot.dataset.source;
|
||||||
|
if (source === 'caldav') {
|
||||||
|
openCalColorPicker(dot, parseInt(dot.dataset.calId));
|
||||||
|
} else if (source === 'local') {
|
||||||
const calId = parseInt(dot.dataset.calId);
|
const calId = parseInt(dot.dataset.calId);
|
||||||
openCalColorPicker(dot, 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) {
|
||||||
|
if (source === 'caldav') {
|
||||||
|
const calId = parseInt(item.dataset.calId);
|
||||||
await api.put(`/caldav/calendars/${calId}`, { name: newName });
|
await api.put(`/caldav/calendars/${calId}`, { name: newName });
|
||||||
for (const acc of state.accounts) {
|
for (const acc of state.accounts) {
|
||||||
for (const cal of acc.calendars) {
|
for (const cal of acc.calendars) { if (cal.id === calId) cal.name = newName; }
|
||||||
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();
|
||||||
|
const source = btn.dataset.source;
|
||||||
|
if (source === 'caldav') {
|
||||||
if (!confirm('CalDAV-Konto wirklich entfernen?')) return;
|
if (!confirm('CalDAV-Konto wirklich entfernen?')) return;
|
||||||
const accId = parseInt(btn.dataset.accId);
|
const accId = parseInt(btn.dataset.accId);
|
||||||
await api.delete(`/caldav/accounts/${accId}`);
|
await api.delete(`/caldav/accounts/${accId}`);
|
||||||
state.accounts = state.accounts.filter(a => a.id !== 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 {
|
||||||
|
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)}`);
|
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') {
|
||||||
|
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 }
|
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
|
||||||
);
|
);
|
||||||
showToast('Termin aktualisiert');
|
|
||||||
} else {
|
} 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');
|
||||||
|
} 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 {
|
||||||
|
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 {
|
||||||
|
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)}`);
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user