My-Weekly-ToDo-List/src/components/CalendarEventModal.tsx
mARTin-B78 e7eb060643 feat: add location autocomplete and custom reminder times to event modal
- Location field shows dropdown suggestions from previously saved locations
  as the user types; selected/submitted locations are persisted to
  profile.viewSettings.savedLocations (max 20, most recent first)
- Reminder dropdown gains a "Custom…" option that opens an inline editor
  for entering X minutes / hours / days before the event; confirmed custom
  values are persisted to profile.viewSettings.customReminderMinutes
  (max 10) and appear in future reminder dropdowns

v1.112.0
2026-05-29 11:53:03 +02:00

1312 lines
78 KiB
TypeScript

import React, { useState, useEffect, lazy, Suspense, useRef, useCallback } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity, Check } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faServer } from '@fortawesome/free-solid-svg-icons';
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function AnimatedDots() {
return (
<span className="animated-dots">
<span className="dot">.</span><span className="dot">.</span><span className="dot">.</span>
<style>{`
.animated-dots .dot {
animation: dotPulse 1.4s infinite;
opacity: 0.2;
}
.animated-dots .dot:nth-child(2) { animation-delay: 0.2s; }
.animated-dots .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
`}</style>
</span>
);
}
// Reminder preset options (minutes)
const REMINDER_PRESETS = [
{ label: 'None', value: -1 },
{ label: 'At time of event', value: 0 },
{ label: '5 minutes before', value: 5 },
{ label: '15 minutes before', value: 15 },
{ label: '30 minutes before', value: 30 },
{ label: '1 hour before', value: 60 },
{ label: '2 hours before', value: 120 },
{ label: '12 hours before', value: 720 },
{ label: '1 day before', value: 1440 },
{ label: '1 week before', value: 10080 },
];
const CUSTOM_REMINDER_SENTINEL = -9999;
function formatReminderMinutes(minutes: number, language: string): string {
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
if (minutes % 10080 === 0) {
const w = minutes / 10080;
return language === 'de' ? `${w} ${w === 1 ? 'Woche' : 'Wochen'} vorher` : `${w} week${w !== 1 ? 's' : ''} before`;
}
if (minutes % 1440 === 0) {
const d = minutes / 1440;
return language === 'de' ? `${d} ${d === 1 ? 'Tag' : 'Tage'} vorher` : `${d} day${d !== 1 ? 's' : ''} before`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return language === 'de' ? `${h} ${h === 1 ? 'Stunde' : 'Stunden'} vorher` : `${h} hour${h !== 1 ? 's' : ''} before`;
}
return language === 'de' ? `${minutes} Minuten vorher` : `${minutes} minutes before`;
}
const BUSY_STATUS_OPTIONS = [
{ label: 'Busy', value: 'busy' },
{ label: 'Free', value: 'free' },
{ label: 'Tentative', value: 'tentative' },
{ label: 'Out of Office', value: 'oof' },
{ label: 'Working Elsewhere', value: 'workingElsewhere' },
];
const VISIBILITY_OPTIONS = [
{ label: 'Default', value: 'default' },
{ label: 'Public', value: 'public' },
{ label: 'Private', value: 'private' },
{ label: 'Confidential', value: 'confidential' },
];
interface CalendarEventModalProps {
event?: any;
initialDate?: Date;
initialStartTime?: string;
initialEndTime?: string;
connections: any[];
weekStartDay?: number; // 0=Sunday, 1=Monday
language?: string;
savedLocations?: string[];
onSaveLocation?: (loc: string) => void;
customReminderMinutes?: number[];
onSaveCustomReminder?: (minutes: number) => void;
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
}
export default function CalendarEventModal({
event,
initialDate,
initialStartTime,
initialEndTime,
connections,
weekStartDay = 0,
language = 'en',
savedLocations = [],
onSaveLocation,
customReminderMinutes = [],
onSaveCustomReminder,
onClose,
onSave,
onDelete
}: CalendarEventModalProps) {
const availableCalendars = connections
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
...cal,
provider: conn.provider,
providerName: conn.provider === 'google' ? 'Google Calendar' :
conn.provider === 'apple' ? 'Apple Calendar' :
conn.provider === 'synology' ? 'Synology Calendar' :
'Outlook Calendar'
})))
.filter((cal: any) => cal.editable);
const [title, setTitle] = useState(event?.title || '');
const [description, setDescription] = useState(event?.description || '');
const [location, setLocation] = useState(event?.location || '');
const [url, setUrl] = useState(event?.url || '');
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none');
const [recurrenceEndType, setRecurrenceEndType] = useState<'never' | 'date' | 'count'>(
event?.recurrenceCount ? 'count' : event?.recurrenceEndDate ? 'date' : 'never'
);
const [recurrenceEndDateValue, setRecurrenceEndDateValue] = useState(
event?.recurrenceEndDate || ''
);
const [recurrenceCount, setRecurrenceCount] = useState(event?.recurrenceCount || 10);
const [customInterval, setCustomInterval] = useState(event?.recurrenceInterval || 1);
const [customUnit, setCustomUnit] = useState<'days' | 'weeks' | 'months' | 'years'>(event?.recurrenceUnit || 'weeks');
const startDow = (() => { const d = event?.start?.dateTime ? new Date(event.start.dateTime) : new Date(); return d.getDay(); })();
const [customDays, setCustomDays] = useState<number[]>(event?.recurrenceDays || [startDow]);
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
const [reminders, setReminders] = useState<Array<{ method: string; minutes: number }>>(
event?.reminders || [{ method: 'display', minutes: 15 }]
);
const [busyStatus, setBusyStatus] = useState<string>(event?.busyStatus || 'busy');
const [visibility, setVisibility] = useState<string>(event?.visibility || 'default');
const [attendees, setAttendees] = useState<Array<{ email: string; displayName?: string }>>(
event?.attendees || []
);
const [newAttendeeEmail, setNewAttendeeEmail] = useState('');
const [attachments, setAttachments] = useState<Array<{ url: string; title?: string }>>(
event?.attachments || []
);
const [newAttachmentUrl, setNewAttachmentUrl] = useState('');
const [showMoreOptions, setShowMoreOptions] = useState(
!!(event?.attendees?.length || event?.attachments?.length || (event?.busyStatus && event.busyStatus !== 'busy') || (event?.visibility && event.visibility !== 'default'))
);
const getInitialStart = () => {
if (event?.start?.dateTime) return new Date(event.start.dateTime);
if (event?.startTime) return new Date(event.startTime);
if (initialDate) {
const d = new Date(initialDate);
if (initialStartTime) {
const [h, m] = initialStartTime.split(':').map(Number);
d.setHours(h, m, 0, 0);
} else {
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
}
return d;
}
return new Date();
};
const getInitialEnd = () => {
if (event?.end?.dateTime) return new Date(event.end.dateTime);
if (event?.endTime) return new Date(event.endTime);
if (initialEndTime && initialDate) {
const d = new Date(initialDate);
const [h, m] = initialEndTime.split(':').map(Number);
d.setHours(h, m, 0, 0);
return d;
}
const start = getInitialStart();
return new Date(start.getTime() + 60 * 60 * 1000);
};
const [startDate, setStartDate] = useState(getInitialStart());
const [endDate, setEndDate] = useState(getInitialEnd());
const [allDay, setAllDay] = useState(!!event?.allDay);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingMode, setDeletingMode] = useState<string | null>(null);
const [error, setError] = useState('');
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
const calendarSelectorRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Location autocomplete
const [locationFocused, setLocationFocused] = useState(false);
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
const locationRef = useRef<HTMLDivElement>(null);
const updateLocationSuggestions = useCallback((val: string) => {
if (!val.trim() || savedLocations.length === 0) {
setLocationSuggestions([]);
return;
}
const lower = val.toLowerCase();
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val);
setLocationSuggestions(matches.slice(0, 6));
}, [savedLocations]);
// Custom reminder state
// customReminderIdx tracks which reminder row is showing the custom editor
const [customEditorIdx, setCustomEditorIdx] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState(30);
const [reminderUnit, setReminderUnit] = useState<'minutes' | 'hours' | 'days'>('minutes');
const customAmountToMinutes = () => {
if (reminderUnit === 'hours') return customAmount * 60;
if (reminderUnit === 'days') return customAmount * 1440;
return customAmount;
};
// Build full reminder options list: presets + saved custom + "Custom..."
const buildReminderOptions = () => [
...REMINDER_PRESETS,
...customReminderMinutes
.filter(m => !REMINDER_PRESETS.some(p => p.value === m))
.map(m => ({ label: formatReminderMinutes(m, language), value: m })),
{ label: language === 'de' ? 'Benutzerdefiniert…' : 'Custom…', value: CUSTOM_REMINDER_SENTINEL },
];
// Close location suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (locationRef.current && !locationRef.current.contains(e.target as Node)) {
setLocationSuggestions([]);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Focus trap
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = Array.from(
dialog.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])')
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (calendarSelectorRef.current && !calendarSelectorRef.current.contains(event.target as Node)) {
setIsCalendarSelectorOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
const supportsURL = selectedCal && (selectedCal.provider !== 'google' && selectedCal.provider !== 'outlook' && selectedCal.provider !== 'synology');
const supportsAttachments = selectedCal && (selectedCal.provider === 'apple' || selectedCal.provider === 'synology');
const getProviderIcon = (provider: string) => {
switch (provider) {
case 'google': return <FontAwesomeIcon icon={faGoogle} style={{ opacity: 0.8 }} />;
case 'apple': return <FontAwesomeIcon icon={faApple} style={{ opacity: 0.8 }} />;
case 'outlook': return <FontAwesomeIcon icon={faMicrosoft} style={{ opacity: 0.8 }} />;
case 'synology': return <FontAwesomeIcon icon={faServer} style={{ opacity: 0.8, fontSize: '0.75rem' }} />;
default: return null;
}
};
const buildSavePayload = (editMode?: string) => {
const activeReminders = reminders.filter(r => r.minutes >= 0);
return {
id: event?.id,
title,
description,
location,
url: url || undefined,
recurrence: recurrence === 'custom' ? customUnit.replace(/s$/, '') === 'day' ? 'daily' : customUnit.replace(/s$/, '') === 'week' ? 'weekly' : customUnit.replace(/s$/, '') === 'month' ? 'monthly' : 'yearly'
: recurrence !== 'none' ? recurrence : undefined,
recurrenceInterval: recurrence === 'custom' ? customInterval : undefined,
recurrenceDays: recurrence === 'custom' && customUnit === 'weeks' ? customDays : undefined,
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
calendarId,
allDay,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
reminders: activeReminders.length > 0 ? activeReminders : undefined,
busyStatus: busyStatus,
visibility: visibility !== 'default' ? visibility : undefined,
attendees: attendees.length > 0 ? attendees : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
...(editMode ? { editMode, recurringEventId: event?.recurringEventId } : {}),
};
};
const handleSubmit = async (editMode?: string) => {
if (!title.trim()) {
setError(language === 'de' ? 'Titel erforderlich' : 'Title is required');
return;
}
if (!calendarId) {
setError(language === 'de' ? 'Bitte Kalender auswählen' : 'Please select a calendar');
return;
}
if (endDate <= startDate) {
setError(language === 'de' ? 'Ende muss nach Start liegen' : 'End time must be after start time');
return;
}
if (event?.id && event?.isRecurring && !editMode && !showRecurringEditOptions) {
setShowRecurringEditOptions(true);
return;
}
// Save location to user profile if non-empty and new
if (location.trim() && onSaveLocation) {
onSaveLocation(location.trim());
}
setIsSaving(true);
setError('');
try {
await onSave(buildSavePayload(editMode));
onClose();
} catch (err: any) {
console.error(err);
setError(err.message || 'Failed to save event');
setIsSaving(false);
setShowRecurringEditOptions(false);
}
};
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
const [showRecurringEditOptions, setShowRecurringEditOptions] = useState(false);
const [recurringEditMode, setRecurringEditMode] = useState<'this' | 'future' | 'all'>('this');
const handleDelete = async (mode?: string) => {
if (!event?.id || !onDelete) return;
if (event.isRecurring && !mode && !showRecurringDeleteOptions) {
setShowRecurringDeleteOptions(true);
return;
}
if (!event.isRecurring && !isDeleteConfirming) {
setIsDeleteConfirming(true);
setTimeout(() => setIsDeleteConfirming(false), 3000);
return;
}
setIsDeleting(true);
setDeletingMode(mode || 'all');
try {
await onDelete(event.id, event.calendarId, mode || 'all');
onClose();
} catch (err: any) {
setError(err.message || 'Failed to delete event');
setIsDeleting(false);
setDeletingMode(null);
setIsDeleteConfirming(false);
setShowRecurringDeleteOptions(false);
}
};
const toLocalISOString = (date: Date) => {
const offset = date.getTimezoneOffset() * 60000;
const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16);
return localISOTime;
};
const handleStartDateChange = (val: string) => {
const newStart = new Date(val);
setStartDate(newStart);
if (endDate <= newStart) {
setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000));
}
};
const addAttendee = () => {
const email = newAttendeeEmail.trim();
if (!email || !email.includes('@')) return;
if (attendees.some(a => a.email === email)) return;
setAttendees([...attendees, { email }]);
setNewAttendeeEmail('');
};
const removeAttendee = (email: string) => {
setAttendees(attendees.filter(a => a.email !== email));
};
const addAttachment = () => {
const attachUrl = newAttachmentUrl.trim();
if (!attachUrl || (!attachUrl.startsWith('http://') && !attachUrl.startsWith('https://'))) return;
if (attachments.some(a => a.url === attachUrl)) return;
setAttachments([...attachments, { url: attachUrl }]);
setNewAttachmentUrl('');
};
const removeAttachment = (attachUrl: string) => {
setAttachments(attachments.filter(a => a.url !== attachUrl));
};
const updateReminder = (index: number, minutes: number) => {
if (minutes === -1) {
setReminders(reminders.filter((_, i) => i !== index));
setCustomEditorIdx(null);
return;
}
if (minutes === CUSTOM_REMINDER_SENTINEL) {
setCustomEditorIdx(index);
setCustomAmount(30);
setReminderUnit('minutes');
return;
}
const updated = [...reminders];
updated[index] = { ...updated[index], minutes };
setReminders(updated);
setCustomEditorIdx(null);
};
const confirmCustomReminder = (index: number) => {
const mins = customAmountToMinutes();
if (mins <= 0) return;
const updated = [...reminders];
updated[index] = { ...updated[index], minutes: mins };
setReminders(updated);
setCustomEditorIdx(null);
if (onSaveCustomReminder) onSaveCustomReminder(mins);
};
const addReminder = () => {
setReminders([...reminders, { method: 'display', minutes: 15 }]);
};
// Compact icon-row style used throughout
const iconRow: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: '8px',
padding: '3px 0', minHeight: '24px',
};
const iconCol: React.CSSProperties = {
width: '18px', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--weekly-text-light)', flexShrink: 0, opacity: 0.6,
};
const fieldCol: React.CSSProperties = {
flex: 1, minWidth: 0,
};
const inlineSelect: React.CSSProperties = {
background: 'transparent', border: 'none',
fontSize: '0.78rem', cursor: 'pointer', outline: 'none',
color: 'var(--weekly-text)', padding: '1px 0',
};
const chipBtn = (active: boolean): React.CSSProperties => ({
padding: '3px 10px', borderRadius: '14px', fontSize: '0.72rem', fontWeight: 500,
border: active ? '1.5px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)',
background: active ? 'rgba(59,130,246,0.1)' : 'transparent',
color: active ? '#3b82f6' : 'var(--weekly-text)',
cursor: 'pointer', transition: 'all 0.15s',
});
const calColor = selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6';
return (
<div className="weekly-modal-overlay" onClick={onClose} aria-hidden="true">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title ? `${event ? 'Edit' : 'New'} event: ${title}` : (event ? 'Edit event' : 'New event')}
className="weekly-modal-content"
onClick={e => e.stopPropagation()}
aria-hidden="false"
style={{
maxWidth: '380px',
width: 'calc(100vw - 12px)',
padding: '0',
borderRadius: '12px',
boxShadow: '0 12px 40px rgba(0,0,0,0.18)',
border: 'none',
maxHeight: '96vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Color accent bar at top */}
<div style={{ height: '3px', background: calColor, flexShrink: 0 }} />
{/* Scrollable body */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div style={{ padding: '8px 14px 6px', display: 'flex', flexDirection: 'column', gap: '2px' }}>
{error && <div id="cal-event-error" role="alert" style={{ color: '#ef4444', fontSize: '0.75rem', textAlign: 'center', padding: '4px', background: 'rgba(239,68,68,0.08)', borderRadius: '6px', marginBottom: '4px' }}>{error}</div>}
{/* Title */}
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={language === 'de' ? 'Titel hinzufügen' : 'Add title'}
aria-label={language === 'de' ? 'Ereignistitel' : 'Event title'}
aria-describedby={error ? 'cal-event-error' : undefined}
autoFocus
style={{
width: '100%', padding: '3px 0', fontSize: '1rem', fontWeight: 600,
border: 'none', borderBottom: `2px solid ${calColor}`,
background: 'transparent', outline: 'none',
color: 'var(--weekly-text)',
}}
/>
{/* Calendar selector */}
<div style={{ ...iconRow, padding: '2px 0' }}>
<div style={{ ...iconCol, gap: '4px', width: 'auto', display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.8rem', opacity: 0.7 }}>{selectedCal ? getProviderIcon(selectedCal.provider) : null}</span>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: calColor, flexShrink: 0 }} />
</div>
<div ref={calendarSelectorRef} style={{ ...fieldCol, position: 'relative' }}>
<button
type="button"
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={!!event}
aria-label={language === 'de' ? 'Kalender auswählen' : 'Select calendar'}
aria-expanded={isCalendarSelectorOpen}
aria-haspopup="listbox"
style={{
display: 'flex', alignItems: 'center', gap: '4px',
background: 'transparent', border: 'none', fontWeight: 500,
cursor: event ? 'default' : 'pointer', outline: 'none',
color: 'var(--weekly-text)', fontSize: '0.8rem', padding: '2px 0',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedCal?.summary || selectedCal?.title || (language === 'de' ? 'Kalender wählen' : 'Select Calendar')}
</span>
{!event && <ChevronDown size={12} style={{ opacity: 0.5 }} />}
</button>
{isCalendarSelectorOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, zIndex: 100,
minWidth: '200px', backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
borderRadius: '8px', boxShadow: '0 4px 15px rgba(0,0,0,0.12)',
border: '1px solid var(--weekly-border)', marginTop: '2px',
padding: '4px', maxHeight: '200px', overflowY: 'auto'
}}>
{availableCalendars.map((cal: any) => (
<div
key={cal.id}
onClick={() => { setCalendarId(cal.id); setIsCalendarSelectorOpen(false); }}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 8px', borderRadius: '5px', cursor: 'pointer',
fontSize: '0.8rem', color: 'var(--weekly-text)',
backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--weekly-hover, rgba(0,0,0,0.05))'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent'}
>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: cal.backgroundColor || cal.color || '#3b82f6', flexShrink: 0 }} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cal.summary || cal.title}
</span>
<span style={{ color: 'var(--weekly-text-light)', opacity: 0.5, fontSize: '0.75rem' }}>
{getProviderIcon(cal.provider)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Divider */}
<div style={{ height: '1px', background: 'var(--weekly-border, #eee)', margin: '0 14px' }} />
{/* Main fields section */}
<div style={{ padding: '2px 14px', display: 'flex', flexDirection: 'column', gap: '0' }}>
{/* All Day toggle */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Clock size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--weekly-text)' }}>{language === 'de' ? 'Ganztägig' : 'All day'}</span>
<label style={{ position: 'relative', width: '36px', height: '20px', cursor: 'pointer' }} aria-label={language === 'de' ? 'Ganztägig' : 'All day event'}>
<input
type="checkbox"
checked={allDay}
onChange={e => setAllDay(e.target.checked)}
aria-label={language === 'de' ? 'Ganztägig' : 'All day event'}
style={{ opacity: 0, width: 0, height: 0 }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: '10px',
background: allDay ? '#3b82f6' : 'var(--weekly-border, #ccc)',
transition: 'background 0.2s',
}} />
<span style={{
position: 'absolute', top: '2px', left: allDay ? '18px' : '2px',
width: '16px', height: '16px', borderRadius: '50%',
background: 'white', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
transition: 'left 0.2s',
}} />
</label>
</div>
</div>
{/* Start & End times - compact inline */}
<div style={{ ...iconRow, padding: '1px 0' }}>
<div style={iconCol} />
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--weekly-text-light)', width: '26px', flexShrink: 0 }}>{language === 'de' ? 'Von' : 'From'}</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)}
aria-label={language === 'de' ? 'Startdatum und -uhrzeit' : 'Start date and time'}
style={{
flex: 1, padding: '3px 6px', border: 'none', borderRadius: '5px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.78rem', outline: 'none', color: 'var(--weekly-text)',
minWidth: 0,
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--weekly-text-light)', width: '26px', flexShrink: 0 }}>{language === 'de' ? 'Bis' : 'To'}</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))}
aria-label={language === 'de' ? 'Enddatum und -uhrzeit' : 'End date and time'}
style={{
flex: 1, padding: '3px 6px', border: 'none', borderRadius: '5px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.78rem', outline: 'none', color: 'var(--weekly-text)',
minWidth: 0,
}}
/>
</div>
</div>
</div>
{/* Repeat */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Repeat size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', alignItems: 'center' }}>
<select value={recurrence} onChange={e => setRecurrence(e.target.value)} aria-label={language === 'de' ? 'Wiederholung' : 'Recurrence'} style={{ ...inlineSelect, flex: 1 }}>
<option value="none">{language === 'de' ? 'Nie' : 'Never'}</option>
<option value="daily">{language === 'de' ? 'Täglich' : 'Every Day'}</option>
<option value="weekly">{language === 'de' ? 'Wöchentlich' : 'Every Week'}</option>
<option value="monthly">{language === 'de' ? 'Monatlich' : 'Every Month'}</option>
<option value="yearly">{language === 'de' ? 'Jährlich' : 'Every Year'}</option>
<option value="custom">{language === 'de' ? 'Benutzerdefiniert...' : 'Custom...'}</option>
</select>
</div>
</div>
{/* Custom recurrence options */}
{recurrence === 'custom' && (
<div style={{ paddingLeft: '30px', display: 'flex', flexDirection: 'column', gap: '4px', paddingBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)' }}>{language === 'de' ? 'Alle' : 'Every'}</span>
<input
type="number" min={1} max={99} value={customInterval}
onChange={e => setCustomInterval(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Wiederholungsintervall' : 'Recurrence interval'}
style={{
width: '40px', textAlign: 'center', fontSize: '0.8rem',
background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px',
padding: '3px 4px', border: 'none', outline: 'none', color: 'var(--weekly-text)',
}}
/>
<select value={customUnit} onChange={e => setCustomUnit(e.target.value as any)} aria-label={language === 'de' ? 'Wiederholungseinheit' : 'Recurrence unit'} style={inlineSelect}>
<option value="days">{customInterval === 1 ? (language === 'de' ? 'Tag' : 'Day') : (language === 'de' ? 'Tage' : 'Days')}</option>
<option value="weeks">{customInterval === 1 ? (language === 'de' ? 'Woche' : 'Week') : (language === 'de' ? 'Wochen' : 'Weeks')}</option>
<option value="months">{customInterval === 1 ? (language === 'de' ? 'Monat' : 'Month') : (language === 'de' ? 'Monate' : 'Months')}</option>
<option value="years">{customInterval === 1 ? (language === 'de' ? 'Jahr' : 'Year') : (language === 'de' ? 'Jahre' : 'Years')}</option>
</select>
</div>
{customUnit === 'weeks' && (
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
{(() => {
const allDays = language === 'de' ? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] : ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const allDaysFull = language === 'de' ? ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] : ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const dayIndices = Array.from({ length: 7 }, (_, i) => (i + weekStartDay) % 7);
return dayIndices.map(dow => (
<button
key={dow}
onClick={() => {
setCustomDays(prev =>
prev.includes(dow) ? (prev.length > 1 ? prev.filter(d => d !== dow) : prev) : [...prev, dow]
);
}}
aria-label={allDaysFull[dow]}
aria-pressed={customDays.includes(dow)}
style={{
width: '28px', height: '28px', borderRadius: '50%',
border: customDays.includes(dow) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)',
backgroundColor: customDays.includes(dow) ? '#3b82f6' : 'transparent',
color: customDays.includes(dow) ? 'white' : 'var(--weekly-text)',
fontSize: '0.7rem', fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{allDays[dow]}
</button>
));
})()}
</div>
)}
</div>
)}
{/* Recurrence End */}
{recurrence !== 'none' && (
<div style={{ paddingLeft: '30px', display: 'flex', alignItems: 'center', gap: '6px', paddingBottom: '4px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)' }}>{language === 'de' ? 'Ende' : 'End'}</span>
<select
value={recurrenceEndType}
onChange={e => setRecurrenceEndType(e.target.value as 'never' | 'date' | 'count')}
aria-label={language === 'de' ? 'Wiederholung Ende' : 'Recurrence end'}
style={inlineSelect}
>
<option value="never">{language === 'de' ? 'Nie' : 'Never'}</option>
<option value="date">{language === 'de' ? 'Am Datum' : 'On Date'}</option>
<option value="count">{language === 'de' ? 'Nach...' : 'After...'}</option>
</select>
{recurrenceEndType === 'date' && (
<input
type="date"
value={recurrenceEndDateValue}
onChange={e => setRecurrenceEndDateValue(e.target.value)}
aria-label={language === 'de' ? 'Enddatum der Wiederholung' : 'Recurrence end date'}
style={{
fontSize: '0.8rem', border: 'none', borderRadius: '6px',
background: 'var(--weekly-bg-secondary, #f5f5f5)',
padding: '3px 6px', outline: 'none', color: 'var(--weekly-text)',
}}
/>
)}
{recurrenceEndType === 'count' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="number" min={1} max={999} value={recurrenceCount}
onChange={e => setRecurrenceCount(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Anzahl der Wiederholungen' : 'Number of occurrences'}
style={{
width: '44px', textAlign: 'center', fontSize: '0.8rem',
background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px',
padding: '3px 4px', border: 'none', outline: 'none', color: 'var(--weekly-text)',
}}
/>
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)' }}>
{language === 'de' ? 'mal' : 'times'}
</span>
</div>
)}
</div>
)}
{/* Location with autocomplete */}
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
<input
type="text"
value={location}
onChange={e => { setLocation(e.target.value); updateLocationSuggestions(e.target.value); }}
onFocus={() => { setLocationFocused(true); updateLocationSuggestions(location); }}
onBlur={() => setTimeout(() => setLocationSuggestions([]), 150)}
onKeyDown={e => { if (e.key === 'Escape') setLocationSuggestions([]); }}
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
aria-label={language === 'de' ? 'Ort' : 'Location'}
aria-autocomplete="list"
aria-expanded={locationSuggestions.length > 0}
style={{
...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', outline: 'none', fontSize: '0.8rem',
color: 'var(--weekly-text)',
}}
/>
{locationSuggestions.length > 0 && (
<div
role="listbox"
aria-label={language === 'de' ? 'Ortsvorschläge' : 'Location suggestions'}
style={{
position: 'absolute', top: '100%', left: '26px', right: 0,
zIndex: 200, background: 'var(--weekly-bg-popover, #fff)',
border: '1px solid var(--weekly-border, #e5e7eb)',
borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
overflow: 'hidden', marginTop: '2px',
}}
>
{locationSuggestions.map((suggestion, i) => (
<div
key={i}
role="option"
aria-selected={false}
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
style={{
padding: '6px 10px', fontSize: '0.78rem',
cursor: 'pointer', color: 'var(--weekly-text)',
display: 'flex', alignItems: 'center', gap: '6px',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--weekly-hover, rgba(0,0,0,0.05))')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span>
</div>
))}
</div>
)}
</div>
{/* Alert / Reminders */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
{reminders.length === 0 ? (
<button onClick={addReminder} style={{ ...inlineSelect, color: 'var(--weekly-text-light)', fontSize: '0.8rem', textAlign: 'left', padding: '2px 0' }}>
{language === 'de' ? 'Erinnerung hinzufügen' : 'Add reminder'}
</button>
) : (
reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
style={{ ...inlineSelect, flex: 1 }}
>
{buildReminderOptions().map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
<X size={12} aria-hidden="true" />
</button>
</div>
{/* Custom reminder editor row */}
{customEditorIdx === idx && (
<div style={{
display: 'flex', alignItems: 'center', gap: '4px',
paddingLeft: '0', background: 'var(--weekly-bg-secondary, #f3f4f6)',
borderRadius: '6px', padding: '4px 6px',
}}>
<input
type="number"
min={1}
max={9999}
value={customAmount}
onChange={e => setCustomAmount(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Erinnerungsmenge' : 'Reminder amount'}
style={{
width: '48px', textAlign: 'center', fontSize: '0.78rem',
background: 'var(--weekly-bg, #fff)', borderRadius: '5px',
padding: '2px 4px', border: '1px solid var(--weekly-border, #ddd)',
outline: 'none', color: 'var(--weekly-text)',
}}
/>
<select
value={reminderUnit}
onChange={e => setReminderUnit(e.target.value as any)}
aria-label={language === 'de' ? 'Erinnerungseinheit' : 'Reminder unit'}
style={{ ...inlineSelect, flex: 1, fontSize: '0.75rem' }}
>
<option value="minutes">{language === 'de' ? 'Minuten' : 'minutes'}</option>
<option value="hours">{language === 'de' ? 'Stunden' : 'hours'}</option>
<option value="days">{language === 'de' ? 'Tage' : 'days'}</option>
</select>
<span style={{ fontSize: '0.7rem', color: 'var(--weekly-text-light)', whiteSpace: 'nowrap' }}>
{language === 'de' ? 'vorher' : 'before'}
</span>
<button
onClick={() => confirmCustomReminder(idx)}
title={language === 'de' ? 'Bestätigen' : 'Confirm'}
style={{
background: '#3b82f6', border: 'none', borderRadius: '5px',
cursor: 'pointer', padding: '3px 7px', display: 'flex', alignItems: 'center',
color: '#fff', flexShrink: 0,
}}
>
<Check size={12} />
</button>
<button
onClick={() => setCustomEditorIdx(null)}
title={language === 'de' ? 'Abbrechen' : 'Cancel'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.6 }}
>
<X size={12} />
</button>
</div>
)}
</div>
))
)}
{reminders.length > 0 && reminders.length < 2 && (
<button onClick={addReminder} style={{ fontSize: '0.72rem', color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer', padding: '0', textAlign: 'left' }}>
+ {language === 'de' ? 'Weitere Erinnerung' : 'Add another'}
</button>
)}
</div>
</div>
{/* Status & Visibility chips */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Activity size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<select value={busyStatus} onChange={e => setBusyStatus(e.target.value)} aria-label={language === 'de' ? 'Status' : 'Busy status'} style={{ ...inlineSelect, fontSize: '0.75rem' }}>
{BUSY_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<span style={{ color: 'var(--weekly-border)', fontSize: '0.7rem' }} aria-hidden="true">|</span>
<select value={visibility} onChange={e => setVisibility(e.target.value)} aria-label={language === 'de' ? 'Sichtbarkeit' : 'Visibility'} style={{ ...inlineSelect, fontSize: '0.75rem' }}>
{VISIBILITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* URL (Conditional) */}
{supportsURL && (
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Link2 size={14} /></div>
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="URL"
aria-label="URL"
style={{
...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', fontSize: '0.8rem', outline: 'none',
color: 'var(--weekly-text)',
}}
/>
</div>
)}
{/* Invitees & Attachments toggle */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Users size={14} /></div>
<button
onClick={() => setShowMoreOptions(!showMoreOptions)}
aria-expanded={showMoreOptions}
aria-label={language === 'de' ? 'Teilnehmer & Anhänge anzeigen' : 'Show invitees & attachments'}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--weekly-text-light)', fontSize: '0.8rem', padding: '0',
}}
>
{language === 'de' ? 'Teilnehmer & Anhänge' : 'Invitees & Attachments'}
{showMoreOptions ? <ChevronUp size={12} aria-hidden="true" /> : <ChevronDown size={12} aria-hidden="true" />}
{attendees.length > 0 && <span style={{ fontSize: '0.7rem', background: 'rgba(59,130,246,0.1)', color: '#3b82f6', padding: '1px 5px', borderRadius: '8px' }}>{attendees.length}</span>}
</button>
</div>
{showMoreOptions && (
<div style={{ paddingLeft: '30px', display: 'flex', flexDirection: 'column', gap: '6px', paddingBottom: '4px' }}>
{/* Attendees */}
<div>
{attendees.map((att, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '3px 6px', marginBottom: '2px',
background: 'var(--weekly-bg-secondary, #f3f4f6)', borderRadius: '4px',
fontSize: '0.75rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.displayName ? `${att.displayName} (${att.email})` : att.email}
</span>
<button
onClick={() => removeAttendee(att.email)}
aria-label={`${language === 'de' ? 'Teilnehmer entfernen' : 'Remove attendee'}: ${att.displayName || att.email}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={12} aria-hidden="true" />
</button>
</div>
))}
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="email"
value={newAttendeeEmail}
onChange={e => setNewAttendeeEmail(e.target.value)}
placeholder={language === 'de' ? 'E-Mail-Adresse' : 'Email address'}
aria-label={language === 'de' ? 'Teilnehmer E-Mail' : 'Attendee email'}
onKeyDown={e => e.key === 'Enter' && addAttendee()}
style={{
flex: 1, padding: '3px 6px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.75rem', outline: 'none',
color: 'var(--weekly-text)',
}}
/>
<button
onClick={addAttendee}
aria-label={language === 'de' ? 'Teilnehmer hinzufügen' : 'Add attendee'}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#3b82f6', padding: '2px' }}>
<Plus size={14} aria-hidden="true" />
</button>
</div>
</div>
{/* Attachments */}
{supportsAttachments && (
<div>
<span style={{ fontSize: '0.72rem', color: 'var(--weekly-text-light)', display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
<Paperclip size={11} /> {language === 'de' ? 'Anhänge' : 'Attachments'}
</span>
{attachments.map((att, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '3px 6px', marginBottom: '2px',
background: 'var(--weekly-bg-secondary, #f3f4f6)', borderRadius: '4px',
fontSize: '0.75rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.title || att.url}
</span>
<button
onClick={() => removeAttachment(att.url)}
aria-label={`${language === 'de' ? 'Anhang entfernen' : 'Remove attachment'}: ${att.title || att.url}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={12} aria-hidden="true" />
</button>
</div>
))}
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="url"
value={newAttachmentUrl}
onChange={e => setNewAttachmentUrl(e.target.value)}
placeholder="URL"
aria-label={language === 'de' ? 'Anhang URL' : 'Attachment URL'}
onKeyDown={e => e.key === 'Enter' && addAttachment()}
style={{
flex: 1, padding: '3px 6px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.75rem', outline: 'none',
color: 'var(--weekly-text)',
}}
/>
<button
onClick={addAttachment}
aria-label={language === 'de' ? 'Anhang hinzufügen' : 'Add attachment'}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#3b82f6', padding: '2px' }}>
<Plus size={14} aria-hidden="true" />
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Divider */}
<div style={{ height: '1px', background: 'var(--weekly-border, #eee)', margin: '0 14px' }} />
{/* Notes */}
<div style={{ padding: '4px 14px 6px' }}>
<Suspense fallback={
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={language === 'de' ? 'Notizen' : 'Notes'}
aria-label={language === 'de' ? 'Notizen' : 'Notes'}
rows={2}
style={{ width: '100%', padding: '3px 0', border: 'none', background: 'transparent', resize: 'none', fontSize: '0.78rem', outline: 'none', color: 'var(--weekly-text)' }}
/>
}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder={language === 'de' ? 'Notizen' : 'Notes'}
minHeight="36px"
/>
</Suspense>
</div>
{/* End scrollable body */}
</div>
{/* Actions bar — pinned at bottom */}
<div style={{
padding: '6px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderTop: '1px solid var(--weekly-border)',
background: 'var(--weekly-bg-secondary, #fafafa)',
borderRadius: '0 0 12px 12px',
flexShrink: 0,
}}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{event && onDelete && !showRecurringDeleteOptions && (
<button
onClick={() => handleDelete()}
disabled={isSaving || isDeleting}
style={{
padding: '4px 8px', background: 'none', color: '#ef4444',
border: 'none', borderRadius: '6px', fontSize: '0.78rem',
fontWeight: 500, cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.5 : 1
}}
>
{isDeleting ? '...' : isDeleteConfirming ? (language === 'de' ? 'Bestätigen?' : 'Confirm?') : (language === 'de' ? 'Löschen' : 'Delete')}
</button>
)}
{event && onDelete && showRecurringDeleteOptions && !isDeleting && (
<button
onClick={() => setShowRecurringDeleteOptions(false)}
style={{
padding: '4px 8px', background: 'none', color: 'var(--weekly-text-light)',
border: 'none', fontSize: '0.78rem', cursor: 'pointer',
}}
>
{language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
)}
{event && onDelete && isDeleting && (
<span style={{ fontSize: '0.8rem', color: '#ef4444', fontWeight: 500, padding: '5px 10px' }}>
{language === 'de' ? 'Lösche' : 'Deleting'}<AnimatedDots />
</span>
)}
{!event && (
<button onClick={onClose} style={{ padding: '4px 8px', fontSize: '0.78rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
{language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
)}
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{event && (
<button onClick={onClose} style={{ padding: '4px 8px', fontSize: '0.78rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
{language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
)}
<button
className="weekly-btn-primary"
onClick={() => handleSubmit()}
disabled={isSaving || isDeleting}
style={{
padding: '5px 14px', borderRadius: '7px', fontSize: '0.8rem',
fontWeight: 600, backgroundColor: calColor, color: 'white',
border: 'none', cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.7 : 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
{isSaving ? <>{language === 'de' ? 'Erstelle' : 'Creating'}<AnimatedDots /></> : (language === 'de' ? 'Speichern' : 'Save')}
</button>
</div>
</div>
</div>
{/* Recurring edit mode overlay */}
{showRecurringEditOptions && (
<div style={{
position: 'fixed', inset: 0, zIndex: 3000,
display: 'flex', justifyContent: 'center', alignItems: 'center',
background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(2px)',
}} onClick={() => setShowRecurringEditOptions(false)}>
<div style={{
background: 'var(--weekly-bg, white)', color: 'var(--weekly-text, #333)',
borderRadius: 12, width: '90%', maxWidth: 380,
boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
overflow: 'hidden',
}} onClick={e => e.stopPropagation()}>
<div style={{
padding: '14px 16px 10px', fontWeight: 700, fontSize: '0.9rem',
borderBottom: `2px solid ${calColor}`,
}}>
{language === 'de' ? 'Wiederkehrendes Ereignis bearbeiten' : 'Edit recurring event'}
<button onClick={() => setShowRecurringEditOptions(false)} style={{
float: 'right', background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--weekly-text-light)', padding: 0,
}}><X size={16} /></button>
</div>
<div style={{ padding: '8px 16px' }}>
{([
{ value: 'this' as const, title: language === 'de' ? 'Nur dieses Ereignis' : 'This event', desc: language === 'de' ? 'Alle anderen bleiben unverändert.' : 'All other events stay the same.' },
{ value: 'future' as const, title: language === 'de' ? 'Dieses und folgende' : 'This and following', desc: language === 'de' ? 'Dieses und alle zukünftigen werden geändert.' : 'This and all future events will be changed.' },
{ value: 'all' as const, title: language === 'de' ? 'Alle Ereignisse' : 'All events', desc: language === 'de' ? 'Alle Ereignisse der Serie werden geändert.' : 'All events in the series will be changed.' },
]).map(opt => (
<label key={opt.value} style={{
display: 'flex', gap: 10, padding: '8px 2px', cursor: 'pointer',
borderBottom: '1px solid var(--weekly-border, #eee)',
alignItems: 'flex-start',
}} onClick={() => setRecurringEditMode(opt.value)}>
<input
type="radio" name="recurringEditMode"
checked={recurringEditMode === opt.value}
onChange={() => setRecurringEditMode(opt.value)}
style={{ marginTop: 3, accentColor: calColor }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: '0.82rem' }}>{opt.title}</div>
<div style={{ fontSize: '0.72rem', opacity: 0.5, marginTop: 1 }}>{opt.desc}</div>
</div>
</label>
))}
</div>
<div style={{
padding: '10px 16px', display: 'flex', justifyContent: 'flex-end', gap: 8,
background: 'var(--weekly-bg-secondary, #fafafa)',
}}>
<button onClick={() => setShowRecurringEditOptions(false)} style={{
padding: '6px 14px', fontSize: '0.82rem', fontWeight: 600,
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--weekly-text-light)',
}}>{language === 'de' ? 'Abbrechen' : 'Cancel'}</button>
<button onClick={() => handleSubmit(recurringEditMode)} disabled={isSaving} style={{
padding: '6px 18px', fontSize: '0.82rem', fontWeight: 600,
background: calColor, color: 'white', border: 'none',
borderRadius: 8, cursor: 'pointer',
opacity: isSaving ? 0.6 : 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}>{isSaving ? <>{language === 'de' ? 'Erstelle' : 'Creating'}<AnimatedDots /></> : (language === 'de' ? 'Speichern' : 'Save')}</button>
</div>
</div>
</div>
)}
{/* Recurring delete mode overlay */}
{showRecurringDeleteOptions && (
<div style={{
position: 'fixed', inset: 0, zIndex: 3000,
display: 'flex', justifyContent: 'center', alignItems: 'center',
background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(2px)',
}} onClick={() => !isDeleting && setShowRecurringDeleteOptions(false)}>
<div style={{
background: 'var(--weekly-bg, white)', color: 'var(--weekly-text, #333)',
borderRadius: 12, width: '90%', maxWidth: 340,
boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
overflow: 'hidden',
}} onClick={e => e.stopPropagation()}>
<div style={{
padding: '14px 16px 10px', fontWeight: 700, fontSize: '0.9rem',
borderBottom: '2px solid #ef4444',
}}>
{language === 'de' ? 'Wiederkehrendes Ereignis löschen' : 'Delete recurring event'}
{!isDeleting && <button onClick={() => setShowRecurringDeleteOptions(false)} style={{
float: 'right', background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--weekly-text-light)', padding: 0,
}}><X size={16} /></button>}
</div>
<div style={{ padding: '4px 0' }}>
{([
{ value: 'this' as const, title: language === 'de' ? 'Nur dieses Ereignis' : 'This event only', desc: language === 'de' ? 'Alle anderen bleiben erhalten.' : 'All other events stay the same.' },
{ value: 'future' as const, title: language === 'de' ? 'Dieses und folgende' : 'This and following', desc: language === 'de' ? 'Dieses und alle zukünftigen werden gelöscht.' : 'This and all future events will be deleted.' },
{ value: 'all' as const, title: language === 'de' ? 'Alle Ereignisse' : 'All events', desc: language === 'de' ? 'Die gesamte Serie wird gelöscht.' : 'The entire series will be deleted.' },
]).map(opt => (
<button
key={opt.value}
onClick={() => handleDelete(opt.value)}
disabled={isDeleting}
style={{
display: 'flex', gap: 10, padding: '10px 16px', cursor: 'pointer',
width: '100%', textAlign: 'left', background: 'none',
border: 'none', borderBottom: '1px solid var(--weekly-border, #eee)',
color: 'var(--weekly-text, #333)',
opacity: isDeleting && deletingMode !== opt.value ? 0.4 : 1,
alignItems: 'center',
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: '0.82rem', color: '#ef4444' }}>{opt.title}</div>
<div style={{ fontSize: '0.72rem', opacity: 0.5, marginTop: 1 }}>{opt.desc}</div>
</div>
{isDeleting && deletingMode === opt.value && (
<span style={{ fontSize: '0.8rem', color: '#ef4444', fontWeight: 600 }}>
<AnimatedDots />
</span>
)}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}