managarten/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte
Till JS 9f6e463eae refactor(calendar,contacts): replace inline SVGs with Phosphor icons
Migrate all inline SVG icon paths to Phosphor components from
@manacore/shared-icons across 38 files. Only spinners (loading
animations) and brand logos (Google) remain as inline SVGs.

Calendar: 0 inline icon SVGs remaining
Contacts: 6 remaining (3 spinners, 1 spinner, 2 Google logos)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:01:18 +02:00

1816 lines
45 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { getContext } from 'svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getDefaultCalendar, getCalendarColorWithBirthdays } from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import { contactsStore } from '$lib/stores/contacts.svelte';
import RecurrenceEditDialog from './RecurrenceEditDialog.svelte';
import RecurrenceSelector from './RecurrenceSelector.svelte';
import type {
LocationDetails,
CalendarEvent,
ResponsiblePerson,
EventAttendee,
} from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import {
ContactSelector,
ContactAvatar,
ConfirmationPopover,
FilterDropdown,
toastStore as toast,
type FilterDropdownOption,
} from '@manacore/shared-ui';
import {
Users,
Trash,
X,
Clock,
CalendarBlank,
ArrowsClockwise,
MapPin,
CaretRight,
TextAlignLeft,
} from '@manacore/shared-icons';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import { tick, onMount, onDestroy } from 'svelte';
import {
parseEventInput,
formatParsedEventPreview,
type ParsedEvent,
} from '$lib/utils/event-parser';
import {
estimateEventDuration,
detectConflicts,
type HistoricalEventData,
type DurationEstimate,
type ConflictResult,
} from '$lib/utils/event-estimator';
import { eventCollection } from '$lib/data/local-store';
// Portal action - moves element to body to escape stacking contexts
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
interface Props {
startTime?: Date;
event?: CalendarEvent;
onClose: () => void;
onCreated?: () => void;
onUpdated?: () => void;
onDeleted?: () => void;
}
let { startTime, event, onClose, onCreated, onUpdated, onDeleted }: Props = $props();
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Mode: create or edit
let isEditMode = $derived(!!event);
// Input ref for programmatic focus
let titleInputRef = $state<HTMLInputElement | null>(null);
// Position tracking
let overlayPosition = $state({ left: 0, top: 0 });
let positionInitialized = $state(false);
// Track when draft event was last modified (to ignore clicks after drag/resize)
let lastDraftUpdateTime = $state(0);
// Calculate position relative to draft event element or existing event
function updatePosition() {
if (typeof window === 'undefined') return;
// In edit mode, position relative to the existing event element
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
: '[data-event-id="__draft__"]';
const eventElement = document.querySelector(eventSelector);
if (!eventElement) {
// Fallback: center in viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
overlayPosition = {
left: Math.max(16, (viewportWidth - 380) / 2),
top: Math.max(16, (viewportHeight - 450) / 2),
};
positionInitialized = true;
return;
}
const rect = eventElement.getBoundingClientRect();
const overlayWidth = 380;
const maxOverlayHeight = 450;
const margin = 16;
const gap = 8; // Gap between event and overlay
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Try to position to the right of the event
let left = rect.right + gap;
let top = rect.top;
// If not enough space on the right, try left side
if (left + overlayWidth > viewportWidth - margin) {
left = rect.left - overlayWidth - gap;
}
// If still no space, position below/above
if (left < margin) {
left = Math.max(margin, Math.min(rect.left, viewportWidth - overlayWidth - margin));
top = rect.bottom + gap;
// If no space below, position above
if (top + maxOverlayHeight > viewportHeight - margin) {
top = rect.top - maxOverlayHeight - gap;
}
}
// Final clamps
left = Math.max(margin, Math.min(left, viewportWidth - overlayWidth - margin));
top = Math.max(margin, Math.min(top, viewportHeight - maxOverlayHeight - margin));
overlayPosition = { left, top };
positionInitialized = true;
}
// Handle clicks outside overlay (but allow clicks on event)
function handleDocumentClick(e: MouseEvent) {
// Ignore clicks within 250ms of draft event update (drag/resize just ended)
if (Date.now() - lastDraftUpdateTime < 250) {
return;
}
const target = e.target as HTMLElement;
// If target was removed from DOM by state change (e.g., button that toggles its own visibility),
// ignore the click to prevent false "outside" detection
if (!target.isConnected) {
return;
}
const overlay = document.querySelector('.quick-event-overlay');
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
: '[data-event-id="__draft__"]';
const eventElement = document.querySelector(eventSelector);
// Don't close if clicking on overlay or event element
if (overlay?.contains(target) || eventElement?.contains(target)) {
return;
}
// Close overlay for clicks outside
onClose();
}
onMount(() => {
// Initial position calculation with slight delay for DOM update
requestAnimationFrame(() => {
updatePosition();
});
// Add click listener with slight delay to avoid immediate close
setTimeout(() => {
document.addEventListener('click', handleDocumentClick);
}, 100);
});
onDestroy(() => {
document.removeEventListener('click', handleDocumentClick);
});
// Update position when draft event changes (user dragged it) - only in create mode
$effect(() => {
if (!isEditMode) {
const draft = eventsStore.draftEvent;
if (draft && positionInitialized) {
// Track when draft was updated (for click ignore logic)
lastDraftUpdateTime = Date.now();
// Use requestAnimationFrame to wait for DOM update
requestAnimationFrame(() => {
updatePosition();
});
}
}
});
// Focus input when overlay opens
$effect(() => {
if (titleInputRef) {
tick().then(() => {
titleInputRef?.focus();
// Select all text in edit mode for easy replacement
if (isEditMode) {
titleInputRef?.select();
}
});
}
});
// Form state - initialize from event (edit mode) or draft event (create mode)
let title = $state('');
let calendarId = $state('');
let description = $state('');
let location = $state('');
let isAllDay = $state(false);
let allDayDisplayMode = $state<'default' | 'header' | 'block'>('default');
// Location details state
let showLocationDetails = $state(false);
let locationStreet = $state('');
let locationPostalCode = $state('');
let locationCity = $state('');
let locationCountry = $state('');
let submitting = $state(false);
let showRecurrenceDialog = $state(false);
let recurrenceRule = $state<string | null>(null);
let recurrenceEndDate = $state<string | null>(null);
// People state
let responsiblePerson = $state<ResponsiblePerson | null>(null);
let attendees = $state<EventAttendee[]>([]);
let showPeopleSelector = $state(false);
let contactsAvailable = $state<boolean | null>(null);
// All-day display mode options
const displayModeOptions: FilterDropdownOption[] = [
{ value: 'default', label: 'Standard (aus Einstellungen)' },
{ value: 'header', label: 'In Kopfzeile' },
{ value: 'block', label: 'Als Tagesblock' },
];
// Check contacts availability
$effect(() => {
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
});
// ─── NL Parser State ───────────────────────────────────
let parsePreview = $state('');
let parsedEvent = $state<ParsedEvent | null>(null);
let autoEstimatedDuration = $state<number | null>(null);
let conflictResult = $state<ConflictResult | null>(null);
let estimateDebounce: ReturnType<typeof setTimeout> | undefined;
let parserApplied = $state(false); // track if we already applied parser results
// Parse title input in create mode for NL detection
function parseTitle(text: string) {
if (isEditMode || !text.trim()) {
parsePreview = '';
parsedEvent = null;
autoEstimatedDuration = null;
conflictResult = null;
return;
}
const parsed = parseEventInput(text);
parsedEvent = parsed;
parsePreview = formatParsedEventPreview(parsed);
// Debounced estimation + conflict check
clearTimeout(estimateDebounce);
estimateDebounce = setTimeout(() => runSmartFeatures(parsed), 400);
}
async function runSmartFeatures(parsed: ParsedEvent) {
try {
const allEvents = await eventCollection.getAll();
// Auto-estimate duration (only if no explicit duration and smart duration enabled)
if (settingsStore.smartDurationEnabled && !parsed.duration && parsed.title) {
const history: HistoricalEventData[] = allEvents.map((e) => ({
title: e.title,
calendarId: e.calendarId,
startDate: e.startDate,
endDate: e.endDate,
allDay: e.allDay,
}));
const estimate = estimateEventDuration(
{ title: parsed.title, calendarId: calendarId || undefined },
history
);
autoEstimatedDuration = estimate?.minutes ?? settingsStore.defaultEventDuration;
} else {
autoEstimatedDuration = null;
}
// Conflict detection (only if we have a start+end time)
if (parsed.startDate && parsed.endDate) {
conflictResult = detectConflicts(
parsed.startDate.toISOString(),
parsed.endDate.toISOString(),
allEvents.map((e) => ({
id: e.id,
title: e.title,
startDate: e.startDate,
endDate: e.endDate,
calendarId: e.calendarId,
allDay: e.allDay,
}))
);
} else {
conflictResult = null;
}
} catch {
autoEstimatedDuration = null;
conflictResult = null;
}
}
/** Apply parsed NL results to form fields */
function applyParsedToForm() {
if (!parsedEvent || parserApplied) return;
const parsed = parsedEvent;
// Set clean title (without NL tokens)
title = parsed.title;
// Apply calendar if matched
if (parsed.calendarName) {
const matchedCal = calendarsCtx.value.find(
(c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase()
);
if (matchedCal) {
calendarId = matchedCal.id;
eventsStore.updateDraftEvent({ calendarId: matchedCal.id });
}
}
// Apply date/time
if (parsed.startDate) {
startDateStr = format(parsed.startDate, 'yyyy-MM-dd');
startTimeStr = format(parsed.startDate, 'HH:mm');
}
if (parsed.endDate) {
endDateStr = format(parsed.endDate, 'yyyy-MM-dd');
endTimeStr = format(parsed.endDate, 'HH:mm');
}
// Apply all-day
if (parsed.isAllDay) {
isAllDay = true;
}
// Apply location
if (parsed.location) {
location = parsed.location;
}
// Apply recurrence
if (parsed.recurrenceRule) {
recurrenceRule = parsed.recurrenceRule;
}
// Auto-apply estimated duration to endDate if no explicit end was parsed
if (
settingsStore.smartDurationEnabled &&
!parsed.duration &&
parsed.startDate &&
autoEstimatedDuration
) {
const endDate = new Date(parsed.startDate.getTime() + autoEstimatedDuration * 60_000);
endDateStr = format(endDate, 'yyyy-MM-dd');
endTimeStr = format(endDate, 'HH:mm');
}
// Update draft event with new times
updateDraftTimes();
parserApplied = true;
// Clear preview after applying
parsePreview = '';
autoEstimatedDuration = null;
}
// Editable date/time strings (for form inputs)
let startDateStr = $state('');
let startTimeStr = $state('');
let endDateStr = $state('');
let endTimeStr = $state('');
// Initialize form state from event in edit mode
$effect(() => {
if (isEditMode && event) {
title = event.title || '';
calendarId = event.calendarId || '';
description = event.description || '';
location = event.location || '';
isAllDay = event.isAllDay || false;
allDayDisplayMode =
(event.metadata?.allDayDisplayMode as 'default' | 'header' | 'block') || 'default';
// Initialize location details
const loc = event.metadata?.locationDetails;
if (loc) {
showLocationDetails = true;
locationStreet = loc.street || '';
locationPostalCode = loc.postalCode || '';
locationCity = loc.city || '';
locationCountry = loc.country || '';
}
// Initialize people
responsiblePerson = event.metadata?.responsiblePerson || null;
attendees = event.metadata?.attendees || [];
// Initialize recurrence
recurrenceRule = event.recurrenceRule || null;
recurrenceEndDate = event.recurrenceEndDate
? typeof event.recurrenceEndDate === 'string'
? event.recurrenceEndDate
: event.recurrenceEndDate.toISOString()
: null;
// Initialize time fields
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
startDateStr = format(eventStart, 'yyyy-MM-dd');
startTimeStr = format(eventStart, 'HH:mm');
endDateStr = format(eventEnd, 'yyyy-MM-dd');
endTimeStr = format(eventEnd, 'HH:mm');
}
});
// Date/time fields - derive from draft event (create mode) or event (edit mode)
let draftStart = $derived(() => {
if (isEditMode && event) {
return toDate(event.startTime);
}
const draft = eventsStore.draftEvent;
if (draft) {
return toDate(draft.startTime);
}
return startTime || new Date();
});
let draftEnd = $derived(() => {
if (isEditMode && event) {
return toDate(event.endTime);
}
const draft = eventsStore.draftEvent;
if (draft) {
return toDate(draft.endTime);
}
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
});
// Display date/time - derived from draft event or event
let displayStartDate = $derived(format(draftStart(), 'yyyy-MM-dd'));
let displayStartTime = $derived(format(draftStart(), 'HH:mm'));
let displayEndDate = $derived(format(draftEnd(), 'yyyy-MM-dd'));
let displayEndTime = $derived(format(draftEnd(), 'HH:mm'));
// Sync form fields from draft event when it changes (e.g., user drags it) - only in create mode
$effect(() => {
if (!isEditMode) {
startDateStr = displayStartDate;
startTimeStr = displayStartTime;
endDateStr = displayEndDate;
endTimeStr = displayEndTime;
}
});
// Set default calendar - only in create mode
$effect(() => {
const defaultCal = getDefaultCalendar(calendarsCtx.value);
if (!isEditMode && !calendarId && defaultCal?.id) {
calendarId = defaultCal.id;
// Update draft event with calendar
eventsStore.updateDraftEvent({ calendarId });
}
});
// Update draft event when title changes - only in create mode
function handleTitleChange(e: Event) {
const target = e.target as HTMLInputElement;
title = target.value;
parserApplied = false; // reset on new input
if (!isEditMode) {
eventsStore.updateDraftEvent({ title: target.value });
parseTitle(target.value);
}
}
// Update draft event when time fields change
function handleStartDateChange(e: Event) {
const target = e.target as HTMLInputElement;
startDateStr = target.value;
if (!isEditMode) {
updateDraftTimes();
}
}
function handleStartTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
startTimeStr = target.value;
if (!isEditMode) {
updateDraftTimes();
}
}
function handleEndDateChange(e: Event) {
const target = e.target as HTMLInputElement;
endDateStr = target.value;
if (!isEditMode) {
updateDraftTimes();
}
}
function handleEndTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
endTimeStr = target.value;
if (!isEditMode) {
updateDraftTimes();
}
}
function updateDraftTimes() {
const startDateTime = isAllDay
? new Date(`${startDateStr}T00:00:00`)
: new Date(`${startDateStr}T${startTimeStr}`);
const endDateTime = isAllDay
? new Date(`${endDateStr}T23:59:59`)
: new Date(`${endDateStr}T${endTimeStr}`);
eventsStore.updateDraftEvent({
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
isAllDay,
});
}
// Update draft when all-day changes
function handleAllDayToggle() {
isAllDay = !isAllDay;
if (!isEditMode) {
updateDraftTimes();
}
}
// Overlay style
let overlayStyle = $derived(`left: ${overlayPosition.left}px; top: ${overlayPosition.top}px;`);
// People helpers
function handleContactSearch(query: string): Promise<ContactSummary[]> {
return contactsStore.searchContacts(query);
}
function handleResponsiblePersonChange(contacts: ContactOrManual[]) {
if (contacts.length === 0) {
responsiblePerson = null;
return;
}
const contact = contacts[0];
if ('isManual' in contact && contact.isManual) {
const manual = contact as ManualContactEntry;
responsiblePerson = { email: manual.email, name: manual.name };
} else {
const ref = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
responsiblePerson = {
email: ref.email || '',
name: ref.displayName,
contactId: ref.contactId,
photoUrl: ref.photoUrl,
company: ref.company,
};
}
}
function handleAttendeesChange(contacts: ContactOrManual[]) {
attendees = contacts.map((contact) => {
if ('isManual' in contact && contact.isManual) {
const manual = contact as ManualContactEntry;
const existing = attendees.find((a) => a.email === manual.email);
return {
email: manual.email,
name: manual.name,
status: existing?.status || ('pending' as const),
};
}
const ref = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
const existing = attendees.find(
(a) => a.contactId === ref.contactId || a.email === ref.email
);
return {
email: ref.email || '',
name: ref.displayName,
status: existing?.status || ('pending' as const),
contactId: ref.contactId,
photoUrl: ref.photoUrl,
company: ref.company,
};
});
}
// Convert to ContactOrManual for selectors
const responsibleAsContact = $derived<ContactOrManual[]>(
responsiblePerson
? responsiblePerson.contactId
? [
{
contactId: responsiblePerson.contactId,
displayName: responsiblePerson.name || responsiblePerson.email,
email: responsiblePerson.email,
photoUrl: responsiblePerson.photoUrl,
company: responsiblePerson.company,
fetchedAt: new Date().toISOString(),
},
]
: [
{
email: responsiblePerson.email,
name: responsiblePerson.name,
isManual: true as const,
},
]
: []
);
const attendeesAsContacts = $derived<ContactOrManual[]>(
attendees.map((a) =>
a.contactId
? {
contactId: a.contactId,
displayName: a.name || a.email,
email: a.email,
photoUrl: a.photoUrl,
company: a.company,
fetchedAt: new Date().toISOString(),
}
: { email: a.email, name: a.name, isManual: true as const }
)
);
// Count of people assigned
const peopleCount = $derived((responsiblePerson ? 1 : 0) + attendees.length);
async function handleSubmit(e: Event) {
e.preventDefault();
// Apply NL parser results to form before submitting (create mode only)
if (!isEditMode && parsedEvent && !parserApplied) {
applyParsedToForm();
}
if (!title.trim() || !calendarId) return;
submitting = true;
try {
const startDateTime = isAllDay
? new Date(`${startDateStr}T00:00:00`)
: new Date(`${startDateStr}T${startTimeStr}`);
const endDateTime = isAllDay
? new Date(`${endDateStr}T23:59:59`)
: new Date(`${endDateStr}T${endTimeStr}`);
// Build location details if any field is filled
const locationDetails: LocationDetails | undefined =
locationStreet.trim() ||
locationPostalCode.trim() ||
locationCity.trim() ||
locationCountry.trim()
? {
street: locationStreet.trim() || undefined,
postalCode: locationPostalCode.trim() || undefined,
city: locationCity.trim() || undefined,
country: locationCountry.trim() || undefined,
}
: undefined;
// Build metadata - preserve existing metadata in edit mode
let metadata: Record<string, unknown> | undefined = isEditMode
? { ...(event?.metadata || {}) }
: undefined;
if (isAllDay && allDayDisplayMode !== 'default') {
metadata = {
...(metadata || {}),
allDayDisplayMode: allDayDisplayMode as 'header' | 'block',
};
} else if (metadata) {
delete metadata.allDayDisplayMode;
}
if (locationDetails) {
metadata = { ...(metadata || {}), locationDetails };
} else if (metadata) {
delete metadata.locationDetails;
}
// Add responsible person
if (responsiblePerson) {
metadata = { ...(metadata || {}), responsiblePerson };
} else if (metadata) {
delete metadata.responsiblePerson;
}
// Add attendees
if (attendees.length > 0) {
metadata = { ...(metadata || {}), attendees };
} else if (metadata) {
delete metadata.attendees;
}
// Clean up empty metadata
if (metadata && Object.keys(metadata).length === 0) {
metadata = undefined;
}
const eventData = {
title: title.trim(),
calendarId,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
isAllDay,
description: description.trim() || undefined,
location: location.trim() || undefined,
recurrenceRule: recurrenceRule || undefined,
recurrenceEndDate: recurrenceEndDate || undefined,
metadata,
};
if (isEditMode && event) {
// Update existing event
const result = await eventsStore.updateEvent(event.id, eventData);
if (result.error) {
toast.error(`Fehler beim Speichern: ${result.error.message}`);
return;
}
toast.success('Termin aktualisiert');
onUpdated?.();
} else {
// Create new event
const result = await eventsStore.createEvent(eventData);
if (result.error) {
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
return;
}
// Calendars auto-refresh via live query — no manual fetch needed
toast.success('Termin erstellt');
onCreated?.();
}
onClose();
} catch (error) {
console.error('Failed to save event:', error);
toast.error('Fehler beim Speichern');
} finally {
submitting = false;
}
}
async function handleDelete() {
if (!event) return;
// For recurring events, show recurrence dialog
if (event.recurrenceRule || eventsStore.isRecurrenceOccurrence(event.id)) {
showRecurrenceDialog = true;
return;
}
submitting = true;
try {
const result = await eventsStore.deleteEvent(event.id);
if (result.error) {
toast.error(`Fehler beim Löschen: ${result.error.message}`);
return;
}
toast.success('Termin gelöscht');
onDeleted?.();
onClose();
} catch (error) {
console.error('Failed to delete event:', error);
toast.error('Fehler beim Löschen');
} finally {
submitting = false;
}
}
async function handleRecurrenceDeleteAction(action: 'this' | 'all' | 'this_and_future') {
if (!event) return;
showRecurrenceDialog = false;
submitting = true;
try {
let result;
if (action === 'this') {
result = await eventsStore.deleteRecurrenceOccurrence(event.id);
} else {
result = await eventsStore.deleteRecurrenceSeries(event.id);
}
if (!result.error) {
onDeleted?.();
onClose();
}
} finally {
submitting = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<RecurrenceEditDialog
visible={showRecurrenceDialog}
mode="delete"
onSelect={handleRecurrenceDeleteAction}
onCancel={() => (showRecurrenceDialog = false)}
/>
<!-- Overlay (no blocking backdrop - allows interaction with calendar) -->
<!-- Portal to body to escape stacking contexts -->
<div
use:portal
class="quick-event-overlay"
style="{overlayStyle} z-index: 99999;"
role="dialog"
aria-modal="true"
aria-label={isEditMode ? 'Termin bearbeiten' : 'Termin erstellen'}
>
<form onsubmit={handleSubmit}>
<!-- Header -->
<div class="overlay-header">
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
<div class="header-actions">
{#if isEditMode}
<ConfirmationPopover
onConfirm={handleDelete}
variant="danger"
title="Termin löschen?"
confirmLabel="Löschen"
loading={submitting}
placement="bottom"
>
<button type="button" class="delete-btn" disabled={submitting} aria-label="Löschen">
<Trash size={16} />
</button>
</ConfirmationPopover>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<X size={16} />
</button>
</div>
</div>
<!-- Scrollable content -->
<div class="overlay-content">
<!-- Title input -->
<div class="form-group">
<input
type="text"
class="title-input"
value={title}
oninput={handleTitleChange}
bind:this={titleInputRef}
placeholder="Titel hinzufügen (z.B. Meeting morgen 14 Uhr 1h @Arbeit)"
aria-label="Terminname"
required
/>
{#if parsePreview || autoEstimatedDuration || (conflictResult && conflictResult.hasConflict)}
<div class="nl-hints">
{#if parsePreview}
<span class="nl-preview">{parsePreview}</span>
{/if}
{#if autoEstimatedDuration}
<span class="nl-estimate">
~{autoEstimatedDuration < 60
? `${autoEstimatedDuration}min`
: `${Math.floor(autoEstimatedDuration / 60)}h${autoEstimatedDuration % 60 ? ` ${autoEstimatedDuration % 60}min` : ''}`}
</span>
{/if}
{#if conflictResult && conflictResult.hasConflict}
<span class="nl-conflict">
Konflikt: {conflictResult.conflicts.map((c) => c.title).join(', ')}
</span>
{/if}
</div>
{/if}
</div>
<!-- Time display under title -->
<div class="time-display">
<Clock class="icon" size={18} />
<span>
{format(draftStart(), 'EEEE, d. MMMM yyyy', { locale: de })}
{#if !isAllDay}
· {displayStartTime} {displayEndTime}
{:else}
· Ganztägig
{/if}
</span>
</div>
<!-- Calendar pills -->
<div class="calendar-pills-container">
{#if calendarsCtx.value.length > 0}
<div class="calendar-pills-scroll">
{#each calendarsCtx.value as cal}
<button
type="button"
class="calendar-pill"
class:active={calendarId === cal.id}
aria-pressed={calendarId === cal.id}
aria-label="Kalender: {cal.name}"
onclick={() => {
calendarId = cal.id;
if (!isEditMode) {
eventsStore.updateDraftEvent({ calendarId: cal.id });
}
}}
>
<span class="calendar-pill-dot" style="background-color: {cal.color || '#3b82f6'}"
></span>
<span class="calendar-pill-name">{cal.name}</span>
</button>
{/each}
</div>
{:else}
<span class="field-placeholder">Standardkalender wird erstellt</span>
{/if}
</div>
<!-- People (compact) -->
<div class="form-row">
<div class="row-icon">
<Users class="icon" size={18} />
</div>
<div class="row-content">
<!-- Responsible person - always show directly -->
<div class="people-subsection">
<span class="field-label">Verantwortlich</span>
{#if responsiblePerson}
<div class="person-chip">
<ContactAvatar
photoUrl={responsiblePerson.photoUrl}
name={responsiblePerson.name || responsiblePerson.email}
size="xs"
/>
<span class="person-name">{responsiblePerson.name || responsiblePerson.email}</span>
<button
type="button"
class="remove-person"
onclick={() => (responsiblePerson = null)}
aria-label="Verantwortliche Person entfernen">×</button
>
</div>
{:else}
<ContactSelector
selectedContacts={[]}
onContactsChange={handleResponsiblePersonChange}
onSearch={handleContactSearch}
allowManualEntry={true}
placeholder="Person auswählen..."
addLabel="Auswählen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
singleSelect={true}
/>
{/if}
</div>
<!-- Attendees - show when expanded or when there are attendees -->
{#if showPeopleSelector || attendees.length > 0}
<div class="people-subsection">
<span class="field-label">Teilnehmer</span>
{#if attendees.length > 0}
<div class="people-chips">
{#each attendees as attendee (attendee.email)}
<div class="person-chip">
<ContactAvatar
photoUrl={attendee.photoUrl}
name={attendee.name || attendee.email}
size="xs"
/>
<span class="person-name">{attendee.name || attendee.email}</span>
<button
type="button"
class="remove-person"
onclick={() =>
(attendees = attendees.filter((a) => a.email !== attendee.email))}
aria-label="Teilnehmer {attendee.name || attendee.email} entfernen"
>×</button
>
</div>
{/each}
</div>
{/if}
<ContactSelector
selectedContacts={attendeesAsContacts}
onContactsChange={handleAttendeesChange}
onSearch={handleContactSearch}
allowManualEntry={true}
placeholder={attendees.length > 0
? 'Weitere hinzufügen...'
: 'Teilnehmer hinzufügen...'}
addLabel="Hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
{:else}
<!-- Show expand button for attendees -->
<button
type="button"
class="add-attendees-btn"
onclick={() => (showPeopleSelector = true)}
>
+ Teilnehmer hinzufügen
</button>
{/if}
</div>
</div>
<!-- All day toggle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="form-row clickable"
onclick={handleAllDayToggle}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleAllDayToggle()}
role="switch"
tabindex="0"
aria-checked={isAllDay}
aria-label="Ganztägig"
>
<div class="row-icon">
<CalendarBlank class="icon" size={18} />
</div>
<div class="row-content toggle-content">
<span>Ganztägig</span>
<input
type="checkbox"
checked={isAllDay}
class="toggle-checkbox"
onclick={(e) => e.stopPropagation()}
onchange={handleAllDayToggle}
/>
</div>
</div>
<!-- All-day display mode -->
{#if isAllDay}
<div class="form-row sub-row">
<div class="row-icon"></div>
<div class="row-content">
<span class="field-label">Anzeigeart</span>
<FilterDropdown
options={displayModeOptions}
value={allDayDisplayMode}
onChange={(v) =>
(allDayDisplayMode = (v as 'default' | 'header' | 'block') || 'default')}
placeholder="Anzeigeart wählen"
/>
</div>
</div>
{/if}
<!-- Recurrence -->
<div class="form-row">
<div class="row-icon">
<ArrowsClockwise class="icon" size={18} />
</div>
<div class="row-content">
<RecurrenceSelector
{recurrenceRule}
{recurrenceEndDate}
onRecurrenceChange={(rule, endDate) => {
recurrenceRule = rule;
recurrenceEndDate = endDate;
}}
/>
</div>
</div>
<!-- Start date/time -->
<div class="form-row">
<div class="row-icon">
<CalendarBlank class="icon" size={18} />
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<span class="field-label" id="start-date-label">Beginn</span>
<input
type="date"
class="field-input"
value={startDateStr}
onchange={handleStartDateChange}
aria-labelledby="start-date-label"
/>
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<span class="field-label" id="start-time-label">Uhrzeit</span>
<input
type="time"
class="field-input"
value={startTimeStr}
onchange={handleStartTimeChange}
aria-label="Beginn Uhrzeit"
/>
</div>
{/if}
</div>
</div>
<!-- End date/time -->
<div class="form-row">
<div class="row-icon">
<CalendarBlank class="icon" size={18} />
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<span class="field-label" id="end-date-label">Ende</span>
<input
type="date"
class="field-input"
value={endDateStr}
onchange={handleEndDateChange}
aria-labelledby="end-date-label"
/>
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<span class="field-label" id="end-time-label">Uhrzeit</span>
<input
type="time"
class="field-input"
value={endTimeStr}
onchange={handleEndTimeChange}
aria-label="Ende Uhrzeit"
/>
</div>
{/if}
</div>
</div>
<!-- Location -->
<div class="form-row">
<div class="row-icon">
<MapPin class="icon" size={18} />
</div>
<div class="row-content">
<input
type="text"
class="field-input full"
bind:value={location}
placeholder="Ort hinzufügen"
aria-label="Ort"
/>
<!-- Toggle for address details -->
<button
type="button"
class="address-toggle"
onclick={() => (showLocationDetails = !showLocationDetails)}
aria-expanded={showLocationDetails}
>
<CaretRight class="toggle-chevron {showLocationDetails ? 'rotated' : ''}" size={14} />
{showLocationDetails ? 'Adressdetails ausblenden' : 'Adressdetails hinzufügen'}
</button>
</div>
</div>
<!-- Location details (expandable) -->
{#if showLocationDetails}
<div class="form-row sub-row">
<div class="row-icon"></div>
<div class="row-content address-details-form">
<div class="address-field">
<span class="field-label">Straße</span>
<input
type="text"
class="field-input"
bind:value={locationStreet}
placeholder="Musterstraße 123"
/>
</div>
<div class="address-row">
<div class="address-field postal">
<span class="field-label">PLZ</span>
<input
type="text"
class="field-input"
bind:value={locationPostalCode}
placeholder="12345"
/>
</div>
<div class="address-field city">
<span class="field-label">Stadt</span>
<input
type="text"
class="field-input"
bind:value={locationCity}
placeholder="Musterstadt"
/>
</div>
</div>
<div class="address-field">
<span class="field-label">Land</span>
<input
type="text"
class="field-input"
bind:value={locationCountry}
placeholder="Deutschland"
/>
</div>
</div>
</div>
{/if}
<!-- Description -->
<div class="form-row">
<div class="row-icon">
<TextAlignLeft class="icon" size={18} />
</div>
<div class="row-content">
<textarea
class="field-textarea"
bind:value={description}
placeholder="Beschreibung hinzufügen"
rows="3"
aria-label="Beschreibung"
></textarea>
</div>
</div>
</div>
<!-- Actions (sticky footer) -->
<div class="overlay-actions">
<button type="button" class="btn-ghost" onclick={onClose}> Abbrechen </button>
<button type="submit" class="btn-primary" disabled={submitting || !title.trim()}>
{submitting ? 'Speichern...' : 'Speichern'}
</button>
</div>
</form>
</div>
<style>
.quick-event-overlay {
position: fixed;
width: 380px;
max-height: 450px;
background: var(--color-surface-elevated-2);
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
z-index: 99999 !important;
display: flex;
flex-direction: column;
animation: slideIn 150ms ease-out;
overflow: hidden;
}
.quick-event-overlay form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--color-border));
flex-shrink: 0;
}
.header-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.close-btn,
.delete-btn {
padding: 0.375rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 150ms;
}
.close-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.delete-btn:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.overlay-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem 0;
}
.form-group {
padding: 0 1.25rem;
}
.title-input {
width: 100%;
padding: 0.75rem 0;
border: none;
background: transparent;
font-size: 1.25rem;
font-weight: 500;
color: hsl(var(--color-foreground));
outline: none;
}
.title-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.nl-hints {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 1.25rem 0.5rem;
font-size: 0.7rem;
}
.nl-preview {
color: hsl(var(--color-muted-foreground));
}
.nl-estimate {
display: inline-flex;
padding: 0.0625rem 0.4rem;
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-primary));
border-radius: 9999px;
font-size: 0.65rem;
}
.nl-conflict {
color: hsl(var(--color-destructive, 0 84% 60%));
font-weight: 500;
}
.time-display {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem 1rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
border-bottom: 1px solid hsl(var(--color-border));
margin-bottom: 0.5rem;
}
.icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: hsl(var(--color-muted-foreground));
}
.form-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
}
.form-row.clickable {
cursor: pointer;
transition: background-color 150ms;
}
.form-row.clickable:hover {
background: hsl(var(--color-muted) / 0.3);
}
.form-row.sub-row {
padding-top: 0;
}
.row-icon {
width: 24px;
display: flex;
align-items: center;
justify-content: center;
padding-top: 0.25rem;
flex-shrink: 0;
}
.calendar-dot {
width: 14px;
height: 14px;
border-radius: 50%;
}
/* Calendar pills */
.calendar-pills-container {
padding: 0.5rem 0;
border-bottom: 1px solid hsl(var(--color-border));
}
.calendar-pills-scroll {
display: flex;
gap: 0.5rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 0 1.25rem 2px;
}
.calendar-pills-scroll::-webkit-scrollbar {
display: none;
}
.calendar-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: all 150ms;
flex-shrink: 0;
}
.calendar-pill:hover {
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-foreground));
}
.calendar-pill.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.calendar-pill-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.calendar-pill-name {
}
.row-content {
flex: 1;
min-width: 0;
}
.toggle-content {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.toggle-checkbox {
width: 18px;
height: 18px;
accent-color: hsl(var(--color-primary));
cursor: pointer;
}
.field-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.25rem;
}
.field-input {
width: 100%;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.field-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.field-placeholder {
display: block;
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.field-input.full {
padding: 0.625rem;
}
.datetime-row {
display: flex;
gap: 0.5rem;
}
.datetime-field {
flex: 1;
}
.datetime-field.time-field {
flex: 0 0 100px;
}
.field-textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.field-textarea:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.overlay-actions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--color-border));
background: var(--color-surface-elevated-2);
flex-shrink: 0;
}
.overlay-actions .btn-primary {
flex: 1;
}
.btn-ghost {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 150ms;
}
.btn-ghost:hover {
background: hsl(var(--color-muted));
}
.btn-primary {
padding: 0.5rem 1.25rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 150ms;
}
.btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Scrollbar styling */
.overlay-content {
scrollbar-width: thin;
scrollbar-color: hsl(var(--color-muted)) transparent;
}
.overlay-content::-webkit-scrollbar {
width: 6px;
}
.overlay-content::-webkit-scrollbar-track {
background: transparent;
}
.overlay-content::-webkit-scrollbar-thumb {
background: hsl(var(--color-muted));
border-radius: 3px;
}
.overlay-content::-webkit-scrollbar-thumb:hover {
background: hsl(var(--color-muted-foreground));
}
/* Address toggle and details */
.address-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
padding: 0;
border: none;
background: transparent;
color: hsl(var(--color-primary));
font-size: 0.8125rem;
cursor: pointer;
transition: color 150ms;
}
.address-toggle:hover {
color: hsl(var(--color-primary) / 0.8);
}
.toggle-chevron {
width: 14px;
height: 14px;
transition: transform 150ms ease;
}
.toggle-chevron.rotated {
transform: rotate(90deg);
}
.address-details-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: var(--radius-sm);
border: 1px solid hsl(var(--color-border));
}
.address-field {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.address-row {
display: flex;
gap: 0.5rem;
}
.address-field.postal {
flex: 0 0 80px;
}
.address-field.city {
flex: 1;
}
/* People section */
.add-attendees-btn {
margin-top: 0.5rem;
padding: 0.25rem 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: color 150ms;
text-align: left;
}
.add-attendees-btn:hover {
color: hsl(var(--color-primary));
}
.people-subsection {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.people-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem 0.25rem 0.25rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-full);
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.person-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-person {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 1rem;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-full);
transition: all 150ms;
}
.remove-person:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.people-subsection + .people-subsection {
margin-top: 0.75rem;
}
</style>