Files
minmal-file-cloud-email-pim…/frontend/src/views/CalendarView.vue
T
Stefan Hacker ce4faedd88 feat: CalDAV-URLs im Kalender-Menue anzeigen
Im Drei-Punkte-Menue jedes Kalenders wird jetzt ein Info-Block mit
den CalDAV-URLs angezeigt:

* Auto-Discovery URL fuer Thunderbird / DAVx5 / Apple Calendar
* Direkt-URL fuer diesen speziellen Kalender (z.B. Outlook
  CalDAV-Synchronizer)
* Kurz-Hinweis welcher Client welche URL nimmt

Jede URL hat ein Kopier-Icon. Ergaenzt den bestehenden iCal-Link
um die bidirektionale Sync-Moeglichkeit ueber CalDAV.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:08:11 +02:00

917 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="view-container">
<div class="view-header">
<h2>Kalender</h2>
<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 Termin" size="small" @click="openNewEvent()" />
</div>
</div>
<div class="calendar-layout">
<aside class="calendar-sidebar">
<h4>Kalender</h4>
<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>
<span class="cal-name">{{ cal.name }}</span>
<span v-if="cal.permission !== 'owner'" class="shared-label">(geteilt)</span>
<Button icon="pi pi-ellipsis-v" text size="small" @click.stop="openCalendarMenu(cal)" />
</div>
</aside>
<div class="calendar-main">
<FullCalendar ref="fcRef" :options="calendarOptions">
<template #eventContent="arg">
<div class="fc-event-content-inner" :title="eventTooltip(arg.event)">
<span v-if="arg.event.extendedProps.all_day" class="fc-icon">📅</span>
<span v-else-if="arg.timeText" class="fc-time">{{ formatEventTime(arg.event) }}</span>
<span v-if="arg.event.extendedProps.recurrence_rule" class="fc-icon" title="Wiederholung">🔁</span>
<span class="fc-title">{{ arg.event.title }}</span>
</div>
</template>
</FullCalendar>
</div>
</div>
<!-- New Calendar Dialog -->
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newCalName" fluid autofocus />
</div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newCalColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewCalendar = false" />
<Button label="Erstellen" @click="createCalendar" />
</template>
</Dialog>
<!-- Event Dialog -->
<Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'"
modal :style="{ width: '560px' }">
<div class="field">
<label>Titel</label>
<InputText v-model="eventForm.summary" fluid autofocus />
</div>
<div class="field">
<label>Kalender</label>
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
</div>
<div class="field-row">
<div class="field">
<label>Start</label>
<InputText v-model="eventForm.dtstart" :type="eventForm.all_day ? 'date' : 'datetime-local'" fluid />
</div>
<div class="field">
<label>Ende</label>
<InputText v-model="eventForm.dtend" :type="eventForm.all_day ? 'date' : 'datetime-local'" fluid />
</div>
</div>
<div class="field">
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
</div>
<div class="field">
<label><input type="checkbox" v-model="eventForm.is_private" /> 🔒 Privat (Teilnehmer sehen nur den Zeitblock)</label>
</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>
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="confirmDeleteEvent = true" />
<Button label="Abbrechen" text @click="showEventDialog = false" />
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
</template>
</Dialog>
<!-- Calendar Menu -->
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '480px' }">
<div v-if="selectedCal" class="cal-menu-content">
<p><strong>{{ selectedCal.name }}</strong></p>
<div v-if="selectedCal.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label>
<div class="share-row">
<div style="position: relative; flex: 1;">
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
fluid @input="onShareSearch" />
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
</div>
</div>
</div>
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareCalendar" />
</div>
<div v-if="calendarShares.length" class="existing-shares">
<template v-for="s in calendarShares" :key="s.id">
<div v-if="editingShareId !== s.id" class="share-perm-item">
<i class="pi pi-user"></i>
<span>{{ s.username }}</span>
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
<Button icon="pi pi-pencil" text size="small" title="Bearbeiten" @click="startEditShare(s)" />
<Button icon="pi pi-trash" text size="small" severity="danger" title="Entfernen" @click="removeShare(s.id)" />
</div>
<div v-else class="share-perm-item editing">
<i class="pi pi-user"></i>
<span>{{ s.username }}</span>
<Select v-model="editSharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button icon="pi pi-check" text size="small" severity="success" title="Speichern" @click="saveEditShare(s)" />
<Button icon="pi pi-times" text size="small" title="Abbrechen" @click="editingShareId = null" />
</div>
</template>
</div>
</div>
<div v-if="selectedCal.permission === 'owner'" class="field ical-block">
<label>iCal-Abo-Link</label>
<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>
<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 class="field caldav-block">
<label><i class="pi pi-info-circle"></i> CalDAV-Zugang (native Sync)</label>
<p class="caldav-hint">
Im Handy/Laptop-Kalender als CalDAV-Account hinzufuegen. Benutzername
und Passwort sind deine normalen Mini-Cloud-Zugangsdaten.
</p>
<div class="url-row">
<strong>Auto-Discovery</strong>
<code>{{ origin }}/dav/</code>
<Button icon="pi pi-copy" text size="small" title="Kopieren" @click="copyText(origin + '/dav/')" />
</div>
<div class="url-row">
<strong>Dieser Kalender (direkt)</strong>
<code>{{ origin }}/dav/{{ username }}/cal-{{ selectedCal.id }}/</code>
<Button icon="pi pi-copy" text size="small" title="Kopieren"
@click="copyText(`${origin}/dav/${username}/cal-${selectedCal.id}/`)" />
</div>
<div class="caldav-clients">
<div><strong>Thunderbird / DAVx5 / Apple</strong>: Auto-Discovery-URL benutzen</div>
<div><strong>Outlook (CalDAV-Synchronizer)</strong>: Direkt-URL</div>
</div>
</div>
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
severity="danger" text size="small" @click="confirmDeleteCal = true" />
</div>
</Dialog>
<Dialog v-model:visible="confirmDeleteEvent" header="Termin loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ editingEvent?.summary }}</strong> wirklich loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteEvent = false" />
<Button label="Loeschen" severity="danger" @click="deleteEvent()" />
</template>
</Dialog>
<!-- Scope picker for recurring events -->
<Dialog v-model:visible="showScopeDialog" header="Serientermin bearbeiten" modal :style="{ width: '440px' }">
<p>Dieser Termin gehoert zu einer Serie.<br>Was moechtest du bearbeiten?</p>
<template #footer>
<Button label="Abbrechen" text @click="showScopeDialog = false" />
<Button label="Nur diesen Termin" outlined @click="openEditScope('occurrence')" />
<Button label="Ganze Serie" @click="openEditScope('series')" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Terminen loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteCal = false" />
<Button label="Loeschen" severity="danger" @click="deleteCalendar()" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
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 auth = useAuthStore()
const origin = computed(() => window.location.origin)
const username = computed(() => auth.user?.username || '')
const calendars = ref([])
const visibleCalendars = reactive({})
const fcRef = ref(null)
const showNewCalendar = ref(false)
const newCalName = ref('')
const newCalColor = ref('#3788d8')
const showEventDialog = ref(false)
const editingEvent = ref(null)
const eventForm = ref({
summary: '', description: '', location: '',
calendar_id: null, dtstart: '', dtend: '', all_day: false, is_private: 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 selectedCal = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const shareSearchResults = ref([])
const calendarShares = ref([])
const editingShareId = ref(null)
const editSharePermission = ref('read')
let shareSearchTimer = null
const icalPassword = ref('')
const confirmDeleteEvent = ref(false)
const confirmDeleteCal = ref(false)
const showScopeDialog = ref(false)
const pendingScopeEvent = ref(null)
const pendingScopeOccurrence = ref(null)
// 'series' (edit master), 'occurrence' (split off single date), or null (non-recurring)
const currentEditScope = ref(null)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
const fullIcalUrl = computed(() =>
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
)
// FullCalendar configuration
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,
displayEventEnd: true,
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
// Force bar/block display even for timed events so the month view
// doesn't switch between dot+time and full bar depending on how
// the event was created.
eventDisplay: 'block',
}))
function formatEventTime(ev) {
if (!ev.start) return ''
const fmt = d => d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
if (!ev.end) return fmt(ev.start)
return `${fmt(ev.start)}${fmt(ev.end)}`
}
function eventTooltip(ev) {
const p = ev.extendedProps
const parts = [ev.title]
if (p.all_day) parts.push('Ganztaegig')
else if (ev.start) {
const fmt = d => d.toLocaleString('de-DE', { hour: '2-digit', minute: '2-digit' })
parts.push(ev.end ? `${fmt(ev.start)}${fmt(ev.end)}` : fmt(ev.start))
}
if (p.recurrence_rule) parts.push('Wiederholung')
if (p.location) parts.push('Ort: ' + p.location)
if (p.description) parts.push(p.description)
return parts.join(' • ')
}
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) {
all.push(toFcEvent(e, cal))
}
}
successCallback(all)
} catch (err) {
failureCallback(err)
}
}
function toFcEvent(e, cal) {
const fc = {
id: String(e.id),
title: e.summary,
color: cal.color,
allDay: e.all_day,
extendedProps: { ...e, _cal: cal },
}
if (e.recurrence_rule) {
fc.rrule = {
freq: extractFreq(e.recurrence_rule),
dtstart: e.dtstart,
...parseRRule(e.recurrence_rule),
}
if (e.exdates && e.exdates.length) {
fc.exdate = e.exdates
}
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
if (e.recurrence_rule) {
pendingScopeEvent.value = e
pendingScopeOccurrence.value = info.event.start
showScopeDialog.value = true
return
}
currentEditScope.value = null
openEditEvent(e)
}
function openEditScope(scope) {
showScopeDialog.value = false
const e = pendingScopeEvent.value
const occ = pendingScopeOccurrence.value
if (scope === 'series') {
currentEditScope.value = 'series'
openEditEvent(e)
} else {
// "Nur diesen Termin" - form starts from the clicked occurrence's
// start/end, non-recurring. Save will add an EXDATE on the master
// plus create a standalone event.
currentEditScope.value = 'occurrence'
editingEvent.value = e
const durationMs = e.dtend ? new Date(e.dtend) - new Date(e.dtstart) : 3600000
const occEnd = new Date(occ.getTime() + durationMs)
eventForm.value = {
summary: e.summary,
description: e.description || '',
location: e.location || '',
calendar_id: e.calendar_id,
dtstart: toLocalISO(occ, e.all_day),
dtend: toLocalISO(occEnd, e.all_day),
all_day: e.all_day,
}
loadRRuleIntoForm('')
// Freeze recurrence editor - this is a single instance
recurFreq.value = 'none'
showEventDialog.value = true
}
}
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() {
const res = await apiClient.get('/calendars')
calendars.value = res.data
for (const c of calendars.value) {
if (!(c.id in visibleCalendars)) visibleCalendars[c.id] = true
}
if (!calendars.value.length) {
await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' })
await loadCalendars()
}
}
async function createCalendar() {
if (!newCalName.value.trim()) return
await apiClient.post('/calendars', { name: newCalName.value.trim(), color: newCalColor.value })
showNewCalendar.value = false
newCalName.value = ''
await loadCalendars()
refreshEvents()
}
function openNewEvent(start, end, allDay = false) {
editingEvent.value = null
const now = start || new Date()
const later = end || new Date(now.getTime() + 3600000)
eventForm.value = {
summary: '',
description: '',
location: '',
calendar_id: ownCalendars.value[0]?.id,
dtstart: toLocalISO(now, allDay),
dtend: toLocalISO(later, allDay),
all_day: allDay,
is_private: false,
}
loadRRuleIntoForm('')
showEventDialog.value = true
}
function openEditEvent(evt) {
editingEvent.value = evt
eventForm.value = {
summary: evt.summary,
description: evt.description || '',
location: evt.location || '',
calendar_id: evt.calendar_id,
dtstart: toLocalISO(new Date(evt.dtstart), evt.all_day),
dtend: evt.dtend ? toLocalISO(new Date(evt.dtend), evt.all_day) : '',
all_day: evt.all_day,
is_private: !!evt.is_private,
}
loadRRuleIntoForm(evt.recurrence_rule)
showEventDialog.value = true
}
async function saveEvent() {
if (!eventForm.value.summary.trim()) return
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,
is_private: eventForm.value.is_private,
recurrence_rule: buildRRule(),
calendar_id: eventForm.value.calendar_id,
}
try {
if (currentEditScope.value === 'occurrence' && editingEvent.value) {
// Split single occurrence off the series: add EXDATE + create replacement
await apiClient.post(`/events/${editingEvent.value.id}/exception`, {
occurrence_date: toLocalISO(pendingScopeOccurrence.value, editingEvent.value.all_day),
replacement: {
summary: payload.summary,
description: payload.description,
location: payload.location,
dtstart: payload.dtstart,
dtend: payload.dtend,
all_day: payload.all_day,
},
})
} else if (editingEvent.value) {
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
} else {
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
}
showEventDialog.value = false
currentEditScope.value = null
pendingScopeEvent.value = null
pendingScopeOccurrence.value = null
refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function deleteEvent() {
if (!editingEvent.value) return
try {
await apiClient.delete(`/events/${editingEvent.value.id}`)
confirmDeleteEvent.value = false
showEventDialog.value = false
refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function openCalendarMenu(cal) {
selectedCal.value = cal
icalPassword.value = ''
shareUsername.value = ''
shareSearchResults.value = []
showCalMenu.value = true
loadShares()
}
function onShareSearch() {
clearTimeout(shareSearchTimer)
const q = shareUsername.value.trim()
if (q.length < 2) { shareSearchResults.value = []; return }
shareSearchTimer = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q } })
shareSearchResults.value = res.data
} catch { shareSearchResults.value = [] }
}, 250)
}
async function shareCalendar() {
if (!shareUsername.value.trim() || !selectedCal.value) return
try {
await apiClient.post(`/calendars/${selectedCal.value.id}/share`, {
username: shareUsername.value.trim(), permission: sharePermission.value,
})
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
shareUsername.value = ''
shareSearchResults.value = []
await loadShares()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error || err.message, life: 5000 })
}
}
async function loadShares() {
if (!selectedCal.value || selectedCal.value.permission !== 'owner') {
calendarShares.value = []
return
}
try {
const res = await apiClient.get(`/calendars/${selectedCal.value.id}/shares`)
calendarShares.value = res.data
} catch { calendarShares.value = [] }
}
function startEditShare(s) {
editingShareId.value = s.id
editSharePermission.value = s.permission
}
async function saveEditShare(s) {
if (!selectedCal.value) return
try {
await apiClient.post(`/calendars/${selectedCal.value.id}/share`, {
username: s.username,
permission: editSharePermission.value,
})
editingShareId.value = null
await loadShares()
toast.add({ severity: 'success', summary: 'Berechtigung aktualisiert', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error || err.message, life: 5000 })
}
}
async function removeShare(shareId) {
if (!selectedCal.value) return
try {
await apiClient.delete(`/calendars/${selectedCal.value.id}/shares/${shareId}`)
await loadShares()
toast.add({ severity: 'success', summary: 'Freigabe entfernt', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function generateIcalLink() {
if (!selectedCal.value) return
const body = {}
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() {
navigator.clipboard.writeText(fullIcalUrl.value)
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
}
function copyText(text) {
navigator.clipboard.writeText(text)
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
}
async function deleteCalendar() {
if (!selectedCal.value) return
await apiClient.delete(`/calendars/${selectedCal.value.id}`)
showCalMenu.value = false
confirmDeleteCal.value = false
await loadCalendars()
refreshEvents()
}
function toLocalISO(date, allDay = false) {
const pad = n => String(n).padStart(2, '0')
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 () => {
await loadCalendars()
refreshEvents()
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.calendar-layout { display: flex; gap: 1rem; align-items: flex-start; }
.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-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.cal-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
.calendar-main { flex: 1; min-width: 0; }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; }
.recur-details { padding: 0.75rem; background: var(--p-surface-50); border-radius: 6px; margin-top: 0.5rem; }
.recur-unit { display: inline-block; margin-left: 0.5rem; font-size: 0.85rem; color: var(--p-text-muted-color); }
.weekday-row { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.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; }
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: white; border: 1px solid var(--p-surface-200); border-radius: 4px;
max-height: 160px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; display: flex; gap: 0.5rem; align-items: center; }
.user-result:hover { background: var(--p-primary-50); }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.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; }
.caldav-block { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; margin-top: 1rem; }
.caldav-hint { font-size: 0.8rem; color: var(--p-text-muted-color); margin: 0 0 0.75rem; }
.url-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; }
.url-row strong { min-width: 160px; font-size: 0.8rem; }
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px;
font-size: 0.8rem; word-break: break-all; flex: 1; }
.caldav-clients { font-size: 0.75rem; color: var(--p-text-muted-color); margin-top: 0.5rem; }
.caldav-clients div { margin-bottom: 0.15rem; }
.hint-badge { font-size: 0.75rem; color: var(--p-primary-700); display: inline-flex; gap: 0.25rem; align-items: center; }
.fc-event-content-inner { display: flex; align-items: center; gap: 0.2rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 0 2px; }
.fc-event-content-inner .fc-icon { flex-shrink: 0; font-size: 0.85em; }
.fc-event-content-inner .fc-time { flex-shrink: 0; font-weight: 600; font-size: 0.8em; opacity: 0.9; }
.fc-event-content-inner .fc-title { overflow: hidden; text-overflow: ellipsis; }
</style>