346 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|