My-Weekly-ToDo-List/src/components/CalendarEventModal.tsx
mARTin 1011aef639 fix: recurring event DST time shift and block height overflow
- Use TZID-based local time (instead of UTC 'Z') for DTSTART/DTEND
  when creating recurring CalDAV events, preventing DST-related time
  shifts (e.g. 13:40 CET showing as 14:40 CEST after clock change)
- Send browser timezone from CalendarEventModal to server
- Guard against NaN event duration (missing/invalid endTime) which
  caused event blocks to stretch to end of day

v1.61.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:23:24 +01:00

815 lines
44 KiB
TypeScript

import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip } 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'));
// Reminder preset options (minutes)
const REMINDER_OPTIONS = [
{ 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 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;
connections: any[];
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
}
export default function CalendarEventModal({
event,
initialDate,
initialStartTime,
connections,
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);
// Custom recurrence fields
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 : ''));
// New fields
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);
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 [error, setError] = useState('');
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
const calendarSelectorRef = useRef<HTMLDivElement>(null);
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');
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 handleSubmit = async () => {
if (!title.trim()) {
setError('Title is required');
return;
}
if (!calendarId) {
setError('Please select a calendar');
return;
}
if (endDate <= startDate) {
setError('End time must be after start time');
return;
}
setIsSaving(true);
setError('');
try {
const activeReminders = reminders.filter(r => r.minutes >= 0);
await onSave({
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 !== 'busy' ? busyStatus : undefined,
visibility: visibility !== 'default' ? visibility : undefined,
attendees: attendees.length > 0 ? attendees : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
});
onClose();
} catch (err: any) {
console.error(err);
setError(err.message || 'Failed to save event');
setIsSaving(false);
}
};
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
const handleDelete = async () => {
if (!event?.id || !onDelete) return;
if (!isDeleteConfirming) {
setIsDeleteConfirming(true);
setTimeout(() => setIsDeleteConfirming(false), 3000);
return;
}
setIsDeleting(true);
try {
await onDelete(event.id, event.calendarId);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to delete event');
setIsDeleting(false);
setIsDeleteConfirming(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));
return;
}
const updated = [...reminders];
updated[index] = { ...updated[index], minutes };
setReminders(updated);
};
const addReminder = () => {
setReminders([...reminders, { method: 'display', minutes: 15 }]);
};
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 10px', minHeight: '32px',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.82rem', color: 'var(--weekly-text-light)', fontWeight: 500,
};
const selectStyle: React.CSSProperties = {
background: 'transparent', border: 'none', textAlign: 'right' as const,
fontSize: '0.82rem', cursor: 'pointer', outline: 'none', color: 'var(--weekly-text)',
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{
maxWidth: '400px',
width: 'calc(100vw - 16px)',
padding: '12px',
borderRadius: '10px',
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
border: '1px solid var(--weekly-border)',
maxHeight: '85vh',
overflowY: 'auto',
}}>
{error && <div style={{ color: '#ef4444', marginBottom: '6px', fontSize: '0.8rem', textAlign: 'center' }}>{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* Title */}
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="New Event"
autoFocus
style={{
width: '100%', padding: '6px 10px', fontSize: '1.05rem', fontWeight: 600,
border: 'none', borderBottom: '1px solid var(--weekly-border)',
borderRadius: '0', background: 'transparent', outline: 'none',
}}
/>
{/* Location */}
<input
type="text"
value={location}
onChange={e => setLocation(e.target.value)}
placeholder="Location or Video Call"
style={{
width: '100%', padding: '5px 10px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
borderRadius: '0', background: 'transparent', outline: 'none', fontSize: '0.82rem',
}}
/>
{/* Calendar row */}
<div style={{ ...rowStyle, position: 'relative' }}>
<span style={labelStyle}>Calendar</span>
<div ref={calendarSelectorRef} style={{ position: 'relative', flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={!!event}
style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'transparent', border: 'none', fontWeight: 500,
cursor: event ? 'default' : 'pointer', outline: 'none',
color: 'var(--weekly-text)', fontSize: '0.82rem',
padding: '2px 0', maxWidth: '180px', justifyContent: 'flex-end'
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedCal?.summary || selectedCal?.title || 'Select Calendar'}
</span>
<div style={{
width: '10px', height: '10px', borderRadius: '50%', flexShrink: 0,
backgroundColor: selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6'
}} />
{!event && (isCalendarSelectorOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />)}
</button>
{isCalendarSelectorOpen && (
<div style={{
position: 'absolute', top: '100%', right: 0, zIndex: 100,
minWidth: '200px', backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
borderRadius: '8px', boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
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 10px', borderRadius: '5px', cursor: 'pointer',
fontSize: '0.82rem', color: 'var(--weekly-text)',
backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent',
transition: 'background 0.2s', textAlign: 'left'
}}
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.6 }}>
{getProviderIcon(cal.provider)}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* All Day */}
<div style={rowStyle}>
<span style={labelStyle}>All Day</span>
<input
type="checkbox"
checked={allDay}
onChange={e => setAllDay(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
</div>
{/* Start / End on same row when not all-day */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', padding: '0 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '30px' }}>
<span style={labelStyle}>Starts</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)}
style={{
padding: '2px 6px', border: 'none', borderRadius: '4px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.8rem', textAlign: 'center',
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '30px' }}>
<span style={labelStyle}>Ends</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))}
style={{
padding: '2px 6px', border: 'none', borderRadius: '4px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.8rem', textAlign: 'center',
}}
/>
</div>
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--weekly-border)', margin: '2px 10px' }} />
{/* Repeat */}
<div style={rowStyle}>
<span style={labelStyle}>Repeat</span>
<select value={recurrence} onChange={e => setRecurrence(e.target.value)} style={selectStyle}>
<option value="none">Never</option>
<option value="daily">Every Day</option>
<option value="weekly">Every Week</option>
<option value="monthly">Every Month</option>
<option value="yearly">Every Year</option>
<option value="custom">Custom...</option>
</select>
</div>
{/* Custom recurrence options */}
{recurrence === 'custom' && (
<>
<div style={{ ...rowStyle, gap: '6px' }}>
<span style={labelStyle}>Every</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="number" min={1} max={99} value={customInterval}
onChange={e => setCustomInterval(Math.max(1, parseInt(e.target.value) || 1))}
style={{
...selectStyle, width: '45px', textAlign: 'center',
background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px', padding: '4px 6px',
}}
/>
<select value={customUnit} onChange={e => setCustomUnit(e.target.value as any)} style={selectStyle}>
<option value="days">{customInterval === 1 ? 'Day' : 'Days'}</option>
<option value="weeks">{customInterval === 1 ? 'Week' : 'Weeks'}</option>
<option value="months">{customInterval === 1 ? 'Month' : 'Months'}</option>
<option value="years">{customInterval === 1 ? 'Year' : 'Years'}</option>
</select>
</div>
</div>
{customUnit === 'weeks' && (
<div style={{ ...rowStyle, flexDirection: 'column', alignItems: 'flex-start', gap: '6px' }}>
<span style={labelStyle}>Repeat on</span>
<div style={{ display: 'flex', gap: '4px', paddingLeft: '0' }}>
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<button
key={i}
onClick={() => {
setCustomDays(prev =>
prev.includes(i) ? (prev.length > 1 ? prev.filter(d => d !== i) : prev) : [...prev, i]
);
}}
style={{
width: '30px', height: '30px', borderRadius: '50%',
border: customDays.includes(i) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)',
backgroundColor: customDays.includes(i) ? '#3b82f6' : 'transparent',
color: customDays.includes(i) ? 'white' : 'var(--weekly-text)',
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{day}
</button>
))}
</div>
</div>
)}
</>
)}
{/* Recurrence End - only shown when repeat is set */}
{recurrence !== 'none' && (
<>
<div style={rowStyle}>
<span style={labelStyle}>End</span>
<select
value={recurrenceEndType}
onChange={e => setRecurrenceEndType(e.target.value as 'never' | 'date' | 'count')}
style={selectStyle}
>
<option value="never">Never</option>
<option value="date">On Date</option>
<option value="count">After...</option>
</select>
</div>
{recurrenceEndType === 'date' && (
<div style={rowStyle}>
<span style={labelStyle}></span>
<input
type="date"
value={recurrenceEndDateValue}
onChange={e => setRecurrenceEndDateValue(e.target.value)}
style={{
...selectStyle,
background: 'var(--weekly-bg-secondary, #f5f5f5)',
borderRadius: '6px',
padding: '4px 8px',
}}
/>
</div>
)}
{recurrenceEndType === 'count' && (
<div style={rowStyle}>
<span style={labelStyle}></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="number"
min={1}
max={999}
value={recurrenceCount}
onChange={e => setRecurrenceCount(Math.max(1, parseInt(e.target.value) || 1))}
style={{
...selectStyle,
width: '50px',
background: 'var(--weekly-bg-secondary, #f5f5f5)',
borderRadius: '6px',
padding: '4px 8px',
textAlign: 'center',
}}
/>
<span style={{ fontSize: '0.82rem', color: 'var(--weekly-text)' }}>times</span>
</div>
</div>
)}
</>
)}
{/* Alert */}
<div style={{ padding: '0 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '32px' }}>
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '4px' }}>
<Bell size={12} /> Alert
</span>
{reminders.length === 0 && (
<button onClick={addReminder} style={{ ...selectStyle, color: '#3b82f6', cursor: 'pointer', background: 'none', border: 'none' }}>
Add
</button>
)}
</div>
{reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px', marginBottom: '2px' }}>
<select
value={reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
style={selectStyle}
>
{REMINDER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)' }}>
<X size={12} />
</button>
</div>
))}
{reminders.length > 0 && reminders.length < 2 && (
<button onClick={addReminder} style={{ fontSize: '0.78rem', color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer', padding: '1px 0' }}>
+ Add another alert
</button>
)}
</div>
{/* Status & Visibility side by side */}
<div style={{ display: 'flex', gap: '8px', padding: '0 10px' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '32px' }}>
<span style={labelStyle}>Status</span>
<select value={busyStatus} onChange={e => setBusyStatus(e.target.value)} style={selectStyle}>
{BUSY_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div style={{ width: '1px', background: 'var(--weekly-border)', margin: '4px 0' }} />
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '32px' }}>
<span style={labelStyle}>Visibility</span>
<select value={visibility} onChange={e => setVisibility(e.target.value)} style={selectStyle}>
{VISIBILITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* URL (Conditional) */}
{supportsURL && (
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="URL"
style={{
width: '100%', padding: '4px 10px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.82rem', outline: 'none'
}}
/>
)}
{/* Expandable: Invitees & Attachments */}
<button
onClick={() => setShowMoreOptions(!showMoreOptions)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
background: 'none', border: 'none', cursor: 'pointer',
color: '#3b82f6', fontSize: '0.8rem', padding: '2px 10px',
}}
>
{showMoreOptions ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
Invitees & Attachments
</button>
{showMoreOptions && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '0 10px' }}>
{/* Attendees */}
<div>
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' }}>
<Users size={12} /> Invitees
</span>
{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.78rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.displayName ? `${att.displayName} (${att.email})` : att.email}
</span>
<button onClick={() => removeAttendee(att.email)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={12} />
</button>
</div>
))}
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="email"
value={newAttendeeEmail}
onChange={e => setNewAttendeeEmail(e.target.value)}
placeholder="Add email address"
onKeyDown={e => e.key === 'Enter' && addAttendee()}
style={{
flex: 1, padding: '4px 6px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.78rem', outline: 'none',
}}
/>
<button onClick={addAttendee} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#3b82f6', padding: '2px',
}}>
<Plus size={14} />
</button>
</div>
</div>
{/* Attachments (Apple/Synology only) */}
{supportsAttachments && (
<div>
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' }}>
<Paperclip size={12} /> 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.78rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.title || att.url}
</span>
<button onClick={() => removeAttachment(att.url)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={12} />
</button>
</div>
))}
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="url"
value={newAttachmentUrl}
onChange={e => setNewAttachmentUrl(e.target.value)}
placeholder="Add attachment URL"
onKeyDown={e => e.key === 'Enter' && addAttachment()}
style={{
flex: 1, padding: '4px 6px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.78rem', outline: 'none',
}}
/>
<button onClick={addAttachment} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#3b82f6', padding: '2px',
}}>
<Plus size={14} />
</button>
</div>
</div>
)}
</div>
)}
{/* Notes */}
<div style={{ padding: '0 10px' }}>
<Suspense fallback={
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Notes"
rows={2}
style={{ width: '100%', padding: '4px 0', border: 'none', borderBottom: '1px solid var(--weekly-border)', background: 'transparent', resize: 'none', fontSize: '0.82rem', outline: 'none' }}
/>
}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Notes"
minHeight="60px"
/>
</Suspense>
</div>
</div>
{/* Actions */}
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid var(--weekly-border)', paddingTop: '10px' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{event && onDelete && (
<button
onClick={handleDelete}
disabled={isSaving || isDeleting}
style={{
padding: '6px 10px', background: 'none', color: '#ef4444',
border: 'none', borderRadius: '6px', fontSize: '0.82rem',
fontWeight: 500, cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.5 : 1
}}
>
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
</button>
)}
{!event && (
<button onClick={onClose} style={{ padding: '6px 10px', fontSize: '0.82rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
Cancel
</button>
)}
</div>
<div style={{ display: 'flex', gap: '6px' }}>
{event && (
<button onClick={onClose} style={{ padding: '6px 10px', fontSize: '0.82rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
Cancel
</button>
)}
<button
className="weekly-btn-primary"
onClick={handleSubmit}
disabled={isSaving || isDeleting}
style={{
padding: '6px 16px', borderRadius: '6px', fontSize: '0.85rem',
fontWeight: 600, backgroundColor: '#3b82f6', color: 'white',
border: 'none', cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.7 : 1
}}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
);
}