feat: Datum-Validierung, Monatsauswahl, CalDAV-Fix, wiederkehrende Termine
- End-Datum passt sich automatisch an wenn Start geändert wird (Duration bleibt erhalten) - Erstellen-Button nutzt den aktuell angesehenen Tag statt immer heute - Monatsansicht: Einzelklick = Tag auswählen, Doppelklick = Tagesansicht, Rechtsklick = Kontextmenü - CalDAV URL-Matching robuster (Normalisierung, Path-Fallback, calendar_id Parameter) - iCal-Abo-Termine sind nicht mehr bearbeitbar (Read-Only-Schutz) - Wiederkehrende Termine mit RRULE-Support (täglich/wöchentlich/monatlich/jährlich/benutzerdefiniert)
This commit is contained in:
@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import caldav
|
||||
from icalendar import Calendar, Event
|
||||
from icalendar import Calendar, Event, vRecur
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -105,6 +105,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
|
||||
location = str(component.get("LOCATION", "") or "")
|
||||
description = str(component.get("DESCRIPTION", "") or "")
|
||||
color = str(component.get("X-CALENDARR-COLOR", "") or "")
|
||||
rrule_prop = component.get("RRULE")
|
||||
rrule_str = rrule_prop.to_ical().decode("utf-8") if rrule_prop else None
|
||||
|
||||
dtstart_prop = component.get("DTSTART")
|
||||
dtend_prop = component.get("DTEND")
|
||||
@@ -154,6 +156,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
|
||||
"location": location,
|
||||
"description": description,
|
||||
"color": color or None,
|
||||
"rrule": rrule_str,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -201,6 +204,8 @@ def create_event(
|
||||
event.add("description", data["description"])
|
||||
if data.get("color"):
|
||||
event.add("x-calendarr-color", data["color"])
|
||||
if data.get("rrule"):
|
||||
event.add("rrule", _parse_rrule_str(data["rrule"]))
|
||||
|
||||
cal.add_component(event)
|
||||
cal_obj.save_event(cal.to_ical().decode("utf-8"))
|
||||
@@ -247,6 +252,11 @@ def update_event(
|
||||
component["DESCRIPTION"] = data["description"]
|
||||
if "color" in data:
|
||||
component["X-CALENDARR-COLOR"] = data["color"]
|
||||
if "rrule" in data:
|
||||
if data["rrule"]:
|
||||
component["RRULE"] = _parse_rrule_str(data["rrule"])
|
||||
elif "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
|
||||
new_cal.add_component(component)
|
||||
|
||||
@@ -260,6 +270,20 @@ def delete_event(url: str, username: str, password: str, event_url: str):
|
||||
resource.delete()
|
||||
|
||||
|
||||
def _parse_rrule_str(rrule_str: str) -> vRecur:
|
||||
"""Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
|
||||
params = {}
|
||||
for part in rrule_str.split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, val = part.split("=", 1)
|
||||
if "," in val:
|
||||
params[key] = val.split(",")
|
||||
else:
|
||||
params[key] = val
|
||||
return vRecur(params)
|
||||
|
||||
|
||||
def _parse_dt(s: str) -> datetime:
|
||||
s = s.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(s)
|
||||
|
||||
@@ -83,6 +83,12 @@ def _migrate():
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added rrule to local_events")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ class LocalEvent(Base):
|
||||
location = Column(String(500), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
color = Column(String(7), nullable=True)
|
||||
rrule = Column(Text, nullable=True)
|
||||
|
||||
calendar = relationship("LocalCalendar", back_populates="events")
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
import caldav_client
|
||||
import models
|
||||
@@ -41,6 +45,7 @@ class EventCreate(BaseModel):
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
@@ -51,6 +56,7 @@ class EventUpdate(BaseModel):
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
|
||||
|
||||
def _account_dict(a: models.CalDAVAccount) -> dict:
|
||||
@@ -75,16 +81,124 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
||||
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
|
||||
results = []
|
||||
try:
|
||||
ev_start_str = ev.start.replace("Z", "+00:00")
|
||||
ev_end_str = ev.end.replace("Z", "+00:00")
|
||||
|
||||
if ev.all_day:
|
||||
ev_start = dt_date.fromisoformat(ev_start_str[:10])
|
||||
ev_end = dt_date.fromisoformat(ev_end_str[:10])
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
|
||||
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
|
||||
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_start = occ.date()
|
||||
occ_end = occ_start + duration
|
||||
results.append({
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": occ_start.isoformat(),
|
||||
"end": occ_end.isoformat(),
|
||||
"allDay": True,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
else:
|
||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||
ev_end = dt_datetime.fromisoformat(ev_end_str)
|
||||
if ev_start.tzinfo is None:
|
||||
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
|
||||
if ev_end.tzinfo is None:
|
||||
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
|
||||
duration = ev_end - ev_start
|
||||
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
|
||||
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
|
||||
if r_start.tzinfo is None:
|
||||
r_start = r_start.replace(tzinfo=dt_timezone.utc)
|
||||
if r_end.tzinfo is None:
|
||||
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||
for occ in occurrences:
|
||||
occ_end = occ + duration
|
||||
results.append({
|
||||
"id": ev.uid,
|
||||
"url": f"local://{ev.uid}",
|
||||
"title": ev.title,
|
||||
"start": occ.isoformat(),
|
||||
"end": occ_end.isoformat(),
|
||||
"allDay": False,
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||
# Fall back to single event
|
||||
results.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, "rrule": ev.rrule,
|
||||
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color, "source": "local",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
host = (parsed.hostname or '').lower()
|
||||
port = parsed.port
|
||||
if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80):
|
||||
port = None
|
||||
netloc = f"{host}:{port}" if port else host
|
||||
path = parsed.path.rstrip('/')
|
||||
return f"{scheme}://{netloc}{path}"
|
||||
|
||||
|
||||
def _find_account_for_event_url(
|
||||
event_url: str, accounts: list[models.CalDAVAccount]
|
||||
) -> Optional[models.CalDAVAccount]:
|
||||
norm_event = _normalize_url(event_url)
|
||||
# Primary: match against normalized account URL
|
||||
for acc in accounts:
|
||||
if event_url.startswith(acc.url):
|
||||
if norm_event.startswith(_normalize_url(acc.url)):
|
||||
return acc
|
||||
# fallback: check calendar urls
|
||||
# Fallback: match against normalized calendar URLs
|
||||
for acc in accounts:
|
||||
for cal in acc.calendars:
|
||||
if event_url.startswith(cal.cal_id):
|
||||
if norm_event.startswith(_normalize_url(cal.cal_id)):
|
||||
return acc
|
||||
# Second fallback: path-only matching
|
||||
event_path = urlparse(event_url).path.rstrip('/')
|
||||
for acc in accounts:
|
||||
acc_path = urlparse(acc.url).path.rstrip('/')
|
||||
if acc_path and event_path.startswith(acc_path):
|
||||
return acc
|
||||
for acc in accounts:
|
||||
for cal in acc.calendars:
|
||||
cal_path = urlparse(cal.cal_id).path.rstrip('/')
|
||||
if cal_path and event_path.startswith(cal_path):
|
||||
return acc
|
||||
return None
|
||||
|
||||
@@ -302,27 +416,35 @@ def get_events(
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == local_cal.id,
|
||||
models.LocalEvent.start < end,
|
||||
models.LocalEvent.end > start,
|
||||
or_(
|
||||
# Non-recurring events in range
|
||||
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
|
||||
# Recurring events: always include so we can expand
|
||||
models.LocalEvent.rrule != None,
|
||||
),
|
||||
)
|
||||
.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",
|
||||
})
|
||||
if ev.rrule:
|
||||
all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
|
||||
else:
|
||||
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,
|
||||
"rrule": None,
|
||||
"calendar_id": f"local-{local_cal.id}",
|
||||
"calendar_name": local_cal.name,
|
||||
"calendarColor": local_cal.color,
|
||||
"source": "local",
|
||||
})
|
||||
|
||||
# ── iCal subscription events ──────────────────────────
|
||||
ical_subs = (
|
||||
@@ -403,6 +525,7 @@ def create_event(
|
||||
"location": data.location,
|
||||
"description": data.description,
|
||||
"color": data.color,
|
||||
"rrule": data.rrule,
|
||||
},
|
||||
)
|
||||
return {"uid": uid, "calendar_id": data.calendar_id}
|
||||
@@ -414,6 +537,7 @@ def create_event(
|
||||
def update_event(
|
||||
event_id: str,
|
||||
event_url: str = Query(...),
|
||||
calendar_id: Optional[int] = Query(None),
|
||||
data: EventUpdate = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
@@ -423,7 +547,18 @@ def update_event(
|
||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
account = _find_account_for_event_url(event_url, accounts)
|
||||
account = None
|
||||
if calendar_id is not None:
|
||||
cal = (
|
||||
db.query(models.Calendar)
|
||||
.join(models.CalDAVAccount)
|
||||
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if cal:
|
||||
account = next((a for a in accounts if a.id == cal.account_id), None)
|
||||
if not account:
|
||||
account = _find_account_for_event_url(event_url, accounts)
|
||||
if not account:
|
||||
raise HTTPException(404, "Event not found or not authorized")
|
||||
try:
|
||||
@@ -443,6 +578,7 @@ def update_event(
|
||||
def delete_event(
|
||||
event_id: str,
|
||||
event_url: str = Query(...),
|
||||
calendar_id: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
@@ -451,7 +587,18 @@ def delete_event(
|
||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
account = _find_account_for_event_url(event_url, accounts)
|
||||
account = None
|
||||
if calendar_id is not None:
|
||||
cal = (
|
||||
db.query(models.Calendar)
|
||||
.join(models.CalDAVAccount)
|
||||
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if cal:
|
||||
account = next((a for a in accounts if a.id == cal.account_id), None)
|
||||
if not account:
|
||||
account = _find_account_for_event_url(event_url, accounts)
|
||||
if not account:
|
||||
raise HTTPException(404, "Event not found or not authorized")
|
||||
try:
|
||||
|
||||
@@ -32,6 +32,7 @@ class EventCreate(BaseModel):
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
@@ -42,6 +43,7 @@ class EventUpdate(BaseModel):
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
rrule: Optional[str] = None
|
||||
|
||||
|
||||
def _cal_dict(cal: models.LocalCalendar) -> dict:
|
||||
@@ -64,6 +66,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
|
||||
"location": ev.location or "",
|
||||
"description": ev.description or "",
|
||||
"color": ev.color,
|
||||
"rrule": ev.rrule,
|
||||
"calendar_id": f"local-{cal.id}",
|
||||
"calendar_name": cal.name,
|
||||
"calendarColor": cal.color,
|
||||
@@ -180,6 +183,7 @@ def create_event(
|
||||
location=data.location,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
rrule=data.rrule,
|
||||
)
|
||||
db.add(ev)
|
||||
db.commit()
|
||||
@@ -219,6 +223,8 @@ def update_event(
|
||||
ev.description = data.description
|
||||
if data.color is not None:
|
||||
ev.color = data.color
|
||||
if data.rrule is not None:
|
||||
ev.rrule = data.rrule if data.rrule else None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -486,6 +486,8 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.month-col:last-child { border-right: none; }
|
||||
.month-col:hover { background: var(--bg-hover); }
|
||||
.month-col.today { background: rgba(66,133,244,.08); }
|
||||
.month-col.month-selected { background: var(--primary-dim); }
|
||||
.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; }
|
||||
.month-col.other-month .cell-day { color: var(--text-3); }
|
||||
.cell-day {
|
||||
font-size: 12px; font-weight: 500; color: var(--text-2);
|
||||
@@ -785,6 +787,30 @@ a { color: var(--primary); text-decoration: none; }
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Recurrence UI ─────────────────────────────────────── */
|
||||
.rec-weekdays { display: flex; gap: 4px; margin-top: 8px; }
|
||||
.rec-day-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
border: 1px solid var(--border); background: var(--bg-card);
|
||||
color: var(--text-2); cursor: pointer; font-size: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.rec-day-btn:hover { background: var(--bg-hover); }
|
||||
.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
|
||||
/* ── Day Context Menu ──────────────────────────────────── */
|
||||
.cal-context-menu {
|
||||
position: fixed; z-index: 1000;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3);
|
||||
min-width: 180px; padding: 4px 0;
|
||||
}
|
||||
.ctx-item {
|
||||
padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer;
|
||||
}
|
||||
.ctx-item:hover { background: var(--bg-hover); }
|
||||
|
||||
/* ── Event Popup ────────────────────────────────────────── */
|
||||
.event-popup {
|
||||
position: fixed; z-index: 600;
|
||||
|
||||
@@ -238,6 +238,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="ev-rec-label">Wiederholung</label>
|
||||
<select id="ev-recurrence">
|
||||
<option value="">Keine</option>
|
||||
<option value="FREQ=DAILY">Täglich</option>
|
||||
<option value="FREQ=WEEKLY">Wöchentlich</option>
|
||||
<option value="FREQ=MONTHLY">Monatlich</option>
|
||||
<option value="FREQ=YEARLY">Jährlich</option>
|
||||
<option value="custom">Benutzerdefiniert…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ev-recurrence-custom" class="form-group hidden">
|
||||
<div class="form-row" style="gap:8px;align-items:center">
|
||||
<label style="white-space:nowrap" id="ev-rec-every-label">Alle</label>
|
||||
<input type="number" id="ev-rec-interval" value="1" min="1" max="99" style="width:60px" />
|
||||
<select id="ev-rec-freq">
|
||||
<option value="DAILY">Tage</option>
|
||||
<option value="WEEKLY">Wochen</option>
|
||||
<option value="MONTHLY">Monate</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ev-rec-weekdays" class="rec-weekdays hidden">
|
||||
<button type="button" class="rec-day-btn" data-day="MO">Mo</button>
|
||||
<button type="button" class="rec-day-btn" data-day="TU">Di</button>
|
||||
<button type="button" class="rec-day-btn" data-day="WE">Mi</button>
|
||||
<button type="button" class="rec-day-btn" data-day="TH">Do</button>
|
||||
<button type="button" class="rec-day-btn" data-day="FR">Fr</button>
|
||||
<button type="button" class="rec-day-btn" data-day="SA">Sa</button>
|
||||
<button type="button" class="rec-day-btn" data-day="SU">So</button>
|
||||
</div>
|
||||
<div class="form-row" style="gap:8px;align-items:center;margin-top:8px">
|
||||
<label id="ev-rec-ends-label">Endet</label>
|
||||
<select id="ev-rec-end-type">
|
||||
<option value="never">Nie</option>
|
||||
<option value="count">Nach Anzahl</option>
|
||||
<option value="until">Am Datum</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ev-rec-end-count" class="hidden" style="margin-top:4px">
|
||||
<input type="number" id="ev-rec-count" value="10" min="1" max="999" style="width:80px" />
|
||||
<span id="ev-rec-occ-label"> Termine</span>
|
||||
</div>
|
||||
<div id="ev-rec-end-until" class="hidden" style="margin-top:4px">
|
||||
<input type="hidden" id="ev-rec-until" />
|
||||
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
|
||||
<span class="dt-display-text">—</span>
|
||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v5H7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kalender</label>
|
||||
<select id="ev-calendar"></select>
|
||||
|
||||
@@ -250,7 +250,23 @@ function renderView() {
|
||||
|
||||
if (state.currentView === 'month') {
|
||||
renderMonth(container, state.currentDate, evs,
|
||||
date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); },
|
||||
(date, action, mouseEvent) => {
|
||||
if (action === 'navigate') {
|
||||
state.currentDate = date;
|
||||
state.currentView = 'day';
|
||||
updateViewButtons();
|
||||
fetchAndRender();
|
||||
} else if (action === 'context') {
|
||||
state.currentDate = date;
|
||||
showDayContextMenu(date, mouseEvent);
|
||||
} else {
|
||||
// 'select' — highlight day without navigating
|
||||
state.currentDate = date;
|
||||
renderMiniCal();
|
||||
renderView();
|
||||
updateTitle();
|
||||
}
|
||||
},
|
||||
showEventPopup,
|
||||
weekStartDay
|
||||
);
|
||||
@@ -764,7 +780,7 @@ function bindTopbar() {
|
||||
});
|
||||
|
||||
document.getElementById('btn-settings').onclick = openSettingsModal;
|
||||
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
|
||||
document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.currentDate);
|
||||
|
||||
// Mouse wheel / trackpad scroll navigation – only for month & quarter
|
||||
let _wheelLast = 0;
|
||||
@@ -837,6 +853,29 @@ function bindSidebar() {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Day Context Menu (month view) ────────────────────────
|
||||
function showDayContextMenu(date, mouseEvent) {
|
||||
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'cal-context-menu';
|
||||
menu.innerHTML = `<div class="ctx-item" data-action="create">${t('ctx_create_event')}</div>`;
|
||||
|
||||
menu.style.left = mouseEvent.clientX + 'px';
|
||||
menu.style.top = mouseEvent.clientY + 'px';
|
||||
document.body.appendChild(menu);
|
||||
|
||||
menu.querySelector('[data-action="create"]').onclick = () => {
|
||||
menu.remove();
|
||||
openNewEventModal(date);
|
||||
};
|
||||
|
||||
const close = (e) => {
|
||||
if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); }
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 0);
|
||||
}
|
||||
|
||||
// ── Event Popup ───────────────────────────────────────────
|
||||
function showEventPopup(ev, anchor) {
|
||||
const popup = document.getElementById('popup-event');
|
||||
@@ -877,6 +916,11 @@ function showEventPopup(ev, anchor) {
|
||||
popup.style.left = Math.max(8, left) + 'px';
|
||||
popup.style.top = Math.max(8, top) + 'px';
|
||||
|
||||
// Hide edit/delete for read-only iCal subscription events
|
||||
const isReadOnly = (ev.source === 'ical');
|
||||
document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : '';
|
||||
document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : '';
|
||||
|
||||
document.getElementById('popup-edit').onclick = () => {
|
||||
popup.classList.add('hidden');
|
||||
openEditEventModal(ev);
|
||||
@@ -921,7 +965,7 @@ function showEventPopup(ev, anchor) {
|
||||
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)}&calendar_id=${ev.calendar_id}`);
|
||||
}
|
||||
showToast(t('event_deleted'));
|
||||
fetchAndRender(true);
|
||||
@@ -1005,11 +1049,13 @@ function openNewEventModal(date) {
|
||||
toggleAlldayFields(false);
|
||||
populateCalendarSelect(null);
|
||||
resetColorPicker('');
|
||||
resetRecurrenceUI();
|
||||
document.getElementById('ev-delete').classList.add('hidden');
|
||||
openModal('modal-event');
|
||||
}
|
||||
|
||||
function openEditEventModal(ev) {
|
||||
if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; }
|
||||
state.editingEvent = ev;
|
||||
state.selectedEventColor = ev.color || '';
|
||||
|
||||
@@ -1033,6 +1079,23 @@ function openEditEventModal(ev) {
|
||||
|
||||
populateCalendarSelect(ev.calendar_id);
|
||||
resetColorPicker(ev.color || '');
|
||||
|
||||
// Recurrence
|
||||
const rrule = ev.rrule || '';
|
||||
const recSel = document.getElementById('ev-recurrence');
|
||||
const customPanel = document.getElementById('ev-recurrence-custom');
|
||||
if (!rrule) {
|
||||
recSel.value = '';
|
||||
customPanel.classList.add('hidden');
|
||||
} else if (['FREQ=DAILY', 'FREQ=WEEKLY', 'FREQ=MONTHLY', 'FREQ=YEARLY'].includes(rrule)) {
|
||||
recSel.value = rrule;
|
||||
customPanel.classList.add('hidden');
|
||||
} else {
|
||||
recSel.value = 'custom';
|
||||
customPanel.classList.remove('hidden');
|
||||
parseRruleIntoUI(rrule);
|
||||
}
|
||||
|
||||
document.getElementById('ev-delete').classList.remove('hidden');
|
||||
openModal('modal-event');
|
||||
}
|
||||
@@ -1050,24 +1113,142 @@ function resetColorPicker(color) {
|
||||
preview.style.background = color || 'var(--primary)';
|
||||
}
|
||||
|
||||
function buildRruleFromUI() {
|
||||
const sel = document.getElementById('ev-recurrence').value;
|
||||
if (!sel) return null;
|
||||
if (sel !== 'custom') return sel;
|
||||
|
||||
const interval = parseInt(document.getElementById('ev-rec-interval').value) || 1;
|
||||
const freq = document.getElementById('ev-rec-freq').value;
|
||||
let rule = `FREQ=${freq}`;
|
||||
if (interval > 1) rule += `;INTERVAL=${interval}`;
|
||||
|
||||
if (freq === 'WEEKLY') {
|
||||
const days = [...document.querySelectorAll('.rec-day-btn.active')].map(b => b.dataset.day);
|
||||
if (days.length) rule += `;BYDAY=${days.join(',')}`;
|
||||
}
|
||||
|
||||
const endType = document.getElementById('ev-rec-end-type').value;
|
||||
if (endType === 'count') {
|
||||
rule += `;COUNT=${parseInt(document.getElementById('ev-rec-count').value) || 10}`;
|
||||
} else if (endType === 'until') {
|
||||
const until = document.getElementById('ev-rec-until').value;
|
||||
if (until) rule += `;UNTIL=${until.replace(/-/g, '')}T235959Z`;
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
function parseRruleIntoUI(rruleStr) {
|
||||
const parts = {};
|
||||
rruleStr.split(';').forEach(p => {
|
||||
const [k, v] = p.split('=', 2);
|
||||
if (k && v) parts[k] = v;
|
||||
});
|
||||
|
||||
document.getElementById('ev-rec-interval').value = parts.INTERVAL || '1';
|
||||
document.getElementById('ev-rec-freq').value = parts.FREQ || 'DAILY';
|
||||
document.getElementById('ev-rec-weekdays').classList.toggle('hidden', parts.FREQ !== 'WEEKLY');
|
||||
|
||||
// Reset all weekday buttons
|
||||
document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active'));
|
||||
if (parts.BYDAY) {
|
||||
parts.BYDAY.split(',').forEach(day => {
|
||||
const btn = document.querySelector(`.rec-day-btn[data-day="${day.trim()}"]`);
|
||||
if (btn) btn.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (parts.COUNT) {
|
||||
document.getElementById('ev-rec-end-type').value = 'count';
|
||||
document.getElementById('ev-rec-count').value = parts.COUNT;
|
||||
document.getElementById('ev-rec-end-count').classList.remove('hidden');
|
||||
document.getElementById('ev-rec-end-until').classList.add('hidden');
|
||||
} else if (parts.UNTIL) {
|
||||
document.getElementById('ev-rec-end-type').value = 'until';
|
||||
// Parse UNTIL: 20260501T235959Z → 2026-05-01
|
||||
const u = parts.UNTIL.replace('Z', '');
|
||||
const formatted = u.length >= 8 ? `${u.slice(0,4)}-${u.slice(4,6)}-${u.slice(6,8)}` : '';
|
||||
if (formatted) setDtValue('ev-rec-until', formatted, 'date');
|
||||
document.getElementById('ev-rec-end-count').classList.add('hidden');
|
||||
document.getElementById('ev-rec-end-until').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('ev-rec-end-type').value = 'never';
|
||||
document.getElementById('ev-rec-end-count').classList.add('hidden');
|
||||
document.getElementById('ev-rec-end-until').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function resetRecurrenceUI() {
|
||||
document.getElementById('ev-recurrence').value = '';
|
||||
document.getElementById('ev-recurrence-custom').classList.add('hidden');
|
||||
document.getElementById('ev-rec-interval').value = '1';
|
||||
document.getElementById('ev-rec-freq').value = 'DAILY';
|
||||
document.getElementById('ev-rec-weekdays').classList.add('hidden');
|
||||
document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById('ev-rec-end-type').value = 'never';
|
||||
document.getElementById('ev-rec-end-count').classList.add('hidden');
|
||||
document.getElementById('ev-rec-end-until').classList.add('hidden');
|
||||
}
|
||||
|
||||
function bindEventModal() {
|
||||
document.getElementById('ev-allday').addEventListener('change', e => {
|
||||
toggleAlldayFields(e.target.checked);
|
||||
});
|
||||
|
||||
// Date/time pickers
|
||||
// Date/time pickers with auto-adjustment logic
|
||||
[
|
||||
{ displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' },
|
||||
{ displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' },
|
||||
{ displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' },
|
||||
{ displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' },
|
||||
].forEach(({ displayId, inputId, mode }) => {
|
||||
{ displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' },
|
||||
{ displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' },
|
||||
{ displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' },
|
||||
{ displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' },
|
||||
].forEach(({ displayId, inputId, mode, role }) => {
|
||||
const disp = document.getElementById(displayId);
|
||||
if (!disp) return;
|
||||
const open = async () => {
|
||||
const current = document.getElementById(inputId)?.value || '';
|
||||
const result = await openDatePicker(disp, current, mode);
|
||||
if (result !== null) setDtValue(inputId, result, mode);
|
||||
const oldStart = mode === 'datetime'
|
||||
? document.getElementById('ev-start').value
|
||||
: document.getElementById('ev-start-date').value;
|
||||
const oldEnd = mode === 'datetime'
|
||||
? document.getElementById('ev-end').value
|
||||
: document.getElementById('ev-end-date').value;
|
||||
|
||||
const result = await openDatePicker(disp, current, mode);
|
||||
if (result === null) return;
|
||||
setDtValue(inputId, result, mode);
|
||||
|
||||
if (role === 'start') {
|
||||
// Adjust end to maintain duration
|
||||
if (mode === 'datetime') {
|
||||
const os = oldStart ? new Date(oldStart) : null;
|
||||
const oe = oldEnd ? new Date(oldEnd) : null;
|
||||
const ns = new Date(result);
|
||||
const duration = (os && oe && oe > os) ? (oe - os) : 3600000;
|
||||
const ne = new Date(ns.getTime() + duration);
|
||||
setDtValue('ev-end', toLocalDatetimeInput(ne), 'datetime');
|
||||
} else {
|
||||
const endVal = document.getElementById('ev-end-date').value;
|
||||
if (!endVal || endVal < result) {
|
||||
setDtValue('ev-end-date', result, 'date');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Validate end is not before start
|
||||
if (mode === 'datetime') {
|
||||
const startVal = document.getElementById('ev-start').value;
|
||||
if (startVal && new Date(result) <= new Date(startVal)) {
|
||||
const corrected = new Date(new Date(startVal).getTime() + 3600000);
|
||||
setDtValue('ev-end', toLocalDatetimeInput(corrected), 'datetime');
|
||||
showToast(t('error_end_before_start'), true);
|
||||
}
|
||||
} else {
|
||||
const startVal = document.getElementById('ev-start-date').value;
|
||||
if (startVal && result < startVal) {
|
||||
setDtValue('ev-end-date', startVal, 'date');
|
||||
showToast(t('error_end_before_start'), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
disp.addEventListener('click', open);
|
||||
disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); });
|
||||
@@ -1091,6 +1272,41 @@ function bindEventModal() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Recurrence UI ──────────────────────────────────────
|
||||
const recSel = document.getElementById('ev-recurrence');
|
||||
const customPanel = document.getElementById('ev-recurrence-custom');
|
||||
const recFreq = document.getElementById('ev-rec-freq');
|
||||
const weekdaysDiv = document.getElementById('ev-rec-weekdays');
|
||||
const endTypeSel = document.getElementById('ev-rec-end-type');
|
||||
|
||||
recSel.addEventListener('change', () => {
|
||||
customPanel.classList.toggle('hidden', recSel.value !== 'custom');
|
||||
});
|
||||
|
||||
recFreq.addEventListener('change', () => {
|
||||
weekdaysDiv.classList.toggle('hidden', recFreq.value !== 'WEEKLY');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.rec-day-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => btn.classList.toggle('active'));
|
||||
});
|
||||
|
||||
endTypeSel.addEventListener('change', () => {
|
||||
document.getElementById('ev-rec-end-count').classList.toggle('hidden', endTypeSel.value !== 'count');
|
||||
document.getElementById('ev-rec-end-until').classList.toggle('hidden', endTypeSel.value !== 'until');
|
||||
});
|
||||
|
||||
const untilDisp = document.getElementById('ev-rec-until-display');
|
||||
if (untilDisp) {
|
||||
const openUntil = async () => {
|
||||
const current = document.getElementById('ev-rec-until').value || '';
|
||||
const result = await openDatePicker(untilDisp, current, 'date');
|
||||
if (result !== null) setDtValue('ev-rec-until', result, 'date');
|
||||
};
|
||||
untilDisp.addEventListener('click', openUntil);
|
||||
untilDisp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openUntil(); });
|
||||
}
|
||||
|
||||
document.getElementById('ev-save').onclick = async () => {
|
||||
const title = document.getElementById('ev-title').value.trim();
|
||||
if (!title) { showToast(t('error_enter_title'), true); return; }
|
||||
@@ -1102,6 +1318,7 @@ function bindEventModal() {
|
||||
const loc = document.getElementById('ev-location').value.trim();
|
||||
const desc = document.getElementById('ev-description').value.trim();
|
||||
const color = state.selectedEventColor;
|
||||
const rrule = buildRruleFromUI();
|
||||
|
||||
let start, end;
|
||||
if (allDay) {
|
||||
@@ -1127,7 +1344,7 @@ function bindEventModal() {
|
||||
);
|
||||
} else if (ev.source === 'local') {
|
||||
await api.put(`/local/events/${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, rrule: rrule || '' }
|
||||
);
|
||||
} else if (ev.source === 'ical') {
|
||||
const subId = ev.calendar_id.replace('ical-', '');
|
||||
@@ -1136,8 +1353,8 @@ function bindEventModal() {
|
||||
);
|
||||
} 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 }
|
||||
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`,
|
||||
{ title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
|
||||
);
|
||||
}
|
||||
showToast(t('event_updated'));
|
||||
@@ -1153,6 +1370,7 @@ function bindEventModal() {
|
||||
await api.post('/local/events', {
|
||||
calendar_id: calId, title, start, end, allDay,
|
||||
location: loc, description: desc, color: color || null,
|
||||
rrule: rrule || null,
|
||||
});
|
||||
showToast(t('event_created'));
|
||||
} else {
|
||||
@@ -1160,6 +1378,7 @@ function bindEventModal() {
|
||||
await api.post('/caldav/events', {
|
||||
calendar_id: calId, title, start, end, allDay,
|
||||
location: loc, description: desc, color: color || null,
|
||||
rrule: rrule || null,
|
||||
});
|
||||
showToast(t('event_created'));
|
||||
}
|
||||
@@ -1184,7 +1403,7 @@ function bindEventModal() {
|
||||
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)}&calendar_id=${ev.calendar_id}`);
|
||||
}
|
||||
showToast(t('event_deleted'));
|
||||
closeModal('modal-event');
|
||||
|
||||
@@ -142,6 +142,15 @@ const translations = {
|
||||
error_enter_title: 'Bitte Titel eingeben',
|
||||
error_enter_date: 'Bitte Datum eingeben',
|
||||
error_enter_start: 'Bitte Start-Zeit eingeben',
|
||||
error_end_before_start: 'Ende kann nicht vor dem Start liegen',
|
||||
ctx_create_event: 'Neuen Termin erstellen',
|
||||
event_readonly: 'Abonnierte Termine können nicht bearbeitet werden',
|
||||
rec_label: 'Wiederholung',
|
||||
rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich',
|
||||
rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…',
|
||||
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
|
||||
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
|
||||
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
|
||||
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
|
||||
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
|
||||
confirm_delete_event: '"{title}" wirklich löschen?',
|
||||
@@ -337,6 +346,15 @@ const translations = {
|
||||
error_enter_title: 'Please enter a title',
|
||||
error_enter_date: 'Please enter a date',
|
||||
error_enter_start: 'Please enter a start time',
|
||||
error_end_before_start: 'End cannot be before start',
|
||||
ctx_create_event: 'Create new event',
|
||||
event_readonly: 'Subscribed events cannot be edited',
|
||||
rec_label: 'Recurrence',
|
||||
rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly',
|
||||
rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…',
|
||||
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
|
||||
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
|
||||
rec_on_date: 'On date', rec_occurrences: 'occurrences',
|
||||
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
|
||||
event_updated: 'Event updated', event_created: 'Event created',
|
||||
confirm_delete_event: 'Really delete "{title}"?',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||
@@ -124,10 +124,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
rowCells.forEach(cell => {
|
||||
const key = dateKey(cell);
|
||||
const isOther = cell.getMonth() !== primaryMonth;
|
||||
const todayCls = isToday(cell) ? 'today' : '';
|
||||
const otherCls = isOther ? 'other-month' : '';
|
||||
const numCls = isToday(cell) ? 'today' : '';
|
||||
colsHtml += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}">
|
||||
const todayCls = isToday(cell) ? 'today' : '';
|
||||
const otherCls = isOther ? 'other-month' : '';
|
||||
const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : '';
|
||||
const numCls = isToday(cell) ? 'today' : '';
|
||||
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls}" data-date="${key}">
|
||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||
</div>`;
|
||||
});
|
||||
@@ -148,6 +149,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
|
||||
// Click handlers via event delegation on the body
|
||||
const body = container.querySelector('.month-body');
|
||||
|
||||
// Single click: select day (or handle event / more clicks)
|
||||
body.addEventListener('click', e => {
|
||||
// Span event click
|
||||
const spanEl = e.target.closest('.month-span-event');
|
||||
@@ -161,13 +164,30 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
const moreEl = e.target.closest('.month-more');
|
||||
if (moreEl) {
|
||||
e.stopPropagation();
|
||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
|
||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
|
||||
return;
|
||||
}
|
||||
// Column click → navigate to day view
|
||||
// Column click → select day
|
||||
const colEl = e.target.closest('.month-col');
|
||||
if (colEl) {
|
||||
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'));
|
||||
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'select');
|
||||
}
|
||||
});
|
||||
|
||||
// Double click: navigate to day view
|
||||
body.addEventListener('dblclick', e => {
|
||||
const colEl = e.target.closest('.month-col');
|
||||
if (colEl && !e.target.closest('.month-span-event')) {
|
||||
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'navigate');
|
||||
}
|
||||
});
|
||||
|
||||
// Right click: context menu
|
||||
body.addEventListener('contextmenu', e => {
|
||||
const colEl = e.target.closest('.month-col');
|
||||
if (colEl) {
|
||||
e.preventDefault();
|
||||
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'context', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ requests==2.32.3
|
||||
pyotp==2.9.0
|
||||
qrcode[pil]==8.0
|
||||
Pillow==11.0.0
|
||||
python-dateutil==2.9.0
|
||||
|
||||
Reference in New Issue
Block a user