managarten/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte
Till-JS 14ce457c7b refactor(shared-ui): centralize toast system across all web apps
- Add toast module to @manacore/shared-ui (toastStore, ToastContainer)
- Remove local toast implementations from:
  - calendar/web
  - chat/web
  - clock/web
  - contacts/web
  - picture/web
  - storage/web
- Update all apps to import toast from shared-ui
- Consistent toast API: toast.success(), toast.error(), toast.info()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:03:29 +01:00

1587 lines
39 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 { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { contactsStore } from '$lib/stores/contacts.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 } 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';
// 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();
// 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);
// 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;
});
});
// 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 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(() => {
if (!isEditMode && !calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.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;
if (!isEditMode) {
eventsStore.updateDraftEvent({ title: 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();
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,
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;
}
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
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;
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;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- 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">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</ConfirmationPopover>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</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"
/>
</div>
<!-- Time display under title -->
<div class="time-display">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<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 calendarsStore.calendars.length > 0}
<div class="calendar-pills-scroll">
{#each calendarsStore.calendars as cal}
<button
type="button"
class="calendar-pill"
class:active={calendarId === cal.id}
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)}>×</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))}
>×</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_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="form-row clickable" onclick={handleAllDayToggle} role="button" tabindex="0">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</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}
<!-- Start date/time -->
<div class="form-row">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<span class="field-label">Beginn</span>
<input
type="date"
class="field-input"
value={startDateStr}
onchange={handleStartDateChange}
/>
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<span class="field-label">Uhrzeit</span>
<input
type="time"
class="field-input"
value={startTimeStr}
onchange={handleStartTimeChange}
/>
</div>
{/if}
</div>
</div>
<!-- End date/time -->
<div class="form-row">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<span class="field-label">Ende</span>
<input
type="date"
class="field-input"
value={endDateStr}
onchange={handleEndDateChange}
/>
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<span class="field-label">Uhrzeit</span>
<input
type="time"
class="field-input"
value={endTimeStr}
onchange={handleEndTimeChange}
/>
</div>
{/if}
</div>
</div>
<!-- Location -->
<div class="form-row">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class="row-content">
<input
type="text"
class="field-input full"
bind:value={location}
placeholder="Ort hinzufügen"
/>
<!-- Toggle for address details -->
<button
type="button"
class="address-toggle"
onclick={() => (showLocationDetails = !showLocationDetails)}
>
<svg
class="toggle-chevron"
class:rotated={showLocationDetails}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{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">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
</div>
<div class="row-content">
<textarea
class="field-textarea"
bind:value={description}
placeholder="Beschreibung hinzufügen"
rows="3"
></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));
}
.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>