UMBRA/frontend/src/components/calendar/CalendarPage.tsx
Kyle Pope 2e2466bfa6 Fix People search: alignment, focus ring, and name matching
- 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>
2026-02-25 23:35:40 +08:00

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>
);
}