feat: Kalender mit FullCalendar - Woche/Monat/Tag, Drag&Drop, Wiederholungen

Kalender-UI komplett neu aufgesetzt mit FullCalendar:
* Drei Ansichten: Monat, Woche, Tag - ueber Toolbar wechselbar
* Drag & Drop: Termine zwischen Tagen verschieben
* Resize: Termindauer direkt am Rand ziehen
* Sidebar mit aktiven Kalendern (Checkbox fuers Ein-/Ausblenden)
* Deutsch lokalisiert, Woche startet Mo, Wochennummern
* Heute-Marker + Jetzt-Linie in Woche/Tag

Terminbearbeitung:
* Titel, Ort, Beschreibung, Zeitraum (oder ganztaegig)
* Wiederholungs-Editor: taeglich, woechentlich (mit Wochentagen),
  monatlich (auch "jeden 2. Mittwoch"), jaehrlich - jeweils mit
  Intervall, Enddatum oder Wiederholungsanzahl
* RRULE-Feld (RFC 5545) wird generiert und vom rrule-Plugin fuer
  die Anzeige im Kalender gerendert

Backend:
* CalendarEvent: description + location Spalten ergaenzt
* Calendar: ical_password_hash fuer passwortgeschuetzte Abo-Links
* /calendars/<id>/ical-link unterstuetzt password + clear_password
* DELETE /calendars/<id>/ical-link zum Zurueckziehen
* ical_export erzwingt HTTP Basic Auth wenn Passwort gesetzt -
  DAVx5, Apple Cal, Thunderbird verstehen das out-of-the-box

Frontend-Deps: @fullcalendar/{core,daygrid,timegrid,interaction,
rrule,vue3}, rrule - ca. 150KB Bundle-Overhead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-12 12:32:59 +02:00
parent 04bc3f80ec
commit c5284f57e0
5 changed files with 591 additions and 186 deletions

View File

@ -2,11 +2,11 @@ import secrets
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import request, jsonify from flask import request, jsonify, Response
from app.api import api_bp from app.api import api_bp
from app.api.auth import token_required from app.api.auth import token_required
from app.extensions import db from app.extensions import db, bcrypt
from app.models.calendar import Calendar, CalendarEvent, CalendarShare from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.user import User from app.models.user import User
@ -166,21 +166,24 @@ def create_event(cal_id):
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400 return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
event_uid = str(uuid.uuid4()) event_uid = str(uuid.uuid4())
description = (data.get('description') or '').strip()
location = (data.get('location') or '').strip()
rrule = (data.get('recurrence_rule') or '').strip()
# Build simple iCal data
ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day, ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day,
data.get('description', ''), data.get('location', ''), description, location, rrule)
data.get('recurrence_rule', ''))
event = CalendarEvent( event = CalendarEvent(
calendar_id=cal_id, calendar_id=cal_id,
uid=event_uid, uid=event_uid,
ical_data=ical_data, ical_data=ical_data,
summary=summary, summary=summary,
description=description or None,
location=location or None,
dtstart=dtstart_dt, dtstart=dtstart_dt,
dtend=dtend_dt, dtend=dtend_dt,
all_day=all_day, all_day=all_day,
recurrence_rule=data.get('recurrence_rule'), recurrence_rule=rrule or None,
) )
db.session.add(event) db.session.add(event)
db.session.commit() db.session.commit()
@ -202,14 +205,18 @@ def update_event(event_id):
data = request.get_json() data = request.get_json()
if 'summary' in data: if 'summary' in data:
event.summary = data['summary'].strip() event.summary = data['summary'].strip()
if 'description' in data:
event.description = (data['description'] or '').strip() or None
if 'location' in data:
event.location = (data['location'] or '').strip() or None
if 'dtstart' in data: if 'dtstart' in data:
event.dtstart = datetime.fromisoformat(data['dtstart']) event.dtstart = datetime.fromisoformat(data['dtstart'])
if 'dtend' in data: if 'dtend' in data:
event.dtend = datetime.fromisoformat(data['dtend']) event.dtend = datetime.fromisoformat(data['dtend']) if data['dtend'] else None
if 'all_day' in data: if 'all_day' in data:
event.all_day = data['all_day'] event.all_day = data['all_day']
if 'recurrence_rule' in data: if 'recurrence_rule' in data:
event.recurrence_rule = data['recurrence_rule'] event.recurrence_rule = (data['recurrence_rule'] or '').strip() or None
if 'calendar_id' in data: if 'calendar_id' in data:
new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True) new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True)
if cerr: if cerr:
@ -218,7 +225,7 @@ def update_event(event_id):
event.ical_data = _build_ical( event.ical_data = _build_ical(
event.uid, event.summary, event.dtstart, event.dtend, event.uid, event.summary, event.dtstart, event.dtend,
event.all_day, data.get('description', ''), data.get('location', ''), event.all_day, event.description or '', event.location or '',
event.recurrence_rule or '' event.recurrence_rule or ''
) )
event.updated_at = datetime.now(timezone.utc) event.updated_at = datetime.now(timezone.utc)
@ -334,19 +341,58 @@ def generate_ical_link(cal_id):
if not cal or cal.owner_id != user.id: if not cal or cal.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404 return jsonify({'error': 'Nicht gefunden'}), 404
data = request.get_json(silent=True) or {}
password = (data.get('password') or '').strip()
if not cal.ical_token:
cal.ical_token = secrets.token_urlsafe(32) cal.ical_token = secrets.token_urlsafe(32)
if password:
cal.ical_password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
elif data.get('clear_password'):
cal.ical_password_hash = None
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
'ical_url': f'/ical/{cal.ical_token}', 'ical_url': f'/ical/{cal.ical_token}',
'token': cal.ical_token, 'token': cal.ical_token,
'has_password': bool(cal.ical_password_hash),
}), 200 }), 200
@api_bp.route('/calendars/<int:cal_id>/ical-link', methods=['DELETE'])
@token_required
def revoke_ical_link(cal_id):
user = request.current_user
cal = db.session.get(Calendar, cal_id)
if not cal or cal.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
cal.ical_token = None
cal.ical_password_hash = None
db.session.commit()
return jsonify({'message': 'Link zurueckgezogen'}), 200
def _basic_auth_challenge():
return Response(
'Kalender erfordert Passwort', 401,
{'WWW-Authenticate': 'Basic realm="Mini-Cloud Kalender"'}
)
def ical_export(token): def ical_export(token):
cal = Calendar.query.filter_by(ical_token=token).first() cal = Calendar.query.filter_by(ical_token=token).first()
if not cal: if not cal:
return jsonify({'error': 'Nicht gefunden'}), 404 return jsonify({'error': 'Nicht gefunden'}), 404
# Password protection via HTTP Basic (compatible with DAVx5, Apple Cal,
# Thunderbird, curl, etc.). Username is ignored.
if cal.ical_password_hash:
auth = request.authorization
if not auth or not auth.password:
return _basic_auth_challenge()
if not bcrypt.check_password_hash(cal.ical_password_hash, auth.password):
return _basic_auth_challenge()
events = CalendarEvent.query.filter_by(calendar_id=cal.id).all() events = CalendarEvent.query.filter_by(calendar_id=cal.id).all()
lines = [ lines = [
@ -357,13 +403,11 @@ def ical_export(token):
] ]
for e in events: for e in events:
if e.ical_data: if e.ical_data:
# Extract VEVENT from stored ical_data
lines.append(e.ical_data) lines.append(e.ical_data)
else: else:
lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day)) lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day))
lines.append('END:VCALENDAR') lines.append('END:VCALENDAR')
from flask import Response
return Response( return Response(
'\r\n'.join(lines), '\r\n'.join(lines),
mimetype='text/calendar', mimetype='text/calendar',
@ -380,6 +424,8 @@ def _format_dt(dt, all_day=False):
def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''): def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''):
if not dtend:
dtend = dtstart
lines = [ lines = [
'BEGIN:VEVENT', 'BEGIN:VEVENT',
f'UID:{uid}', f'UID:{uid}',

View File

@ -12,6 +12,7 @@ class Calendar(db.Model):
color = db.Column(db.String(7), default='#3788d8') color = db.Column(db.String(7), default='#3788d8')
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
ical_token = db.Column(db.String(64), unique=True, nullable=True, index=True) ical_token = db.Column(db.String(64), unique=True, nullable=True, index=True)
ical_password_hash = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)) onupdate=lambda: datetime.now(timezone.utc))
@ -29,6 +30,7 @@ class Calendar(db.Model):
'color': self.color, 'color': self.color,
'description': self.description, 'description': self.description,
'ical_token': self.ical_token, 'ical_token': self.ical_token,
'ical_has_password': bool(self.ical_password_hash),
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
} }
@ -41,6 +43,8 @@ class CalendarEvent(db.Model):
uid = db.Column(db.String(255), unique=True, nullable=False) uid = db.Column(db.String(255), unique=True, nullable=False)
ical_data = db.Column(db.Text, nullable=False) # Full VCALENDAR component ical_data = db.Column(db.Text, nullable=False) # Full VCALENDAR component
summary = db.Column(db.String(500), nullable=True) summary = db.Column(db.String(500), nullable=True)
description = db.Column(db.Text, nullable=True)
location = db.Column(db.String(500), nullable=True)
dtstart = db.Column(db.DateTime, nullable=True, index=True) dtstart = db.Column(db.DateTime, nullable=True, index=True)
dtend = db.Column(db.DateTime, nullable=True) dtend = db.Column(db.DateTime, nullable=True)
all_day = db.Column(db.Boolean, default=False) all_day = db.Column(db.Boolean, default=False)
@ -55,6 +59,8 @@ class CalendarEvent(db.Model):
'calendar_id': self.calendar_id, 'calendar_id': self.calendar_id,
'uid': self.uid, 'uid': self.uid,
'summary': self.summary, 'summary': self.summary,
'description': self.description,
'location': self.location,
'dtstart': self.dtstart.isoformat() if self.dtstart else None, 'dtstart': self.dtstart.isoformat() if self.dtstart else None,
'dtend': self.dtend.isoformat() if self.dtend else None, 'dtend': self.dtend.isoformat() if self.dtend else None,
'all_day': self.all_day, 'all_day': self.all_day,

View File

@ -8,11 +8,18 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/rrule": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@primevue/themes": "^4.5.4", "@primevue/themes": "^4.5.4",
"axios": "^1.15.0", "axios": "^1.15.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.5", "primevue": "^4.5.5",
"rrule": "^2.8.1",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
@ -101,6 +108,65 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/rrule": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/rrule/-/rrule-6.1.20.tgz",
"integrity": "sha512-5Awk7bmaA97hSZRpIBehenXkYreVIvx8nnaMFZ/LDGRuK1mgbR4vSUrDTvVU+oEqqKnj/rqMBByWqN5NeehQxw==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"rrule": "^2.6.0"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.20"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"vue": "^3.0.11"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@ -1393,6 +1459,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/primeicons": { "node_modules/primeicons": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
@ -1471,6 +1547,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1522,9 +1607,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.8", "version": "8.0.8",

View File

@ -9,11 +9,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/rrule": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@primevue/themes": "^4.5.4", "@primevue/themes": "^4.5.4",
"axios": "^1.15.0", "axios": "^1.15.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.5", "primevue": "^4.5.5",
"rrule": "^2.8.1",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },

View File

@ -4,44 +4,24 @@
<h2>Kalender</h2> <h2>Kalender</h2>
<div class="header-actions"> <div class="header-actions">
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" /> <Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
<Button icon="pi pi-plus" label="Neues Event" size="small" @click="openNewEvent" /> <Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
</div> </div>
</div> </div>
<div class="calendar-layout"> <div class="calendar-layout">
<aside class="calendar-sidebar"> <aside class="calendar-sidebar">
<h4>Kalender</h4>
<div v-for="cal in calendars" :key="cal.id" class="calendar-item"> <div v-for="cal in calendars" :key="cal.id" class="calendar-item">
<input type="checkbox" v-model="visibleCalendars[cal.id]" @change="refreshEvents" />
<div class="calendar-color" :style="{ background: cal.color }"></div> <div class="calendar-color" :style="{ background: cal.color }"></div>
<span>{{ cal.name }}</span> <span class="cal-name">{{ cal.name }}</span>
<span v-if="cal.owner_name" class="shared-label">({{ cal.owner_name }})</span> <span v-if="cal.permission !== 'owner'" class="shared-label">(geteilt)</span>
<Button icon="pi pi-ellipsis-v" text size="small" @click="openCalendarMenu(cal, $event)" /> <Button icon="pi pi-ellipsis-v" text size="small" @click.stop="openCalendarMenu(cal)" />
</div> </div>
</aside> </aside>
<div class="calendar-main"> <div class="calendar-main">
<div class="cal-nav"> <FullCalendar ref="fcRef" :options="calendarOptions" />
<Button icon="pi pi-chevron-left" text @click="changeMonth(-1)" />
<h3>{{ currentMonthLabel }}</h3>
<Button icon="pi pi-chevron-right" text @click="changeMonth(1)" />
<Button label="Heute" text size="small" @click="goToday" />
</div>
<div class="cal-grid">
<div class="cal-header" v-for="day in weekDays" :key="day">{{ day }}</div>
<div
v-for="(cell, i) in calendarCells"
:key="i"
class="cal-cell"
:class="{ 'other-month': !cell.currentMonth, 'today': cell.isToday }"
@click="openNewEventOnDate(cell.date)"
>
<span class="cell-day">{{ cell.day }}</span>
<div v-for="evt in cell.events" :key="evt.id" class="cell-event"
:style="{ background: evt.color }" @click.stop="openEditEvent(evt)">
{{ evt.summary }}
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -62,7 +42,8 @@
</Dialog> </Dialog>
<!-- Event Dialog --> <!-- Event Dialog -->
<Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Event bearbeiten' : 'Neues Event'" modal :style="{ width: '500px' }"> <Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'"
modal :style="{ width: '560px' }">
<div class="field"> <div class="field">
<label>Titel</label> <label>Titel</label>
<InputText v-model="eventForm.summary" fluid autofocus /> <InputText v-model="eventForm.summary" fluid autofocus />
@ -74,16 +55,73 @@
<div class="field-row"> <div class="field-row">
<div class="field"> <div class="field">
<label>Start</label> <label>Start</label>
<InputText v-model="eventForm.dtstart" type="datetime-local" fluid /> <InputText v-model="eventForm.dtstart" :type="eventForm.all_day ? 'date' : 'datetime-local'" fluid />
</div> </div>
<div class="field"> <div class="field">
<label>Ende</label> <label>Ende</label>
<InputText v-model="eventForm.dtend" type="datetime-local" fluid /> <InputText v-model="eventForm.dtend" :type="eventForm.all_day ? 'date' : 'datetime-local'" fluid />
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label> <label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
</div> </div>
<div class="field">
<label>Ort</label>
<InputText v-model="eventForm.location" fluid />
</div>
<div class="field">
<label>Beschreibung</label>
<Textarea v-model="eventForm.description" rows="3" fluid />
</div>
<!-- Recurrence editor -->
<div class="field">
<label>Wiederholung</label>
<Select v-model="recurFreq" :options="recurFreqOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div v-if="recurFreq !== 'none'" class="recur-details">
<div class="field-row">
<div class="field">
<label>Alle</label>
<InputText v-model.number="recurInterval" type="number" min="1" style="width: 80px" />
<span class="recur-unit">{{ recurUnitLabel }}</span>
</div>
<div class="field">
<label>Ende</label>
<Select v-model="recurEndMode" :options="recurEndOptions" optionLabel="label" optionValue="value" />
</div>
</div>
<div v-if="recurEndMode === 'count'" class="field">
<label>Nach X Wiederholungen</label>
<InputText v-model.number="recurCount" type="number" min="1" style="width: 100px" />
</div>
<div v-if="recurEndMode === 'until'" class="field">
<label>Bis Datum</label>
<InputText v-model="recurUntil" type="date" style="width: 180px" />
</div>
<div v-if="recurFreq === 'weekly'" class="field">
<label>An Wochentagen</label>
<div class="weekday-row">
<label v-for="d in weekdayBtns" :key="d.value" class="weekday-btn" :class="{ active: recurWeekdays.includes(d.value) }">
<input type="checkbox" :value="d.value" v-model="recurWeekdays" /> {{ d.label }}
</label>
</div>
</div>
<div v-if="recurFreq === 'monthly'" class="field">
<label><input type="checkbox" v-model="recurByDayOfWeek" /> Am X. Wochentag des Monats (z.B. "jeden 2. Mittwoch")</label>
</div>
<div v-if="recurFreq === 'monthly' && recurByDayOfWeek" class="field-row">
<div class="field">
<label>Position</label>
<Select v-model="recurBySetPos" :options="setPosOptions" optionLabel="label" optionValue="value" />
</div>
<div class="field">
<label>Wochentag</label>
<Select v-model="recurByDay" :options="weekdayBtns" optionLabel="label" optionValue="value" />
</div>
</div>
</div>
<template #footer> <template #footer>
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="confirmDeleteEvent = true" /> <Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="confirmDeleteEvent = true" />
<Button label="Abbrechen" text @click="showEventDialog = false" /> <Button label="Abbrechen" text @click="showEventDialog = false" />
@ -91,12 +129,12 @@
</template> </template>
</Dialog> </Dialog>
<!-- Calendar Context Menu --> <!-- Calendar Menu -->
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '400px' }"> <Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '480px' }">
<div v-if="selectedCal" class="cal-menu-content"> <div v-if="selectedCal" class="cal-menu-content">
<p><strong>{{ selectedCal.name }}</strong></p> <p><strong>{{ selectedCal.name }}</strong></p>
<div class="field"> <div v-if="selectedCal.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label> <label>Mit Benutzer teilen</label>
<div class="share-row"> <div class="share-row">
<InputText v-model="shareUsername" placeholder="Benutzername" fluid /> <InputText v-model="shareUsername" placeholder="Benutzername" fluid />
@ -105,11 +143,24 @@
</div> </div>
</div> </div>
<div v-if="selectedCal.permission === 'owner'" class="field"> <div v-if="selectedCal.permission === 'owner'" class="field ical-block">
<Button label="iCal-Link generieren" icon="pi pi-link" outlined size="small" @click="generateIcalLink" /> <label>iCal-Abo-Link</label>
<div v-if="icalUrl" class="ical-url"> <div v-if="!selectedCal.ical_token">
<InputText v-model="icalPassword" placeholder="Passwort (optional)" type="password" style="width: 220px" />
<Button label="Link erstellen" icon="pi pi-link" outlined size="small" @click="generateIcalLink" style="margin-left: 0.5rem" />
</div>
<div v-else class="ical-url">
<code>{{ fullIcalUrl }}</code> <code>{{ fullIcalUrl }}</code>
<Button icon="pi pi-copy" text size="small" @click="copyIcal" /> <Button icon="pi pi-copy" text size="small" @click="copyIcal" title="Kopieren" />
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
<span v-if="selectedCal.ical_has_password" class="hint-badge">
<i class="pi pi-lock"></i> Passwortgeschuetzt
</span>
<InputText v-model="icalPassword" placeholder="Passwort aendern" type="password" style="width: 200px" />
<Button label="Passwort setzen" size="small" @click="setIcalPassword" />
<Button v-if="selectedCal.ical_has_password" label="Passwort entfernen" size="small" text @click="clearIcalPassword" />
<Button label="Link zurueckziehen" severity="danger" size="small" text @click="revokeIcal" />
</div>
</div> </div>
</div> </div>
@ -118,39 +169,44 @@
</div> </div>
</Dialog> </Dialog>
<!-- Confirm delete event --> <Dialog v-model:visible="confirmDeleteEvent" header="Termin loeschen" modal :style="{ width: '400px' }">
<Dialog v-model:visible="confirmDeleteEvent" header="Event loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ editingEvent?.summary }}</strong> wirklich loeschen?</p> <p>Moechtest du <strong>{{ editingEvent?.summary }}</strong> wirklich loeschen?</p>
<template #footer> <template #footer>
<Button label="Abbrechen" text @click="confirmDeleteEvent = false" /> <Button label="Abbrechen" text @click="confirmDeleteEvent = false" />
<Button label="Loeschen" severity="danger" @click="deleteEvent; confirmDeleteEvent = false" /> <Button label="Loeschen" severity="danger" @click="deleteEvent()" />
</template> </template>
</Dialog> </Dialog>
<!-- Confirm delete calendar -->
<Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }"> <Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Events loeschen?</p> <p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Terminen loeschen?</p>
<template #footer> <template #footer>
<Button label="Abbrechen" text @click="confirmDeleteCal = false" /> <Button label="Abbrechen" text @click="confirmDeleteCal = false" />
<Button label="Loeschen" severity="danger" @click="deleteCalendar(); confirmDeleteCal = false" /> <Button label="Loeschen" severity="danger" @click="deleteCalendar()" />
</template> </template>
</Dialog> </Dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client' import apiClient from '../api/client'
import Button from 'primevue/button' import Button from 'primevue/button'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select' import Select from 'primevue/select'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import rrulePlugin from '@fullcalendar/rrule'
import deLocale from '@fullcalendar/core/locales/de'
const toast = useToast() const toast = useToast()
const calendars = ref([]) const calendars = ref([])
const allEvents = ref([]) const visibleCalendars = reactive({})
const currentDate = ref(new Date()) const fcRef = ref(null)
const showNewCalendar = ref(false) const showNewCalendar = ref(false)
const newCalName = ref('') const newCalName = ref('')
@ -158,92 +214,237 @@ const newCalColor = ref('#3788d8')
const showEventDialog = ref(false) const showEventDialog = ref(false)
const editingEvent = ref(null) const editingEvent = ref(null)
const eventForm = ref({ summary: '', calendar_id: null, dtstart: '', dtend: '', all_day: false }) const eventForm = ref({
summary: '', description: '', location: '',
calendar_id: null, dtstart: '', dtend: '', all_day: false,
})
// Recurrence editor state
const recurFreq = ref('none')
const recurInterval = ref(1)
const recurEndMode = ref('forever')
const recurCount = ref(10)
const recurUntil = ref('')
const recurWeekdays = ref([])
const recurByDayOfWeek = ref(false)
const recurBySetPos = ref(1)
const recurByDay = ref('MO')
const recurFreqOptions = [
{ label: 'Keine Wiederholung', value: 'none' },
{ label: 'Taeglich', value: 'daily' },
{ label: 'Woechentlich', value: 'weekly' },
{ label: 'Monatlich', value: 'monthly' },
{ label: 'Jaehrlich', value: 'yearly' },
]
const recurEndOptions = [
{ label: 'Kein Ende', value: 'forever' },
{ label: 'Bis Datum', value: 'until' },
{ label: 'Nach Anzahl', value: 'count' },
]
const weekdayBtns = [
{ label: 'Mo', value: 'MO' }, { label: 'Di', value: 'TU' },
{ label: 'Mi', value: 'WE' }, { label: 'Do', value: 'TH' },
{ label: 'Fr', value: 'FR' }, { label: 'Sa', value: 'SA' }, { label: 'So', value: 'SU' },
]
const setPosOptions = [
{ label: '1.', value: 1 }, { label: '2.', value: 2 }, { label: '3.', value: 3 },
{ label: '4.', value: 4 }, { label: 'letzter', value: -1 },
]
const recurUnitLabel = computed(() => ({
daily: 'Tag(e)', weekly: 'Woche(n)', monthly: 'Monat(e)', yearly: 'Jahr(e)',
}[recurFreq.value] || ''))
const showCalMenu = ref(false) const showCalMenu = ref(false)
const selectedCal = ref(null) const selectedCal = ref(null)
const shareUsername = ref('') const shareUsername = ref('')
const sharePermission = ref('read') const sharePermission = ref('read')
const icalUrl = ref('') const icalPassword = ref('')
const confirmDeleteEvent = ref(false) const confirmDeleteEvent = ref(false)
const confirmDeleteCal = ref(false) const confirmDeleteCal = ref(false)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }] const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner')) const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
const fullIcalUrl = computed(() => icalUrl.value ? `${window.location.origin}${icalUrl.value}` : '') const fullIcalUrl = computed(() =>
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
)
const currentMonthLabel = computed(() => { // FullCalendar configuration
return currentDate.value.toLocaleString('de-DE', { month: 'long', year: 'numeric' }) const calendarOptions = computed(() => ({
}) plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, rrulePlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
locale: deLocale,
firstDay: 1,
nowIndicator: true,
editable: true,
droppable: true,
eventDurationEditable: true,
selectable: true,
weekNumbers: true,
height: 'auto',
allDaySlot: true,
events: fetchEvents,
eventClick: onEventClick,
select: onDateSelect,
eventDrop: onEventDrop,
eventResize: onEventDrop,
}))
const calendarCells = computed(() => { async function fetchEvents(info, successCallback, failureCallback) {
const year = currentDate.value.getFullYear() try {
const month = currentDate.value.getMonth() const all = []
const firstDay = new Date(year, month, 1) for (const cal of calendars.value) {
const lastDay = new Date(year, month + 1, 0) if (visibleCalendars[cal.id] === false) continue
const today = new Date() const res = await apiClient.get(`/calendars/${cal.id}/events`, {
params: { start: info.startStr, end: info.endStr },
let startDay = (firstDay.getDay() + 6) % 7
const cells = []
for (let i = startDay - 1; i >= 0; i--) {
const d = new Date(year, month, -i)
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
}
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d)
const isToday = date.toDateString() === today.toDateString()
const dayEvents = allEvents.value.filter(e => {
const start = new Date(e.dtstart)
return start.getFullYear() === year && start.getMonth() === month && start.getDate() === d
}) })
cells.push({ date, day: d, currentMonth: true, isToday, events: dayEvents }) for (const e of res.data) {
all.push(toFcEvent(e, cal))
} }
while (cells.length < 42) {
const d = new Date(year, month + 1, cells.length - startDay - lastDay.getDate() + 1)
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
} }
successCallback(all)
return cells } catch (err) {
}) failureCallback(err)
}
function changeMonth(delta) {
const d = new Date(currentDate.value)
d.setMonth(d.getMonth() + delta)
currentDate.value = d
loadEvents()
} }
function goToday() { function toFcEvent(e, cal) {
currentDate.value = new Date() const fc = {
loadEvents() id: String(e.id),
title: e.summary,
color: cal.color,
allDay: e.all_day,
extendedProps: { ...e, _cal: cal },
}
if (e.recurrence_rule) {
// FullCalendar rrule plugin consumes the RRULE together with dtstart
fc.rrule = {
freq: extractFreq(e.recurrence_rule),
dtstart: e.dtstart,
...parseRRule(e.recurrence_rule),
}
if (e.dtend && e.dtstart) {
const ms = new Date(e.dtend).getTime() - new Date(e.dtstart).getTime()
if (ms > 0) fc.duration = msToDuration(ms)
}
} else {
fc.start = e.dtstart
fc.end = e.dtend
}
return fc
}
function extractFreq(rrule) {
const m = /FREQ=([A-Z]+)/.exec(rrule)
return m ? m[1].toLowerCase() : 'daily'
}
function parseRRule(rrule) {
const out = {}
const parts = rrule.replace(/^RRULE:/i, '').split(';')
for (const p of parts) {
const [k, v] = p.split('=')
if (!v) continue
const key = k.toUpperCase()
if (key === 'INTERVAL') out.interval = parseInt(v)
else if (key === 'COUNT') out.count = parseInt(v)
else if (key === 'UNTIL') out.until = v
else if (key === 'BYDAY') out.byweekday = v.split(',')
else if (key === 'BYSETPOS') out.bysetpos = v.split(',').map(Number)
}
return out
}
function msToDuration(ms) {
const hours = Math.floor(ms / 3600000)
const minutes = Math.floor((ms % 3600000) / 60000)
return { hours, minutes }
}
function buildRRule() {
if (recurFreq.value === 'none') return ''
const parts = [`FREQ=${recurFreq.value.toUpperCase()}`]
if (recurInterval.value > 1) parts.push(`INTERVAL=${recurInterval.value}`)
if (recurEndMode.value === 'count') parts.push(`COUNT=${recurCount.value}`)
if (recurEndMode.value === 'until' && recurUntil.value) {
parts.push(`UNTIL=${recurUntil.value.replace(/-/g, '')}T235959Z`)
}
if (recurFreq.value === 'weekly' && recurWeekdays.value.length) {
parts.push(`BYDAY=${recurWeekdays.value.join(',')}`)
}
if (recurFreq.value === 'monthly' && recurByDayOfWeek.value) {
parts.push(`BYDAY=${recurByDay.value}`)
parts.push(`BYSETPOS=${recurBySetPos.value}`)
}
return parts.join(';')
}
function loadRRuleIntoForm(rrule) {
if (!rrule) {
recurFreq.value = 'none'
return
}
const parsed = parseRRule(rrule)
recurFreq.value = extractFreq(rrule)
recurInterval.value = parsed.interval || 1
if (parsed.count) { recurEndMode.value = 'count'; recurCount.value = parsed.count }
else if (parsed.until) {
recurEndMode.value = 'until'
// UNTIL=20261231T235959Z -> 2026-12-31
const d = parsed.until.slice(0, 8)
recurUntil.value = `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
} else recurEndMode.value = 'forever'
recurWeekdays.value = parsed.byweekday || []
if (parsed.bysetpos) {
recurByDayOfWeek.value = true
recurBySetPos.value = parsed.bysetpos[0]
recurByDay.value = (parsed.byweekday || ['MO'])[0]
} else {
recurByDayOfWeek.value = false
}
}
function onEventClick(info) {
const e = info.event.extendedProps
openEditEvent(e)
}
function onDateSelect(info) {
openNewEvent(info.start, info.end, info.allDay)
}
async function onEventDrop(info) {
const e = info.event.extendedProps
try {
await apiClient.put(`/events/${e.id}`, {
dtstart: toServerISO(info.event.start, e.all_day),
dtend: info.event.end ? toServerISO(info.event.end, e.all_day) : null,
})
toast.add({ severity: 'success', summary: 'Verschoben', life: 2000 })
} catch (err) {
info.revert()
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
function refreshEvents() {
fcRef.value?.getApi().refetchEvents()
} }
async function loadCalendars() { async function loadCalendars() {
const res = await apiClient.get('/calendars') const res = await apiClient.get('/calendars')
calendars.value = res.data calendars.value = res.data
for (const c of calendars.value) {
if (!(c.id in visibleCalendars)) visibleCalendars[c.id] = true
}
if (!calendars.value.length) { if (!calendars.value.length) {
await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' }) await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' })
const res2 = await apiClient.get('/calendars') await loadCalendars()
calendars.value = res2.data
}
}
async function loadEvents() {
allEvents.value = []
for (const cal of calendars.value) {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
const start = new Date(year, month - 1, 1).toISOString()
const end = new Date(year, month + 2, 0).toISOString()
try {
const res = await apiClient.get(`/calendars/${cal.id}/events`, { params: { start, end } })
allEvents.value.push(...res.data.map(e => ({ ...e, color: cal.color, calendarName: cal.name })))
} catch { /* skip */ }
} }
} }
@ -253,33 +454,23 @@ async function createCalendar() {
showNewCalendar.value = false showNewCalendar.value = false
newCalName.value = '' newCalName.value = ''
await loadCalendars() await loadCalendars()
refreshEvents()
} }
function openNewEvent() { function openNewEvent(start, end, allDay = false) {
editingEvent.value = null editingEvent.value = null
const now = new Date() const now = start || new Date()
const later = new Date(now.getTime() + 3600000) const later = end || new Date(now.getTime() + 3600000)
eventForm.value = { eventForm.value = {
summary: '', summary: '',
description: '',
location: '',
calendar_id: ownCalendars.value[0]?.id, calendar_id: ownCalendars.value[0]?.id,
dtstart: toLocalISO(now), dtstart: toLocalISO(now, allDay),
dtend: toLocalISO(later), dtend: toLocalISO(later, allDay),
all_day: false, all_day: allDay,
}
showEventDialog.value = true
}
function openNewEventOnDate(date) {
editingEvent.value = null
const start = new Date(date); start.setHours(9, 0)
const end = new Date(date); end.setHours(10, 0)
eventForm.value = {
summary: '',
calendar_id: ownCalendars.value[0]?.id,
dtstart: toLocalISO(start),
dtend: toLocalISO(end),
all_day: false,
} }
loadRRuleIntoForm('')
showEventDialog.value = true showEventDialog.value = true
} }
@ -287,37 +478,57 @@ function openEditEvent(evt) {
editingEvent.value = evt editingEvent.value = evt
eventForm.value = { eventForm.value = {
summary: evt.summary, summary: evt.summary,
description: evt.description || '',
location: evt.location || '',
calendar_id: evt.calendar_id, calendar_id: evt.calendar_id,
dtstart: toLocalISO(new Date(evt.dtstart)), dtstart: toLocalISO(new Date(evt.dtstart), evt.all_day),
dtend: toLocalISO(new Date(evt.dtend)), dtend: evt.dtend ? toLocalISO(new Date(evt.dtend), evt.all_day) : '',
all_day: evt.all_day, all_day: evt.all_day,
} }
loadRRuleIntoForm(evt.recurrence_rule)
showEventDialog.value = true showEventDialog.value = true
} }
async function saveEvent() { async function saveEvent() {
if (!eventForm.value.summary.trim()) return if (!eventForm.value.summary.trim()) return
const payload = { ...eventForm.value } const payload = {
summary: eventForm.value.summary.trim(),
description: eventForm.value.description,
location: eventForm.value.location,
dtstart: fromLocalISO(eventForm.value.dtstart, eventForm.value.all_day),
dtend: eventForm.value.dtend ? fromLocalISO(eventForm.value.dtend, eventForm.value.all_day) : null,
all_day: eventForm.value.all_day,
recurrence_rule: buildRRule(),
calendar_id: eventForm.value.calendar_id,
}
try {
if (editingEvent.value) { if (editingEvent.value) {
await apiClient.put(`/events/${editingEvent.value.id}`, payload) await apiClient.put(`/events/${editingEvent.value.id}`, payload)
} else { } else {
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload) await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
} }
showEventDialog.value = false showEventDialog.value = false
await loadEvents() refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
} }
async function deleteEvent() { async function deleteEvent() {
if (!editingEvent.value) return if (!editingEvent.value) return
try {
await apiClient.delete(`/events/${editingEvent.value.id}`) await apiClient.delete(`/events/${editingEvent.value.id}`)
confirmDeleteEvent.value = false
showEventDialog.value = false showEventDialog.value = false
await loadEvents() refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
} }
function openCalendarMenu(cal) { function openCalendarMenu(cal) {
selectedCal.value = cal selectedCal.value = cal
icalUrl.value = '' icalPassword.value = ''
showCalMenu.value = true showCalMenu.value = true
} }
@ -336,8 +547,37 @@ async function shareCalendar() {
async function generateIcalLink() { async function generateIcalLink() {
if (!selectedCal.value) return if (!selectedCal.value) return
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`) const body = {}
icalUrl.value = res.data.ical_url if (icalPassword.value) body.password = icalPassword.value
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`, body)
selectedCal.value.ical_token = res.data.token
selectedCal.value.ical_has_password = res.data.has_password
icalPassword.value = ''
toast.add({ severity: 'success', summary: 'Link erstellt', life: 2500 })
}
async function setIcalPassword() {
if (!selectedCal.value || !icalPassword.value) return
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`, { password: icalPassword.value })
selectedCal.value.ical_has_password = res.data.has_password
icalPassword.value = ''
toast.add({ severity: 'success', summary: 'Passwort gesetzt', life: 2500 })
}
async function clearIcalPassword() {
if (!selectedCal.value) return
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`, { clear_password: true })
selectedCal.value.ical_has_password = res.data.has_password
toast.add({ severity: 'success', summary: 'Passwort entfernt', life: 2500 })
}
async function revokeIcal() {
if (!selectedCal.value) return
if (!confirm('Link zurueckziehen? Abonnenten koennen danach nicht mehr zugreifen.')) return
await apiClient.delete(`/calendars/${selectedCal.value.id}/ical-link`)
selectedCal.value.ical_token = null
selectedCal.value.ical_has_password = false
toast.add({ severity: 'success', summary: 'Link zurueckgezogen', life: 2500 })
} }
function copyIcal() { function copyIcal() {
@ -349,18 +589,42 @@ async function deleteCalendar() {
if (!selectedCal.value) return if (!selectedCal.value) return
await apiClient.delete(`/calendars/${selectedCal.value.id}`) await apiClient.delete(`/calendars/${selectedCal.value.id}`)
showCalMenu.value = false showCalMenu.value = false
confirmDeleteCal.value = false
await loadCalendars() await loadCalendars()
await loadEvents() refreshEvents()
} }
function toLocalISO(date) { function toLocalISO(date, allDay = false) {
const pad = n => String(n).padStart(2, '0') const pad = n => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` const d = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
if (allDay) return d
return `${d}T${pad(date.getHours())}:${pad(date.getMinutes())}`
} }
function fromLocalISO(s, allDay = false) {
// datetime-local strings are in local tz - just pass through
if (allDay) return s + 'T00:00:00'
return s
}
function toServerISO(date, allDay = false) {
return toLocalISO(date, allDay)
}
watch(() => eventForm.value.all_day, (allDay) => {
// Toggle between date / datetime-local - strip or add time part
const fix = (v) => {
if (!v) return v
if (allDay) return v.length > 10 ? v.slice(0, 10) : v
return v.length === 10 ? `${v}T09:00` : v
}
eventForm.value.dtstart = fix(eventForm.value.dtstart)
eventForm.value.dtend = fix(eventForm.value.dtend)
})
onMounted(async () => { onMounted(async () => {
await loadCalendars() await loadCalendars()
await loadEvents() refreshEvents()
}) })
</script> </script>
@ -369,28 +633,27 @@ onMounted(async () => {
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.view-header h2 { margin: 0; } .view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; } .header-actions { display: flex; gap: 0.5rem; }
.calendar-layout { display: flex; gap: 1rem; } .calendar-layout { display: flex; gap: 1rem; align-items: flex-start; }
.calendar-sidebar { width: 220px; flex-shrink: 0; } .calendar-sidebar { width: 240px; flex-shrink: 0; }
.calendar-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
.calendar-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; } .calendar-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.calendar-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; } .calendar-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; } .cal-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.calendar-main { flex: 1; } .shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
.cal-nav { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .calendar-main { flex: 1; min-width: 0; }
.cal-nav h3 { margin: 0; min-width: 180px; text-align: center; } .field { margin-bottom: 0.75rem; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid var(--p-surface-200); } .field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.cal-header { padding: 0.5rem; text-align: center; font-weight: 600; font-size: 0.8rem; background: var(--p-surface-100); border-bottom: 1px solid var(--p-surface-200); } .field-row { display: flex; gap: 0.75rem; }
.cal-cell { min-height: 80px; padding: 0.25rem; border: 1px solid var(--p-surface-100); cursor: pointer; font-size: 0.8rem; }
.cal-cell:hover { background: var(--p-surface-50); }
.cal-cell.other-month { opacity: 0.4; }
.cal-cell.today { background: var(--p-primary-50); }
.cell-day { font-weight: 500; font-size: 0.75rem; }
.cell-event { font-size: 0.7rem; padding: 1px 4px; border-radius: 3px; color: white; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 1rem; }
.field-row .field { flex: 1; } .field-row .field { flex: 1; }
.share-row { display: flex; gap: 0.5rem; align-items: flex-start; } .recur-details { padding: 0.75rem; background: var(--p-surface-50); border-radius: 6px; margin-top: 0.5rem; }
.ical-url { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; } .recur-unit { display: inline-block; margin-left: 0.5rem; font-size: 0.85rem; color: var(--p-text-muted-color); }
.ical-url code { font-size: 0.75rem; word-break: break-all; } .weekday-row { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.cal-menu-content p { margin: 0 0 1rem; } .weekday-btn { display: flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; border-radius: 4px;
background: var(--p-surface-100); font-size: 0.85rem; cursor: pointer; }
.weekday-btn.active { background: var(--p-primary-100); }
.share-row { display: flex; gap: 0.5rem; align-items: center; }
.ical-block { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; margin-top: 1rem; }
.ical-url { font-size: 0.8rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.ical-url code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; word-break: break-all; }
.hint-badge { font-size: 0.75rem; color: var(--p-primary-700); display: inline-flex; gap: 0.25rem; align-items: center; }
</style> </style>