- Wrap CategoryFilterBar in flex-1 min-w-0 so search aligns right - Add first_name, last_name, nickname to People search filter - Add ring-inset to all header search inputs (People, Todos, Locations, Reminders, Calendar) to prevent focus ring clipping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
526 lines
18 KiB
TypeScript
526 lines
18 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import FullCalendar from '@fullcalendar/react';
|
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
|
import interactionPlugin from '@fullcalendar/interaction';
|
|
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
|
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
|
|
import { useCalendars } from '@/hooks/useCalendars';
|
|
import { useSettings } from '@/hooks/useSettings';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import CalendarSidebar from './CalendarSidebar';
|
|
import EventDetailPanel from './EventDetailPanel';
|
|
import type { CreateDefaults } from './EventDetailPanel';
|
|
|
|
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
|
|
|
const viewLabels: Record<CalendarView, string> = {
|
|
dayGridMonth: 'Month',
|
|
timeGridWeek: 'Week',
|
|
timeGridDay: 'Day',
|
|
};
|
|
|
|
export default function CalendarPage() {
|
|
const queryClient = useQueryClient();
|
|
const location = useLocation();
|
|
const calendarRef = useRef<FullCalendar>(null);
|
|
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
|
|
const [calendarTitle, setCalendarTitle] = useState('');
|
|
|
|
const [eventSearch, setEventSearch] = useState('');
|
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
|
|
// Panel state
|
|
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
|
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
|
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
|
|
|
const { settings } = useSettings();
|
|
const { data: calendars = [] } = useCalendars();
|
|
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Location data for event panel
|
|
const { data: locations = [] } = useQuery({
|
|
queryKey: ['locations'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<LocationType[]>('/locations');
|
|
return data;
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
const locationMap = useMemo(() => {
|
|
const map = new Map<number, string>();
|
|
locations.forEach((l) => map.set(l.id, l.name));
|
|
return map;
|
|
}, [locations]);
|
|
|
|
// Handle navigation state from dashboard
|
|
useEffect(() => {
|
|
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
|
|
if (!state) return;
|
|
const calApi = calendarRef.current?.getApi();
|
|
if (state.date && calApi) {
|
|
calApi.gotoDate(state.date);
|
|
if (state.view) calApi.changeView(state.view);
|
|
}
|
|
if (state.eventId) {
|
|
setSelectedEventId(state.eventId);
|
|
setPanelMode('view');
|
|
}
|
|
window.history.replaceState({}, '');
|
|
}, [location.state]);
|
|
|
|
// Resize FullCalendar when container size changes (e.g. sidebar collapse)
|
|
useEffect(() => {
|
|
const el = calendarContainerRef.current;
|
|
if (!el) return;
|
|
const observer = new ResizeObserver(() => {
|
|
calendarRef.current?.getApi().updateSize();
|
|
});
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const panelOpen = panelMode !== 'closed';
|
|
|
|
// Continuously resize calendar during panel open/close CSS transition
|
|
useEffect(() => {
|
|
let rafId: number;
|
|
const start = performance.now();
|
|
const duration = 350; // slightly longer than the 300ms CSS transition
|
|
const tick = () => {
|
|
calendarRef.current?.getApi().updateSize();
|
|
if (performance.now() - start < duration) {
|
|
rafId = requestAnimationFrame(tick);
|
|
}
|
|
};
|
|
rafId = requestAnimationFrame(tick);
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [panelOpen]);
|
|
|
|
// Scroll wheel navigation in month view
|
|
useEffect(() => {
|
|
const el = calendarContainerRef.current;
|
|
if (!el) return;
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const handleWheel = (e: WheelEvent) => {
|
|
const api = calendarRef.current?.getApi();
|
|
if (!api || api.view.type !== 'dayGridMonth') return;
|
|
e.preventDefault();
|
|
if (debounceTimer) return;
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = null;
|
|
}, 300);
|
|
if (e.deltaY > 0) api.next();
|
|
else if (e.deltaY < 0) api.prev();
|
|
};
|
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', handleWheel);
|
|
}, []);
|
|
|
|
const { data: events = [] } = useQuery({
|
|
queryKey: ['calendar-events'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<CalendarEvent[]>('/events');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const selectedEvent = useMemo(
|
|
() => events.find((e) => e.id === selectedEventId) ?? null,
|
|
[selectedEventId, events],
|
|
);
|
|
|
|
// Escape key closes detail panel
|
|
useEffect(() => {
|
|
if (!panelOpen) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') handlePanelClose();
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [panelOpen]);
|
|
|
|
const visibleCalendarIds = useMemo(
|
|
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
|
|
[calendars],
|
|
);
|
|
|
|
const toLocalDatetime = (d: Date): string => {
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
};
|
|
|
|
const updateEventTimes = (
|
|
id: number,
|
|
start: string,
|
|
end: string,
|
|
allDay: boolean,
|
|
revert: () => void,
|
|
) => {
|
|
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
|
old?.map((e) =>
|
|
e.id === id
|
|
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
|
: e,
|
|
),
|
|
);
|
|
eventMutation.mutate({ id, start, end, allDay, revert });
|
|
};
|
|
|
|
const eventMutation = useMutation({
|
|
mutationFn: async ({
|
|
id,
|
|
start,
|
|
end,
|
|
allDay,
|
|
}: {
|
|
id: number;
|
|
start: string;
|
|
end: string;
|
|
allDay: boolean;
|
|
revert: () => void;
|
|
}) => {
|
|
const response = await api.put(`/events/${id}`, {
|
|
start_datetime: start,
|
|
end_datetime: end,
|
|
all_day: allDay,
|
|
});
|
|
return response.data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.success('Event updated');
|
|
},
|
|
onError: (error, variables) => {
|
|
variables.revert();
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.error(getErrorMessage(error, 'Failed to update event'));
|
|
},
|
|
});
|
|
|
|
const filteredEvents = useMemo(() => {
|
|
if (calendars.length === 0) return events;
|
|
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
|
}, [events, visibleCalendarIds, calendars.length]);
|
|
|
|
const searchResults = useMemo(() => {
|
|
if (!eventSearch.trim()) return [];
|
|
const q = eventSearch.toLowerCase();
|
|
return filteredEvents
|
|
.filter((e) => e.title.toLowerCase().includes(q))
|
|
.slice(0, 8);
|
|
}, [filteredEvents, eventSearch]);
|
|
|
|
const handleSearchSelect = (event: CalendarEvent) => {
|
|
const calApi = calendarRef.current?.getApi();
|
|
if (!calApi) return;
|
|
const startDate = new Date(event.start_datetime);
|
|
calApi.gotoDate(startDate);
|
|
if (event.all_day) {
|
|
calApi.changeView('dayGridMonth');
|
|
} else {
|
|
calApi.changeView('timeGridDay');
|
|
}
|
|
setEventSearch('');
|
|
setSearchFocused(false);
|
|
// Also open the event in the panel
|
|
if (!event.is_virtual) {
|
|
setSelectedEventId(event.id);
|
|
setPanelMode('view');
|
|
}
|
|
};
|
|
|
|
const calendarEvents = filteredEvents.map((event) => ({
|
|
id: String(event.id),
|
|
title: event.title,
|
|
start: event.start_datetime,
|
|
end: event.end_datetime || undefined,
|
|
allDay: event.all_day,
|
|
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
|
|
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
|
extendedProps: {
|
|
is_virtual: event.is_virtual,
|
|
is_recurring: event.is_recurring,
|
|
parent_event_id: event.parent_event_id,
|
|
},
|
|
}));
|
|
|
|
const handleEventClick = (info: EventClickArg) => {
|
|
const event = events.find((e) => String(e.id) === info.event.id);
|
|
if (!event) return;
|
|
if (event.is_virtual) {
|
|
toast.info(`${event.title} — from People contacts`);
|
|
return;
|
|
}
|
|
setSelectedEventId(event.id);
|
|
setPanelMode('view');
|
|
};
|
|
|
|
const handleEventDrop = (info: EventDropArg) => {
|
|
if (info.event.extendedProps.is_virtual) {
|
|
info.revert();
|
|
return;
|
|
}
|
|
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
|
info.revert();
|
|
toast.info('Click the event to edit recurring events');
|
|
return;
|
|
}
|
|
const id = parseInt(info.event.id);
|
|
const start = info.event.allDay
|
|
? info.event.startStr
|
|
: info.event.start
|
|
? toLocalDatetime(info.event.start)
|
|
: info.event.startStr;
|
|
const end = info.event.allDay
|
|
? info.event.endStr || info.event.startStr
|
|
: info.event.end
|
|
? toLocalDatetime(info.event.end)
|
|
: start;
|
|
updateEventTimes(id, start, end, info.event.allDay, info.revert);
|
|
};
|
|
|
|
const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => {
|
|
if (info.event.extendedProps.is_virtual) {
|
|
info.revert();
|
|
return;
|
|
}
|
|
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
|
|
info.revert();
|
|
toast.info('Click the event to edit recurring events');
|
|
return;
|
|
}
|
|
const id = parseInt(info.event.id);
|
|
const start = info.event.allDay
|
|
? info.event.startStr
|
|
: info.event.start
|
|
? toLocalDatetime(info.event.start)
|
|
: info.event.startStr;
|
|
const end = info.event.allDay
|
|
? info.event.endStr || info.event.startStr
|
|
: info.event.end
|
|
? toLocalDatetime(info.event.end)
|
|
: start;
|
|
updateEventTimes(id, start, end, info.event.allDay, info.revert);
|
|
};
|
|
|
|
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
|
setSelectedEventId(null);
|
|
setPanelMode('create');
|
|
setCreateDefaults({
|
|
start: selectInfo.startStr,
|
|
end: selectInfo.endStr,
|
|
allDay: selectInfo.allDay,
|
|
});
|
|
};
|
|
|
|
const handleCreateNew = () => {
|
|
setSelectedEventId(null);
|
|
setPanelMode('create');
|
|
setCreateDefaults(null);
|
|
};
|
|
|
|
const handlePanelClose = () => {
|
|
calendarRef.current?.getApi().unselect();
|
|
setPanelMode('closed');
|
|
setSelectedEventId(null);
|
|
setCreateDefaults(null);
|
|
};
|
|
|
|
const handleUseTemplate = (template: EventTemplate) => {
|
|
setSelectedEventId(null);
|
|
setPanelMode('create');
|
|
setCreateDefaults({
|
|
templateData: {
|
|
title: template.title,
|
|
description: template.description || '',
|
|
all_day: template.all_day,
|
|
calendar_id: template.calendar_id ?? undefined,
|
|
location_id: template.location_id || undefined,
|
|
is_starred: template.is_starred,
|
|
recurrence_rule: template.recurrence_rule || undefined,
|
|
} as Partial<CalendarEvent>,
|
|
templateName: template.name,
|
|
});
|
|
};
|
|
|
|
const handleDatesSet = (arg: DatesSetArg) => {
|
|
setCalendarTitle(arg.view.title);
|
|
setCurrentView(arg.view.type as CalendarView);
|
|
};
|
|
|
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
|
const navigateNext = () => calendarRef.current?.getApi().next();
|
|
const navigateToday = () => calendarRef.current?.getApi().today();
|
|
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden animate-fade-in">
|
|
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
|
|
|
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Custom toolbar */}
|
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigateNext}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
|
|
Today
|
|
</Button>
|
|
|
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
|
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
|
<button
|
|
key={view}
|
|
onClick={() => changeView(view)}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
|
currentView === view
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
style={{
|
|
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Event search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search events..."
|
|
value={eventSearch}
|
|
onChange={(e) => setEventSearch(e.target.value)}
|
|
onFocus={() => setSearchFocused(true)}
|
|
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
|
|
className="w-52 h-8 pl-8 text-sm ring-inset"
|
|
/>
|
|
{searchFocused && searchResults.length > 0 && (
|
|
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
|
|
{searchResults.map((event) => (
|
|
<button
|
|
key={event.id}
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => handleSearchSelect(event)}
|
|
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
|
>
|
|
<div
|
|
className="w-2 h-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="font-medium truncate block">{event.title}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(event.start_datetime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button size="sm" onClick={handleCreateNew}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Create Event
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Calendar grid + event detail panel */}
|
|
<div className="flex-1 overflow-hidden flex">
|
|
<div
|
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
|
}`}
|
|
>
|
|
<div className="h-full">
|
|
<FullCalendar
|
|
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
|
ref={calendarRef}
|
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
initialView="dayGridMonth"
|
|
headerToolbar={false}
|
|
firstDay={settings?.first_day_of_week ?? 0}
|
|
events={calendarEvents}
|
|
editable={true}
|
|
selectable={true}
|
|
selectMirror={true}
|
|
unselectAuto={false}
|
|
dayMaxEvents={true}
|
|
weekends={true}
|
|
nowIndicator={true}
|
|
eventClick={handleEventClick}
|
|
eventDrop={handleEventDrop}
|
|
eventResize={handleEventResize}
|
|
select={handleDateSelect}
|
|
datesSet={handleDatesSet}
|
|
height="100%"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail panel (desktop) */}
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
}`}
|
|
>
|
|
<EventDetailPanel
|
|
event={panelMode === 'view' ? selectedEvent : null}
|
|
isCreating={panelMode === 'create'}
|
|
createDefaults={createDefaults}
|
|
onClose={handlePanelClose}
|
|
onSaved={handlePanelClose}
|
|
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile detail panel overlay */}
|
|
{panelOpen && (
|
|
<div
|
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
|
onClick={handlePanelClose}
|
|
>
|
|
<div
|
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<EventDetailPanel
|
|
event={panelMode === 'view' ? selectedEvent : null}
|
|
isCreating={panelMode === 'create'}
|
|
createDefaults={createDefaults}
|
|
onClose={handlePanelClose}
|
|
onSaved={handlePanelClose}
|
|
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|