ce4faedd88
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>
917 lines
36 KiB
Vue
917 lines
36 KiB
Vue
<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>
|