mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(calendar): add CalDAV/iCal sync UI and recurring events support
CalDAV/iCal Sync: - Add sync API client (lib/api/sync.ts) for all external calendar endpoints - Add external calendars store with connect, disconnect, sync operations - Add /settings/sync page with provider selection (Google, CalDAV, iCal URL, Apple), credentials form, CalDAV discovery, sync status display, and manual sync trigger - Add link to sync settings from main settings page Recurring Events: - Add RecurrenceSelector component with preset selection (daily, weekly, monthly, yearly, weekdays) and custom configuration (interval, weekday picker, end date) - Integrate RecurrenceSelector into EventForm between date fields and location - Expand recurring events into individual occurrences in events store using generateOccurrences() from @calendar/shared - Add recurrence-aware delete: single occurrence (exception), all occurrences, or series update via dedicated store methods - Add RecurrenceEditDialog component for "this/all/this and future" selection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43a2226290
commit
ab42c265e1
8 changed files with 1720 additions and 2 deletions
109
apps/calendar/apps/web/src/lib/api/sync.ts
Normal file
109
apps/calendar/apps/web/src/lib/api/sync.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* External Calendar Sync API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type {
|
||||
ExternalCalendar,
|
||||
ConnectExternalCalendarInput,
|
||||
CalDavDiscoveryResult,
|
||||
} from '@calendar/shared';
|
||||
|
||||
export interface UpdateExternalCalendarInput {
|
||||
name?: string;
|
||||
syncEnabled?: boolean;
|
||||
syncDirection?: 'import' | 'export' | 'both';
|
||||
syncInterval?: number;
|
||||
color?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// ==================== External Calendars CRUD ====================
|
||||
|
||||
export async function getExternalCalendars() {
|
||||
const result = await fetchApi<{ calendars: ExternalCalendar[] }>('/sync/external');
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendars, error: null };
|
||||
}
|
||||
|
||||
export async function getExternalCalendar(id: string) {
|
||||
const result = await fetchApi<{ calendar: ExternalCalendar }>(`/sync/external/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function connectExternalCalendar(data: ConnectExternalCalendarInput) {
|
||||
const result = await fetchApi<{ calendar: ExternalCalendar }>('/sync/external', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function updateExternalCalendar(id: string, data: UpdateExternalCalendarInput) {
|
||||
const result = await fetchApi<{ calendar: ExternalCalendar }>(`/sync/external/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function disconnectExternalCalendar(id: string) {
|
||||
return fetchApi<{ success: boolean }>(`/sync/external/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Sync Operations ====================
|
||||
|
||||
export async function triggerSync(id: string) {
|
||||
const result = await fetchApi<{
|
||||
success: boolean;
|
||||
eventsImported?: number;
|
||||
eventsExported?: number;
|
||||
}>(`/sync/external/${id}/sync`, { method: 'POST' });
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data, error: null };
|
||||
}
|
||||
|
||||
// ==================== CalDAV Discovery ====================
|
||||
|
||||
export async function discoverCalDav(serverUrl: string, username: string, password: string) {
|
||||
const result = await fetchApi<CalDavDiscoveryResult>('/sync/caldav/discover', {
|
||||
method: 'POST',
|
||||
body: { serverUrl, username, password },
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendars, error: null };
|
||||
}
|
||||
|
||||
// ==================== Google OAuth ====================
|
||||
|
||||
export async function getGoogleAuthUrl() {
|
||||
const result = await fetchApi<{ url: string }>('/sync/google/auth-url');
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.url, error: null };
|
||||
}
|
||||
|
||||
// ==================== iCal Export ====================
|
||||
|
||||
export function getICalExportUrl(calendarId: string): string {
|
||||
// This returns the URL for direct browser download
|
||||
return `/api/v1/calendars/${calendarId}/export.ics`;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
} from '@manacore/shared-ui';
|
||||
import AttendeeSelector from './AttendeeSelector.svelte';
|
||||
import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte';
|
||||
import RecurrenceSelector from './RecurrenceSelector.svelte';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CreateEventInput,
|
||||
|
|
@ -67,6 +68,12 @@
|
|||
// Attendees state
|
||||
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
|
||||
|
||||
// Recurrence state
|
||||
let recurrenceRule = $state<string | null>(event?.recurrenceRule || null);
|
||||
let recurrenceEndDate = $state<string | null>(
|
||||
event?.recurrenceEndDate ? format(toDate(event.recurrenceEndDate), 'yyyy-MM-dd') : null
|
||||
);
|
||||
|
||||
// Convert EventTag to Tag type for shared-ui components
|
||||
function eventTagToTag(tag: EventTag): Tag {
|
||||
return {
|
||||
|
|
@ -222,6 +229,10 @@
|
|||
endTime: endDateTime.toISOString(),
|
||||
// Only include calendarId if set - backend will use default if not provided
|
||||
...(calendarId ? { calendarId } : {}),
|
||||
recurrenceRule: recurrenceRule || undefined,
|
||||
recurrenceEndDate: recurrenceEndDate
|
||||
? new Date(`${recurrenceEndDate}T23:59:59`).toISOString()
|
||||
: undefined,
|
||||
metadata: finalMetadata,
|
||||
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
|
||||
};
|
||||
|
|
@ -331,6 +342,16 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Wiederholung -->
|
||||
<RecurrenceSelector
|
||||
{recurrenceRule}
|
||||
{recurrenceEndDate}
|
||||
onRecurrenceChange={(rule, endDt) => {
|
||||
recurrenceRule = rule;
|
||||
recurrenceEndDate = endDt;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="location" class="text-sm font-medium text-foreground">Ort</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts">
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
type RecurrenceAction = 'this' | 'all' | 'this_and_future';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
mode: 'edit' | 'delete';
|
||||
onSelect: (action: RecurrenceAction) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { visible, mode, onSelect, onCancel }: Props = $props();
|
||||
|
||||
const title = $derived(
|
||||
mode === 'edit' ? 'Wiederkehrenden Termin bearbeiten' : 'Wiederkehrenden Termin löschen'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal {visible} onClose={onCancel} {title} maxWidth="sm">
|
||||
<div class="options">
|
||||
<button class="option-btn" onclick={() => onSelect('this')}>
|
||||
<span class="option-title">
|
||||
{mode === 'edit' ? 'Nur diesen Termin' : 'Nur diesen Termin löschen'}
|
||||
</span>
|
||||
<span class="option-desc">Andere Wiederholungen bleiben unverändert</span>
|
||||
</button>
|
||||
|
||||
<button class="option-btn" onclick={() => onSelect('this_and_future')}>
|
||||
<span class="option-title">
|
||||
{mode === 'edit' ? 'Diesen und alle zukünftigen' : 'Diesen und alle zukünftigen löschen'}
|
||||
</span>
|
||||
<span class="option-desc">Vergangene Wiederholungen bleiben erhalten</span>
|
||||
</button>
|
||||
|
||||
<button class="option-btn" onclick={() => onSelect('all')}>
|
||||
<span class="option-title">
|
||||
{mode === 'edit' ? 'Alle Termine der Serie' : 'Alle Termine der Serie löschen'}
|
||||
</span>
|
||||
<span class="option-desc">Die gesamte Wiederholungsserie wird betroffen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="footer">
|
||||
<button class="cancel-btn" onclick={onCancel}>Abbrechen</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.option-btn:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
RECURRENCE_PRESETS,
|
||||
type RecurrencePattern,
|
||||
type Weekday,
|
||||
WEEKDAY_SHORT_LABELS,
|
||||
} from '@calendar/shared';
|
||||
import { parseRRule, formatRRule, describeRecurrence } from '@calendar/shared';
|
||||
import { ArrowsClockwise } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
recurrenceRule: string | null;
|
||||
recurrenceEndDate: string | null;
|
||||
onRecurrenceChange: (rule: string | null, endDate: string | null) => void;
|
||||
}
|
||||
|
||||
let { recurrenceRule, recurrenceEndDate, onRecurrenceChange }: Props = $props();
|
||||
|
||||
let showCustom = $state(false);
|
||||
let selectedPreset = $state<string | null>(recurrenceRule);
|
||||
|
||||
// Custom recurrence state
|
||||
let pattern = $state<RecurrencePattern>(
|
||||
(recurrenceRule ? parseRRule(recurrenceRule) : null) || { frequency: 'WEEKLY' }
|
||||
);
|
||||
let endDate = $state(recurrenceEndDate || '');
|
||||
|
||||
// Check if current rule matches a preset
|
||||
const isCustom = $derived.by(() => {
|
||||
if (!recurrenceRule) return false;
|
||||
return !RECURRENCE_PRESETS.some((p) => p.value === recurrenceRule);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isCustom && recurrenceRule) {
|
||||
showCustom = true;
|
||||
selectedPreset = '__custom__';
|
||||
} else {
|
||||
selectedPreset = recurrenceRule;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePresetChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
|
||||
if (value === '') {
|
||||
// No repeat
|
||||
selectedPreset = null;
|
||||
showCustom = false;
|
||||
onRecurrenceChange(null, null);
|
||||
} else if (value === '__custom__') {
|
||||
showCustom = true;
|
||||
selectedPreset = '__custom__';
|
||||
// Apply current pattern
|
||||
const rule = formatRRule(pattern);
|
||||
onRecurrenceChange(rule, endDate || null);
|
||||
} else {
|
||||
selectedPreset = value;
|
||||
showCustom = false;
|
||||
onRecurrenceChange(value, null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFrequencyChange(e: Event) {
|
||||
pattern = {
|
||||
...pattern,
|
||||
frequency: (e.target as HTMLSelectElement).value as RecurrencePattern['frequency'],
|
||||
};
|
||||
applyCustomPattern();
|
||||
}
|
||||
|
||||
function handleIntervalChange(e: Event) {
|
||||
const val = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
pattern = { ...pattern, interval: val > 1 ? val : undefined };
|
||||
applyCustomPattern();
|
||||
}
|
||||
|
||||
function toggleWeekday(day: Weekday) {
|
||||
const current = pattern.byDay || [];
|
||||
const updated = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
|
||||
pattern = { ...pattern, byDay: updated.length > 0 ? updated : undefined };
|
||||
applyCustomPattern();
|
||||
}
|
||||
|
||||
function handleEndDateChange(e: Event) {
|
||||
endDate = (e.target as HTMLInputElement).value;
|
||||
const rule = formatRRule(pattern);
|
||||
onRecurrenceChange(rule, endDate || null);
|
||||
}
|
||||
|
||||
function applyCustomPattern() {
|
||||
const rule = formatRRule(pattern);
|
||||
onRecurrenceChange(rule, endDate || null);
|
||||
}
|
||||
|
||||
const weekdays: Weekday[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||||
|
||||
const currentDescription = $derived(
|
||||
recurrenceRule ? describeRecurrence(parseRRule(recurrenceRule)) : 'Wiederholt sich nicht'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="recurrence-selector">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<ArrowsClockwise size={16} class="text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">Wiederholung</span>
|
||||
</div>
|
||||
|
||||
<select class="select-input" value={selectedPreset ?? ''} onchange={handlePresetChange}>
|
||||
{#each RECURRENCE_PRESETS as preset}
|
||||
<option value={preset.value ?? ''}>{preset.label}</option>
|
||||
{/each}
|
||||
<option value="__custom__">Benutzerdefiniert...</option>
|
||||
</select>
|
||||
|
||||
{#if showCustom}
|
||||
<div class="custom-section">
|
||||
<div class="custom-row">
|
||||
<label class="custom-label">Alle</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
value={pattern.interval || 1}
|
||||
onchange={handleIntervalChange}
|
||||
class="interval-input"
|
||||
/>
|
||||
<select class="frequency-select" value={pattern.frequency} onchange={handleFrequencyChange}>
|
||||
<option value="DAILY">Tage</option>
|
||||
<option value="WEEKLY">Wochen</option>
|
||||
<option value="MONTHLY">Monate</option>
|
||||
<option value="YEARLY">Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if pattern.frequency === 'WEEKLY'}
|
||||
<div class="weekday-row">
|
||||
<label class="custom-label">An</label>
|
||||
<div class="weekday-buttons">
|
||||
{#each weekdays as day}
|
||||
<button
|
||||
type="button"
|
||||
class="weekday-btn"
|
||||
class:active={pattern.byDay?.includes(day)}
|
||||
onclick={() => toggleWeekday(day)}
|
||||
>
|
||||
{WEEKDAY_SHORT_LABELS[day].substring(0, 2)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="custom-row">
|
||||
<label class="custom-label">Bis</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onchange={handleEndDateChange}
|
||||
class="date-input"
|
||||
placeholder="Kein Enddatum"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if recurrenceRule}
|
||||
<p class="recurrence-description">{currentDescription}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recurrence-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.custom-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.custom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-label {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.interval-input {
|
||||
width: 4rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.interval-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.frequency-select {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.frequency-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.weekday-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weekday-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.weekday-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.weekday-btn:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.weekday-btn.active {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.date-input {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.recurrence-description {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--primary));
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
*/
|
||||
|
||||
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import { parseRRule, generateOccurrences } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { format, isWithinInterval, isSameDay } from 'date-fns';
|
||||
import { format, isWithinInterval, isSameDay, differenceInMilliseconds } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { toastStore } from '@manacore/shared-ui';
|
||||
|
||||
|
|
@ -17,6 +18,53 @@ let loadedRange = $state<{ start: Date; end: Date } | null>(null);
|
|||
// Draft event for quick create (temporary event shown in grid before saving)
|
||||
let draftEvent = $state<CalendarEvent | null>(null);
|
||||
|
||||
/**
|
||||
* Expand recurring events into individual occurrences for the current view range.
|
||||
* Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}`
|
||||
*/
|
||||
function expandRecurringEvents(
|
||||
rawEvents: CalendarEvent[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date
|
||||
): CalendarEvent[] {
|
||||
const result: CalendarEvent[] = [];
|
||||
|
||||
for (const event of rawEvents) {
|
||||
if (!event.recurrenceRule) {
|
||||
result.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pattern = parseRRule(event.recurrenceRule);
|
||||
if (!pattern) {
|
||||
result.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
const durationMs = differenceInMilliseconds(eventEnd, eventStart);
|
||||
const exceptions = (event.recurrenceExceptions as string[]) || [];
|
||||
|
||||
const occurrences = generateOccurrences(eventStart, pattern, rangeStart, rangeEnd, exceptions);
|
||||
|
||||
for (const occurrenceDate of occurrences) {
|
||||
const occEnd = new Date(occurrenceDate.getTime() + durationMs);
|
||||
const dateKey = format(occurrenceDate, 'yyyy-MM-dd');
|
||||
|
||||
result.push({
|
||||
...event,
|
||||
id: `${event.id}__recurrence__${dateKey}`,
|
||||
parentEventId: event.id,
|
||||
startTime: occurrenceDate.toISOString(),
|
||||
endTime: occEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const eventsStore = {
|
||||
// Getters - always return safe values
|
||||
get events() {
|
||||
|
|
@ -51,7 +99,8 @@ export const eventsStore = {
|
|||
} else {
|
||||
// API returns events array directly (already extracted in api/events.ts)
|
||||
const eventsData = result.data as CalendarEvent[] | null;
|
||||
events = eventsData || [];
|
||||
// Expand recurring events into individual occurrences for the view range
|
||||
events = expandRecurringEvents(eventsData || [], startDate, endDate);
|
||||
loadedRange = { start: startDate, end: endDate };
|
||||
}
|
||||
|
||||
|
|
@ -234,4 +283,85 @@ export const eventsStore = {
|
|||
isDraftEvent(eventId: string) {
|
||||
return eventId === '__draft__';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an event ID is a recurrence occurrence
|
||||
*/
|
||||
isRecurrenceOccurrence(eventId: string) {
|
||||
return eventId.includes('__recurrence__');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the parent event ID from a recurrence occurrence ID
|
||||
*/
|
||||
getParentEventId(eventId: string): string {
|
||||
if (eventId.includes('__recurrence__')) {
|
||||
return eventId.split('__recurrence__')[0];
|
||||
}
|
||||
return eventId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a single occurrence of a recurring event by adding an exception date
|
||||
*/
|
||||
async deleteRecurrenceOccurrence(eventId: string) {
|
||||
const parentId = this.getParentEventId(eventId);
|
||||
const dateKey = eventId.split('__recurrence__')[1]; // yyyy-MM-dd
|
||||
|
||||
// Find the parent event to get existing exceptions
|
||||
const parent = events.find(
|
||||
(e) => e.id === parentId || this.getParentEventId(e.id) === parentId
|
||||
);
|
||||
if (!parent) return { error: { message: 'Event not found' } };
|
||||
|
||||
const realParentId = this.getParentEventId(parent.id);
|
||||
const existingExceptions = (parent.recurrenceExceptions as string[]) || [];
|
||||
const updatedExceptions = [...existingExceptions, dateKey];
|
||||
|
||||
// Optimistic: remove this occurrence from local state
|
||||
events = events.filter((e) => e.id !== eventId);
|
||||
|
||||
const result = await api.updateEvent(realParentId, {
|
||||
recurrenceExceptions: updatedExceptions as unknown as undefined,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Fehler: ${result.error.message}`);
|
||||
// Refetch to restore state
|
||||
if (loadedRange) {
|
||||
this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
}
|
||||
} else {
|
||||
toastStore.success('Termin gelöscht');
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all occurrences of a recurring event (deletes the parent)
|
||||
*/
|
||||
async deleteRecurrenceSeries(eventId: string) {
|
||||
const parentId = this.getParentEventId(eventId);
|
||||
return this.deleteEvent(parentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update all occurrences of a recurring event (updates the parent)
|
||||
*/
|
||||
async updateRecurrenceSeries(eventId: string, data: UpdateEventInput) {
|
||||
const parentId = this.getParentEventId(eventId);
|
||||
const result = await api.updateEvent(parentId, data);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Fehler: ${result.error.message}`);
|
||||
} else {
|
||||
// Refetch to regenerate occurrences
|
||||
if (loadedRange) {
|
||||
await this.fetchEvents(loadedRange.start, loadedRange.end);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* External Calendars Store - Manages CalDAV/iCal/Google calendar connections
|
||||
*/
|
||||
|
||||
import type { ExternalCalendar, ConnectExternalCalendarInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/sync';
|
||||
import { toastStore } from '@manacore/shared-ui';
|
||||
|
||||
// State
|
||||
let externalCalendars = $state<ExternalCalendar[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let syncingIds = $state<Set<string>>(new Set());
|
||||
|
||||
function getArray(): ExternalCalendar[] {
|
||||
const arr = externalCalendars ?? [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
|
||||
export const externalCalendarsStore = {
|
||||
get calendars() {
|
||||
return externalCalendars;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
isSyncing(id: string) {
|
||||
return syncingIds.has(id);
|
||||
},
|
||||
|
||||
async fetchCalendars() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getExternalCalendars();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
externalCalendars = [];
|
||||
} else {
|
||||
externalCalendars = result.data || [];
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
async connect(data: ConnectExternalCalendarInput) {
|
||||
const result = await api.connectExternalCalendar(data);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Verbindung fehlgeschlagen: ${result.error.message}`);
|
||||
} else if (result.data) {
|
||||
externalCalendars = [...externalCalendars, result.data];
|
||||
toastStore.success(`${data.name} verbunden`);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async update(id: string, data: api.UpdateExternalCalendarInput) {
|
||||
const result = await api.updateExternalCalendar(id, data);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Aktualisierung fehlgeschlagen: ${result.error.message}`);
|
||||
} else if (result.data) {
|
||||
externalCalendars = getArray().map((c) => (c.id === id ? result.data! : c));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async disconnect(id: string) {
|
||||
const cal = getArray().find((c) => c.id === id);
|
||||
const result = await api.disconnectExternalCalendar(id);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Trennung fehlgeschlagen: ${result.error.message}`);
|
||||
} else {
|
||||
externalCalendars = getArray().filter((c) => c.id !== id);
|
||||
toastStore.success(`${cal?.name || 'Kalender'} getrennt`);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async triggerSync(id: string) {
|
||||
syncingIds = new Set([...syncingIds, id]);
|
||||
|
||||
const result = await api.triggerSync(id);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Sync fehlgeschlagen: ${result.error.message}`);
|
||||
// Update last sync error in local state
|
||||
externalCalendars = getArray().map((c) =>
|
||||
c.id === id ? { ...c, lastSyncError: result.error!.message } : c
|
||||
);
|
||||
} else {
|
||||
toastStore.success('Synchronisation abgeschlossen');
|
||||
// Update local state with new sync time
|
||||
externalCalendars = getArray().map((c) =>
|
||||
c.id === id ? { ...c, lastSyncAt: new Date().toISOString(), lastSyncError: null } : c
|
||||
);
|
||||
}
|
||||
|
||||
const newSet = new Set(syncingIds);
|
||||
newSet.delete(id);
|
||||
syncingIds = newSet;
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async discoverCalDav(serverUrl: string, username: string, password: string) {
|
||||
return api.discoverCalDav(serverUrl, username, password);
|
||||
},
|
||||
|
||||
async getGoogleAuthUrl() {
|
||||
return api.getGoogleAuthUrl();
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return getArray().find((c) => c.id === id);
|
||||
},
|
||||
|
||||
clear() {
|
||||
externalCalendars = [];
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
|
|
@ -358,6 +358,33 @@
|
|||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Externe Kalender -->
|
||||
<SettingsSection title="Externe Kalender">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<SettingsCard>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Verbinde Google Calendar, Apple Calendar, CalDAV oder iCal-URLs.
|
||||
</p>
|
||||
<a
|
||||
href="/settings/sync"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm bg-primary text-primary-foreground hover:bg-primary/90 transition-colors self-start"
|
||||
>
|
||||
Kalender-Sync verwalten
|
||||
</a>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Global App Settings (synced across all apps) -->
|
||||
<GlobalSettingsSection
|
||||
{userSettings}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,883 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { externalCalendarsStore } from '$lib/stores/external-calendars.svelte';
|
||||
import {
|
||||
CaretLeft,
|
||||
Plus,
|
||||
ArrowsClockwise,
|
||||
Trash,
|
||||
Globe,
|
||||
GoogleLogo,
|
||||
Link,
|
||||
CloudArrowDown,
|
||||
CloudArrowUp,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
X,
|
||||
} from '@manacore/shared-icons';
|
||||
import { Modal, Input } from '@manacore/shared-ui';
|
||||
import { PROVIDER_INFO, type CalendarProvider, type SyncDirection } from '@calendar/shared';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
// Connect form state
|
||||
let showConnectForm = $state(false);
|
||||
let connectStep = $state<'provider' | 'credentials' | 'caldav-discover'>('provider');
|
||||
let selectedProvider = $state<CalendarProvider | null>(null);
|
||||
let connectName = $state('');
|
||||
let connectUrl = $state('');
|
||||
let connectUsername = $state('');
|
||||
let connectPassword = $state('');
|
||||
let connectDirection = $state<SyncDirection>('import');
|
||||
let isConnecting = $state(false);
|
||||
|
||||
// CalDAV discovery
|
||||
let discoveredCalendars = $state<Array<{ url: string; name: string; color?: string }>>([]);
|
||||
let isDiscovering = $state(false);
|
||||
|
||||
// Provider selection
|
||||
const providers: { id: CalendarProvider; label: string; description: string }[] = [
|
||||
{ id: 'ical_url', label: 'iCal URL', description: 'ICS-Link importieren (z.B. Feiertage)' },
|
||||
{ id: 'caldav', label: 'CalDAV', description: 'CalDAV-Server verbinden' },
|
||||
{ id: 'google', label: 'Google Calendar', description: 'Mit Google Kalender synchronisieren' },
|
||||
{ id: 'apple', label: 'Apple Calendar', description: 'iCloud Kalender verbinden' },
|
||||
];
|
||||
|
||||
function selectProvider(provider: CalendarProvider) {
|
||||
selectedProvider = provider;
|
||||
|
||||
if (provider === 'google') {
|
||||
handleGoogleConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'caldav') {
|
||||
connectStep = 'caldav-discover';
|
||||
return;
|
||||
}
|
||||
|
||||
connectStep = 'credentials';
|
||||
}
|
||||
|
||||
async function handleGoogleConnect() {
|
||||
const result = await externalCalendarsStore.getGoogleAuthUrl();
|
||||
if (result.data) {
|
||||
window.location.href = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCalDavDiscover() {
|
||||
if (!connectUrl.trim() || !connectUsername.trim() || !connectPassword.trim()) return;
|
||||
|
||||
isDiscovering = true;
|
||||
const result = await externalCalendarsStore.discoverCalDav(
|
||||
connectUrl.trim(),
|
||||
connectUsername.trim(),
|
||||
connectPassword.trim()
|
||||
);
|
||||
|
||||
if (result.data) {
|
||||
discoveredCalendars = result.data;
|
||||
}
|
||||
isDiscovering = false;
|
||||
}
|
||||
|
||||
async function connectCalDavCalendar(cal: { url: string; name: string; color?: string }) {
|
||||
isConnecting = true;
|
||||
await externalCalendarsStore.connect({
|
||||
name: cal.name,
|
||||
provider: 'caldav',
|
||||
calendarUrl: cal.url,
|
||||
username: connectUsername.trim(),
|
||||
password: connectPassword.trim(),
|
||||
syncDirection: connectDirection,
|
||||
color: cal.color,
|
||||
});
|
||||
isConnecting = false;
|
||||
closeConnectForm();
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!connectName.trim() || !connectUrl.trim()) return;
|
||||
if (!selectedProvider) return;
|
||||
|
||||
isConnecting = true;
|
||||
|
||||
await externalCalendarsStore.connect({
|
||||
name: connectName.trim(),
|
||||
provider: selectedProvider,
|
||||
calendarUrl: connectUrl.trim(),
|
||||
username: connectUsername.trim() || undefined,
|
||||
password: connectPassword.trim() || undefined,
|
||||
syncDirection: connectDirection,
|
||||
});
|
||||
|
||||
isConnecting = false;
|
||||
closeConnectForm();
|
||||
}
|
||||
|
||||
function closeConnectForm() {
|
||||
showConnectForm = false;
|
||||
connectStep = 'provider';
|
||||
selectedProvider = null;
|
||||
connectName = '';
|
||||
connectUrl = '';
|
||||
connectUsername = '';
|
||||
connectPassword = '';
|
||||
connectDirection = 'import';
|
||||
discoveredCalendars = [];
|
||||
}
|
||||
|
||||
async function handleDisconnect(id: string, name: string) {
|
||||
if (!confirm(`"${name}" wirklich trennen? Synchronisierte Termine werden gelöscht.`)) return;
|
||||
await externalCalendarsStore.disconnect(id);
|
||||
}
|
||||
|
||||
async function handleSync(id: string) {
|
||||
await externalCalendarsStore.triggerSync(id);
|
||||
}
|
||||
|
||||
async function handleToggleSync(id: string, currentEnabled: boolean) {
|
||||
await externalCalendarsStore.update(id, { syncEnabled: !currentEnabled });
|
||||
}
|
||||
|
||||
function formatSyncTime(date: Date | string | null | undefined): string {
|
||||
if (!date) return 'Noch nie';
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: de });
|
||||
}
|
||||
|
||||
function getSyncDirectionLabel(direction: SyncDirection): string {
|
||||
switch (direction) {
|
||||
case 'import':
|
||||
return 'Nur Import';
|
||||
case 'export':
|
||||
return 'Nur Export';
|
||||
case 'both':
|
||||
return 'Bidirektional';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
await externalCalendarsStore.fetchCalendars();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kalender-Sync - Einstellungen</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<header class="header">
|
||||
<a href="/settings" class="back-button" aria-label="Zurück">
|
||||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<h1 class="title">Kalender-Sync</h1>
|
||||
<button
|
||||
onclick={() => (showConnectForm = true)}
|
||||
class="add-button"
|
||||
aria-label="Kalender verbinden"
|
||||
>
|
||||
<Plus size={20} weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="description">
|
||||
Verbinde externe Kalender, um Termine zu importieren und zu synchronisieren.
|
||||
</p>
|
||||
|
||||
{#if externalCalendarsStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<Warning size={16} />
|
||||
<span>{externalCalendarsStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if externalCalendarsStore.loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if externalCalendarsStore.calendars.length === 0}
|
||||
<div class="empty-state">
|
||||
<Globe size={48} class="text-muted-foreground" />
|
||||
<p>Keine externen Kalender verbunden</p>
|
||||
<button class="btn btn-primary" onclick={() => (showConnectForm = true)}>
|
||||
<Plus size={16} weight="bold" />
|
||||
Kalender verbinden
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="calendar-list">
|
||||
{#each externalCalendarsStore.calendars as cal (cal.id)}
|
||||
<div class="calendar-card">
|
||||
<div class="calendar-header">
|
||||
<div class="calendar-info">
|
||||
<div class="calendar-color" style="background-color: {cal.color}"></div>
|
||||
<div>
|
||||
<h3 class="calendar-name">{cal.name}</h3>
|
||||
<span class="calendar-provider"
|
||||
>{PROVIDER_INFO[cal.provider]?.label || cal.provider}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => handleSync(cal.id)}
|
||||
disabled={externalCalendarsStore.isSyncing(cal.id)}
|
||||
title="Jetzt synchronisieren"
|
||||
>
|
||||
<ArrowsClockwise
|
||||
size={16}
|
||||
class={externalCalendarsStore.isSyncing(cal.id) ? 'animate-spin' : ''}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn-danger"
|
||||
onclick={() => handleDisconnect(cal.id, cal.name)}
|
||||
title="Verbindung trennen"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Richtung</span>
|
||||
<span class="detail-value">
|
||||
{#if cal.syncDirection === 'import'}
|
||||
<CloudArrowDown size={14} />
|
||||
{:else if cal.syncDirection === 'export'}
|
||||
<CloudArrowUp size={14} />
|
||||
{:else}
|
||||
<ArrowsClockwise size={14} />
|
||||
{/if}
|
||||
{getSyncDirectionLabel(cal.syncDirection)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Letzte Sync</span>
|
||||
<span class="detail-value">
|
||||
{formatSyncTime(cal.lastSyncAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Status</span>
|
||||
<span class="detail-value">
|
||||
{#if cal.lastSyncError}
|
||||
<span class="status-error">
|
||||
<Warning size={14} />
|
||||
Fehler
|
||||
</span>
|
||||
{:else if cal.syncEnabled}
|
||||
<span class="status-ok">
|
||||
<CheckCircle size={14} />
|
||||
Aktiv (alle {cal.syncInterval} Min.)
|
||||
</span>
|
||||
{:else}
|
||||
<span class="status-paused">Pausiert</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if cal.lastSyncError}
|
||||
<div class="sync-error">
|
||||
{cal.lastSyncError}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="calendar-footer">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cal.syncEnabled}
|
||||
onchange={() => handleToggleSync(cal.id, cal.syncEnabled)}
|
||||
class="toggle"
|
||||
/>
|
||||
<span>Auto-Sync</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Connect Modal -->
|
||||
<Modal
|
||||
visible={showConnectForm}
|
||||
onClose={closeConnectForm}
|
||||
title={connectStep === 'provider'
|
||||
? 'Kalender verbinden'
|
||||
: connectStep === 'caldav-discover'
|
||||
? 'CalDAV-Server verbinden'
|
||||
: `${PROVIDER_INFO[selectedProvider!]?.label || ''} verbinden`}
|
||||
maxWidth="md"
|
||||
>
|
||||
{#if connectStep === 'provider'}
|
||||
<div class="provider-list">
|
||||
{#each providers as provider}
|
||||
<button class="provider-item" onclick={() => selectProvider(provider.id)}>
|
||||
<div class="provider-icon">
|
||||
{#if provider.id === 'google'}
|
||||
<GoogleLogo size={24} />
|
||||
{:else if provider.id === 'ical_url'}
|
||||
<Link size={24} />
|
||||
{:else}
|
||||
<Globe size={24} />
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="provider-name">{provider.label}</span>
|
||||
<span class="provider-desc">{provider.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if connectStep === 'caldav-discover'}
|
||||
<div class="connect-form">
|
||||
<div class="form-field">
|
||||
<label for="caldav-url">Server-URL</label>
|
||||
<Input bind:value={connectUrl} placeholder="https://caldav.example.com" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="caldav-user">Benutzername</label>
|
||||
<Input bind:value={connectUsername} placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="caldav-pass">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={connectPassword}
|
||||
placeholder="Passwort"
|
||||
class="password-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if discoveredCalendars.length > 0}
|
||||
<div class="discovered-list">
|
||||
<h4>Gefundene Kalender:</h4>
|
||||
{#each discoveredCalendars as cal}
|
||||
<button
|
||||
class="discovered-item"
|
||||
onclick={() => connectCalDavCalendar(cal)}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<div class="calendar-color" style="background-color: {cal.color || '#6b7280'}"></div>
|
||||
<span>{cal.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick={() => (connectStep = 'provider')}>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleCalDavDiscover}
|
||||
disabled={isDiscovering || !connectUrl.trim() || !connectUsername.trim()}
|
||||
>
|
||||
{isDiscovering ? 'Suche...' : 'Kalender suchen'}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{:else if connectStep === 'credentials'}
|
||||
<div class="connect-form">
|
||||
<div class="form-field">
|
||||
<label>Name</label>
|
||||
<Input bind:value={connectName} placeholder="Mein externer Kalender" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>URL</label>
|
||||
<Input
|
||||
bind:value={connectUrl}
|
||||
placeholder={selectedProvider === 'ical_url'
|
||||
? 'https://example.com/calendar.ics'
|
||||
: 'https://caldav.example.com/calendar'}
|
||||
/>
|
||||
</div>
|
||||
{#if selectedProvider !== 'ical_url'}
|
||||
<div class="form-field">
|
||||
<label>Benutzername</label>
|
||||
<Input bind:value={connectUsername} placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={connectPassword}
|
||||
placeholder="Passwort"
|
||||
class="password-input"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-field">
|
||||
<label>Sync-Richtung</label>
|
||||
<select bind:value={connectDirection} class="select-input">
|
||||
<option value="import">Nur Import</option>
|
||||
{#if selectedProvider !== 'ical_url'}
|
||||
<option value="export">Nur Export</option>
|
||||
<option value="both">Bidirektional</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick={() => (connectStep = 'provider')}>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleConnect}
|
||||
disabled={isConnecting || !connectName.trim() || !connectUrl.trim()}
|
||||
>
|
||||
{isConnecting ? 'Verbinde...' : 'Verbinden'}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 2px solid hsl(var(--border));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--card));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.calendar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-color {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.calendar-provider {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-details {
|
||||
padding: 0 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-row + .detail-row {
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: hsl(142 71% 45%);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.sync-error {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.calendar-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Icon buttons */
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn-danger:hover {
|
||||
color: hsl(0 84% 60%);
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
}
|
||||
|
||||
/* Provider list */
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.provider-item:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.provider-desc {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Connect form */
|
||||
.connect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.password-input,
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.password-input:focus,
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Discovered calendars */
|
||||
.discovered-list {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.discovered-list h4 {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.discovered-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.discovered-item:hover:not(:disabled) {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
.discovered-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.discovered-item + .discovered-item {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Modal footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue