My-Weekly-ToDo-List/src/components/CalendarEventModal.tsx

346 lines
16 KiB
TypeScript

import React, { useState, useEffect, lazy, Suspense } from 'react';
const RichTextEditor = lazy(() => import('./RichTextEditor'));
interface CalendarEventModalProps {
event?: any; // Existing event if editing
initialDate?: Date; // If creating new
initialStartTime?: string; // If creating new from slot
connections: any[]; // To select calendar
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) {
// Flatten calendars from connections to get selectable options
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); // Only editable calendars
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 [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
// Date/Time State
// If event exists, use its start/end.
// If new, use initialDate + initialStartTime.
// Default duration: 1 hour.
const getInitialStart = () => {
// Support both nested (start.dateTime) and flat (startTime) event formats
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 {
// Default to next hour if no time specified (though usually slot click gives time)
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
}
return d;
}
return new Date();
};
const getInitialEnd = () => {
// Support both nested (end.dateTime) and flat (endTime) event formats
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); // +1 hour
};
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 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 {
await onSave({
id: event?.id,
title,
description,
location,
url: url || undefined,
recurrence: recurrence !== 'none' ? recurrence : undefined,
calendarId,
allDay,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() }
});
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); // Reset after 3 seconds
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);
}
};
// Helper to format date for input type="datetime-local"
// Format: YYYY-MM-DDThh:mm
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);
// Auto-adjust end date if it becomes before start
if (endDate <= newStart) {
setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000));
}
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '500px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>
{event ? 'Edit Event' : 'New Event'}
</h3>
{error && <div style={{ color: 'red', marginBottom: '1rem' }}>{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{/* Title */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Title</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Event Title"
autoFocus
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem' }}
/>
</div>
{/* Calendar Selection */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Calendar</label>
<select
value={calendarId}
onChange={e => setCalendarId(e.target.value)}
disabled={!!event} // Usually can't move events between calendars easily in basic implementation
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
{availableCalendars.length === 0 && <option value="">No editable calendars</option>}
{availableCalendars.map((cal: any) => (
<option key={cal.id} value={cal.id}>
{cal.summary || cal.title} ({cal.providerName})
</option>
))}
</select>
</div>
{/* All Day Toggle */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<input
type="checkbox"
checked={allDay}
onChange={e => setAllDay(e.target.checked)}
/>
All Day
</label>
</div>
{/* Date/Time */}
<div style={{ display: 'flex', gap: '15px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Start</label>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>End</label>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
</div>
{/* Recurrence */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Repeat</label>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value)}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="none">Never</option>
<option value="daily">Every Day</option>
<option value="weekly">Every Week</option>
<option value="biweekly">Every 2 Weeks</option>
<option value="monthly">Every Month</option>
<option value="yearly">Every Year</option>
</select>
</div>
{/* Location */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Location</label>
<input
type="text"
value={location}
onChange={e => setLocation(e.target.value)}
placeholder="Location (optional)"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{/* URL */}
{(() => {
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
const isUrlDisabled = selectedCal && (selectedCal.provider === 'google' || selectedCal.provider === 'outlook');
return (
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>URL</label>
<input
type="url"
value={isUrlDisabled ? '' : url}
onChange={e => setUrl(e.target.value)}
placeholder={isUrlDisabled ? 'Not supported by this calendar provider' : 'https://...'}
disabled={!!isUrlDisabled}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
opacity: isUrlDisabled ? 0.5 : 1,
background: isUrlDisabled ? '#f5f5f5' : undefined,
cursor: isUrlDisabled ? 'not-allowed' : undefined,
}}
/>
</div>
);
})()}
{/* Description - Rich text */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Notes</label>
<Suspense fallback={
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Notes..."
rows={3}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', resize: 'vertical' }}
/>
}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Notes..."
minHeight="120px"
/>
</Suspense>
</div>
</div>
<div className="weekly-modal-actions" style={{ marginTop: '2rem', display: 'flex', justifyContent: 'space-between' }}>
<div>
{event && onDelete && (
<button
onClick={handleDelete}
disabled={isSaving || isDeleting}
style={{
padding: '10px 20px',
background: isDeleteConfirming ? '#d32f2f' : 'transparent',
color: isDeleteConfirming ? 'white' : '#d32f2f',
border: '1px solid #d32f2f',
borderRadius: '4px',
fontSize: '1rem',
cursor: 'pointer',
transition: 'all 0.2s',
width: isDeleteConfirming ? 'auto' : 'initial' // Expand if needed
}}
>
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
</button>
)}
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="weekly-btn weekly-btn-secondary" onClick={onClose} disabled={isSaving || isDeleting}>Cancel</button>
<button className="weekly-btn weekly-btn-primary" onClick={handleSubmit} disabled={isSaving || isDeleting}>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
);
}