diff --git a/package.json b/package.json index 2fdcf27..acab7a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.35.3", + "version": "1.36.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts index 5eefb1c..40bb73d 100644 --- a/src/app/api/calendar/events/route.ts +++ b/src/app/api/calendar/events/route.ts @@ -39,7 +39,8 @@ export async function POST(request: NextRequest) { if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); - const { calendarId, title, description, start, end, location, allDay, recurrence, url } = body; + const { calendarId, title, description, start, end, location, allDay, recurrence, url, + reminders, busyStatus, visibility, attendees, attachments } = body; console.log('[API] Creating event:', { calendarId, title, start, end }); @@ -63,6 +64,11 @@ export async function POST(request: NextRequest) { allDay: !!allDay, recurrence, url, + reminders, + busyStatus, + visibility, + attendees, + attachments, } as any); // Update cache - await to ensure it's ready before client refreshes @@ -86,7 +92,8 @@ export async function PATCH(request: NextRequest) { if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); - const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url } = body; + const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url, + reminders, busyStatus, visibility, attendees, attachments } = body; console.log('[API] Updating event:', { calendarId, eventId, title }); @@ -110,6 +117,11 @@ export async function PATCH(request: NextRequest) { allDay: allDay !== undefined ? !!allDay : undefined, recurrence, url, + reminders, + busyStatus, + visibility, + attendees, + attachments, } as any); // Update cache - await to ensure it's ready before client refreshes diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index fff8d31..3652d8f 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -1,16 +1,45 @@ import React, { useState, useEffect, lazy, Suspense, useRef } from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-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; // Existing event if editing - initialDate?: Date; // If creating new - initialStartTime?: string; // If creating new from slot - connections: any[]; // To select calendar + event?: any; + initialDate?: Date; + initialStartTime?: string; + connections: any[]; onClose: () => void; onSave: (eventData: any) => Promise; onDelete?: (eventId: string, calendarId: string) => Promise; @@ -25,7 +54,6 @@ export default function CalendarEventModal({ onSave, onDelete }: CalendarEventModalProps) { - // Flatten calendars from connections to get selectable options const availableCalendars = connections .flatMap(conn => (conn.calendars || []).map((cal: any) => ({ ...cal, @@ -35,7 +63,7 @@ export default function CalendarEventModal({ conn.provider === 'synology' ? 'Synology Calendar' : 'Outlook Calendar' }))) - .filter((cal: any) => cal.editable); // Only editable calendars + .filter((cal: any) => cal.editable); const [title, setTitle] = useState(event?.title || ''); const [description, setDescription] = useState(event?.description || ''); @@ -44,13 +72,26 @@ export default function CalendarEventModal({ 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. + // New fields + const [reminders, setReminders] = useState>( + event?.reminders || [{ method: 'display', minutes: 15 }] + ); + const [busyStatus, setBusyStatus] = useState(event?.busyStatus || 'busy'); + const [visibility, setVisibility] = useState(event?.visibility || 'default'); + const [attendees, setAttendees] = useState>( + event?.attendees || [] + ); + const [newAttendeeEmail, setNewAttendeeEmail] = useState(''); + const [attachments, setAttachments] = useState>( + event?.attachments || [] + ); + const [newAttachmentUrl, setNewAttachmentUrl] = useState(''); + const [showMoreOptions, setShowMoreOptions] = useState( + // Auto-expand if event has these fields set + !!(event?.attendees?.length || event?.attachments?.length || (event?.busyStatus && event.busyStatus !== 'busy') || (event?.visibility && event.visibility !== 'default')) + ); 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) { @@ -59,7 +100,6 @@ export default function CalendarEventModal({ 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); } @@ -69,11 +109,10 @@ export default function CalendarEventModal({ }; 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 + return new Date(start.getTime() + 60 * 60 * 1000); }; const [startDate, setStartDate] = useState(getInitialStart()); @@ -85,7 +124,6 @@ export default function CalendarEventModal({ const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false); const calendarSelectorRef = useRef(null); - // Close dropdown on outside click useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (calendarSelectorRef.current && !calendarSelectorRef.current.contains(event.target as Node)) { @@ -98,6 +136,8 @@ export default function CalendarEventModal({ 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 ; @@ -125,6 +165,9 @@ export default function CalendarEventModal({ setIsSaving(true); setError(''); try { + // Filter out "none" reminders + const activeReminders = reminders.filter(r => r.minutes >= 0); + await onSave({ id: event?.id, title, @@ -135,7 +178,12 @@ export default function CalendarEventModal({ calendarId, allDay, start: { dateTime: startDate.toISOString() }, - end: { dateTime: endDate.toISOString() } + end: { dateTime: endDate.toISOString() }, + 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) { @@ -152,7 +200,7 @@ export default function CalendarEventModal({ if (!isDeleteConfirming) { setIsDeleteConfirming(true); - setTimeout(() => setIsDeleteConfirming(false), 3000); // Reset after 3 seconds + setTimeout(() => setIsDeleteConfirming(false), 3000); return; } @@ -167,8 +215,6 @@ export default function CalendarEventModal({ } }; - // 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); @@ -178,21 +224,71 @@ export default function CalendarEventModal({ 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)); } }; + 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) { + // Remove this reminder + 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', + }; + const labelStyle: React.CSSProperties = { + fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500, + }; + const selectStyle: React.CSSProperties = { + background: 'transparent', border: 'none', textAlign: 'right' as const, + fontSize: '0.9rem', cursor: 'pointer', outline: 'none', color: 'var(--weekly-text)', + }; return (
-
e.stopPropagation()} style={{ - maxWidth: '450px', - padding: '20px', +
e.stopPropagation()} style={{ + maxWidth: '450px', + padding: '20px', borderRadius: '12px', boxShadow: '0 10px 25px rgba(0,0,0,0.15)', - border: '1px solid var(--weekly-border)' + border: '1px solid var(--weekly-border)', + maxHeight: '90vh', + overflowY: 'auto', }}> {error &&
{error}
} @@ -204,12 +300,12 @@ export default function CalendarEventModal({ onChange={e => setTitle(e.target.value)} placeholder="New Event" autoFocus - style={{ - width: '100%', - padding: '12px 16px', - fontSize: '1.25rem', + style={{ + width: '100%', + padding: '12px 16px', + fontSize: '1.25rem', fontWeight: 600, - border: 'none', + border: 'none', borderBottom: '1px solid var(--weekly-border)', borderRadius: '0', background: 'transparent', @@ -224,10 +320,10 @@ export default function CalendarEventModal({ value={location} onChange={e => setLocation(e.target.value)} placeholder="Location or Video Call" - style={{ - width: '100%', - padding: '8px 16px', - border: 'none', + style={{ + width: '100%', + padding: '8px 16px', + border: 'none', borderBottom: '1px solid var(--weekly-border)', borderRadius: '0', background: 'transparent', @@ -236,78 +332,51 @@ export default function CalendarEventModal({ }} /> - {/* Calendar row: Label + Custom Selector */} -
- Calendar - + {/* Calendar row */} +
+ Calendar +
{isCalendarSelectorOpen && (
{availableCalendars.map((cal: any) => (
{ - setCalendarId(cal.id); - setIsCalendarSelectorOpen(false); - }} + onClick={() => { setCalendarId(cal.id); setIsCalendarSelectorOpen(false); }} style={{ - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '8px 12px', - borderRadius: '6px', - cursor: 'pointer', - fontSize: '0.9rem', - color: 'var(--weekly-text)', + display: 'flex', alignItems: 'center', gap: '10px', + padding: '8px 12px', borderRadius: '6px', cursor: 'pointer', + fontSize: '0.9rem', color: 'var(--weekly-text)', backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent', - transition: 'background 0.2s', - textAlign: 'left' + 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'} @@ -327,8 +396,8 @@ export default function CalendarEventModal({
{/* All Day row */} -
- All Day +
+ All Day -
- Starts +
+ Starts handleStartDateChange(e.target.value)} - style={{ - padding: '4px 8px', - border: 'none', - borderRadius: '6px', + style={{ + padding: '4px 8px', border: 'none', borderRadius: '6px', background: 'var(--weekly-bg-secondary, #f3f4f6)', - fontSize: '0.9rem', - textAlign: 'center' + fontSize: '0.9rem', textAlign: 'center' }} />
-
- Ends +
+ Ends setEndDate(new Date(e.target.value))} - style={{ - padding: '4px 8px', - border: 'none', - borderRadius: '6px', + style={{ + padding: '4px 8px', border: 'none', borderRadius: '6px', background: 'var(--weekly-bg-secondary, #f3f4f6)', - fontSize: '0.9rem', - textAlign: 'center' + fontSize: '0.9rem', textAlign: 'center' }} />
@@ -376,21 +439,9 @@ export default function CalendarEventModal({ {/* Meta Fields Group */}
{/* Recurrence */} -
- Repeat - setRecurrence(e.target.value)} style={selectStyle}> @@ -399,6 +450,62 @@ export default function CalendarEventModal({
+ {/* Reminders */} +
+
0 ? '6px' : 0 }}> + + Alert + + {reminders.length === 0 && ( + + )} +
+ {reminders.map((reminder, idx) => ( +
+ + +
+ ))} + {reminders.length > 0 && reminders.length < 2 && ( + + )} +
+ + {/* Busy Status */} +
+ Status + +
+ + {/* Visibility */} +
+ Visibility + +
+ {/* URL (Conditional) */} {supportsURL && ( setUrl(e.target.value)} placeholder="URL" - style={{ - width: '100%', - padding: '6px 0', - border: 'none', + style={{ + width: '100%', padding: '6px 0', border: 'none', borderBottom: '1px solid var(--weekly-border)', - background: 'transparent', - fontSize: '0.9rem', - outline: 'none' + background: 'transparent', fontSize: '0.9rem', outline: 'none' }} /> )} + {/* Expandable section: Attendees & Attachments */} + + + {showMoreOptions && ( +
+ {/* Attendees */} +
+ + Invitees + + {attendees.map((att, idx) => ( +
+ + {att.displayName ? `${att.displayName} (${att.email})` : att.email} + + +
+ ))} +
+ setNewAttendeeEmail(e.target.value)} + placeholder="Add email address" + onKeyDown={e => e.key === 'Enter' && addAttendee()} + style={{ + flex: 1, padding: '6px 8px', border: 'none', + borderBottom: '1px solid var(--weekly-border)', + background: 'transparent', fontSize: '0.85rem', outline: 'none', + }} + /> + +
+
+ + {/* Attachments (URL-based, Apple/Synology only) */} + {supportsAttachments && ( +
+ + Attachments + + {attachments.map((att, idx) => ( +
+ + {att.title || att.url} + + +
+ ))} +
+ setNewAttachmentUrl(e.target.value)} + placeholder="Add attachment URL" + onKeyDown={e => e.key === 'Enter' && addAttachment()} + style={{ + flex: 1, padding: '6px 8px', border: 'none', + borderBottom: '1px solid var(--weekly-border)', + background: 'transparent', fontSize: '0.85rem', outline: 'none', + }} + /> + +
+
+ )} +
+ )} + {/* Notes */}
@@ -468,26 +670,21 @@ export default function CalendarEventModal({ )}
- +
{event && ( )} -