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:
parent
04bc3f80ec
commit
c5284f57e0
|
|
@ -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}',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function fetchEvents(info, successCallback, failureCallback) {
|
||||||
|
try {
|
||||||
|
const all = []
|
||||||
|
for (const cal of calendars.value) {
|
||||||
|
if (visibleCalendars[cal.id] === false) continue
|
||||||
|
const res = await apiClient.get(`/calendars/${cal.id}/events`, {
|
||||||
|
params: { start: info.startStr, end: info.endStr },
|
||||||
})
|
})
|
||||||
|
for (const e of res.data) {
|
||||||
const calendarCells = computed(() => {
|
all.push(toFcEvent(e, cal))
|
||||||
const year = currentDate.value.getFullYear()
|
}
|
||||||
const month = currentDate.value.getMonth()
|
}
|
||||||
const firstDay = new Date(year, month, 1)
|
successCallback(all)
|
||||||
const lastDay = new Date(year, month + 1, 0)
|
} catch (err) {
|
||||||
const today = new Date()
|
failureCallback(err)
|
||||||
|
}
|
||||||
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++) {
|
function toFcEvent(e, cal) {
|
||||||
const date = new Date(year, month, d)
|
const fc = {
|
||||||
const isToday = date.toDateString() === today.toDateString()
|
id: String(e.id),
|
||||||
const dayEvents = allEvents.value.filter(e => {
|
title: e.summary,
|
||||||
const start = new Date(e.dtstart)
|
color: cal.color,
|
||||||
return start.getFullYear() === year && start.getMonth() === month && start.getDate() === d
|
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,
|
||||||
})
|
})
|
||||||
cells.push({ date, day: d, currentMonth: true, isToday, events: dayEvents })
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (cells.length < 42) {
|
function refreshEvents() {
|
||||||
const d = new Date(year, month + 1, cells.length - startDay - lastDay.getDate() + 1)
|
fcRef.value?.getApi().refetchEvents()
|
||||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells
|
|
||||||
})
|
|
||||||
|
|
||||||
function changeMonth(delta) {
|
|
||||||
const d = new Date(currentDate.value)
|
|
||||||
d.setMonth(d.getMonth() + delta)
|
|
||||||
currentDate.value = d
|
|
||||||
loadEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToday() {
|
|
||||||
currentDate.value = new Date()
|
|
||||||
loadEvents()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue