mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 22:02:13 +02:00
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>
1816 lines
45 KiB
Svelte
1816 lines
45 KiB
Svelte
<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>
|