Phase 1 fixes: - W-01: Add start_period: 30s to backend healthcheck for migration window - W-03: Narrow .dockerignore *.md to specific files (preserve alembic/README) Phase 2 fixes: - C-01: Wrap Argon2id calls in totp.py (disable, regenerate, backup verify, backup store) — missed in initial AC-2 pass - S-01: Extract async wrappers (ahash_password, averify_password, averify_password_with_upgrade) into services/auth.py, refactor all callers to use them instead of manual run_in_executor boilerplate - W-01: Fix ntfy dedup regression — commit per category instead of per-user to preserve dedup records if a later category fails Phase 4 fixes: - C-01: Fix optimistic drag-and-drop cache key to include date range - C-02: Replace toISOString() with format() to avoid UTC date shift in visible range calculation - W-02: Initialize visibleRange from current month to eliminate unscoped first fetch + immediate refetch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
687 lines
26 KiB
TypeScript
687 lines
26 KiB
TypeScript
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { format } from 'date-fns';
|
|
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, PanelLeft, Plus, Search } from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import axios from 'axios';
|
|
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } 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 { Select } from '@/components/ui/select';
|
|
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
|
|
import CalendarSidebar from './CalendarSidebar';
|
|
import EventDetailPanel from './EventDetailPanel';
|
|
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
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 = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
|
|
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
|
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Resizable sidebar
|
|
const SIDEBAR_STORAGE_KEY = 'umbra-calendar-sidebar-width';
|
|
const SIDEBAR_MIN = 180;
|
|
const SIDEBAR_MAX = 400;
|
|
const SIDEBAR_DEFAULT = 224; // w-56
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
|
if (saved) {
|
|
const n = parseInt(saved, 10);
|
|
if (!isNaN(n) && n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) return n;
|
|
}
|
|
return SIDEBAR_DEFAULT;
|
|
});
|
|
const isResizingRef = useRef(false);
|
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
isResizingRef.current = true;
|
|
const startX = e.clientX;
|
|
const startWidth = sidebarWidth;
|
|
let latestWidth = startWidth;
|
|
|
|
const onMouseMove = (ev: MouseEvent) => {
|
|
latestWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + (ev.clientX - startX)));
|
|
// Direct DOM mutation — bypasses React entirely during drag, zero re-renders
|
|
if (sidebarRef.current) {
|
|
sidebarRef.current.style.width = latestWidth + 'px';
|
|
}
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
isResizingRef.current = false;
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
// Single React state commit on release — triggers localStorage persist + final reconciliation
|
|
setSidebarWidth(latestWidth);
|
|
};
|
|
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
}, [sidebarWidth]);
|
|
|
|
// Persist sidebar width on change
|
|
useEffect(() => {
|
|
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
|
|
}, [sidebarWidth]);
|
|
|
|
// 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]);
|
|
|
|
// Build permission map: calendar_id -> permission level
|
|
const permissionMap = useMemo(() => {
|
|
const map = new Map<number, CalendarPermission | 'owner'>();
|
|
calendars.forEach((cal) => map.set(cal.id, 'owner'));
|
|
sharedData.forEach((m) => map.set(m.calendar_id, m.permission));
|
|
return map;
|
|
}, [calendars, sharedData]);
|
|
|
|
// Set of calendar IDs that are shared (owned or membership)
|
|
const sharedCalendarIds = useMemo(() => {
|
|
const ids = new Set<number>();
|
|
calendars.forEach((cal) => { if (cal.is_shared) ids.add(cal.id); });
|
|
sharedData.forEach((m) => ids.add(m.calendar_id));
|
|
return ids;
|
|
}, [calendars, sharedData]);
|
|
|
|
// 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';
|
|
|
|
// Track desktop breakpoint to prevent dual EventDetailPanel mount
|
|
const isDesktop = useMediaQuery(DESKTOP);
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
|
|
// 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) => {
|
|
// Skip wheel navigation on touch devices (let them scroll normally)
|
|
if ('ontouchstart' in window) return;
|
|
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);
|
|
}, []);
|
|
|
|
// AW-2: Track visible date range for scoped event fetching
|
|
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
|
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = now.getMonth();
|
|
// FullCalendar month view typically fetches prev month to next month
|
|
const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd');
|
|
const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd');
|
|
return { start, end };
|
|
});
|
|
|
|
const { data: events = [] } = useQuery({
|
|
queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<CalendarEvent[]>('/events', {
|
|
params: { start: visibleRange.start, end: visibleRange.end },
|
|
});
|
|
return data;
|
|
},
|
|
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
const selectedEvent = useMemo(
|
|
() => events.find((e) => e.id === selectedEventId) ?? null,
|
|
[selectedEventId, events],
|
|
);
|
|
|
|
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
|
|
const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false;
|
|
|
|
// Close panel if shared calendar was removed while viewing
|
|
useEffect(() => {
|
|
if (!selectedEvent || allCalendarIds.size === 0) return;
|
|
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
|
|
handlePanelClose();
|
|
toast.info('This calendar is no longer available');
|
|
}
|
|
}, [allCalendarIds, selectedEvent]);
|
|
|
|
// 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(
|
|
() => {
|
|
const owned = calendars.filter((c) => c.is_visible).map((c) => c.id);
|
|
return new Set([...owned, ...visibleSharedIds]);
|
|
},
|
|
[calendars, visibleSharedIds],
|
|
);
|
|
|
|
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,
|
|
) => {
|
|
// C-01 fix: match active query key which includes date range
|
|
queryClient.setQueryData<CalendarEvent[]>(
|
|
['calendar-events', visibleRange.start, visibleRange.end],
|
|
(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'] });
|
|
if (axios.isAxiosError(error) && error.response?.status === 423) {
|
|
toast.error('Event is locked by another user');
|
|
} else {
|
|
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))',
|
|
editable: permissionMap.get(event.calendar_id) !== 'read_only',
|
|
extendedProps: {
|
|
is_virtual: event.is_virtual,
|
|
is_recurring: event.is_recurring,
|
|
parent_event_id: event.parent_event_id,
|
|
calendar_id: event.calendar_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;
|
|
}
|
|
if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') {
|
|
info.revert();
|
|
toast.error('You have read-only access to this calendar');
|
|
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;
|
|
}
|
|
if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') {
|
|
info.revert();
|
|
toast.error('You have read-only access to this calendar');
|
|
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);
|
|
// AW-2: Capture visible range for scoped event fetching
|
|
// C-02 fix: use format() not toISOString() to avoid UTC date shift
|
|
const start = format(arg.start, 'yyyy-MM-dd');
|
|
const end = format(arg.end, 'yyyy-MM-dd');
|
|
setVisibleRange((prev) =>
|
|
prev.start === start && prev.end === end ? prev : { start, end }
|
|
);
|
|
};
|
|
|
|
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">
|
|
<div className="hidden lg:flex lg:flex-row shrink-0">
|
|
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
|
|
<div
|
|
onMouseDown={handleSidebarMouseDown}
|
|
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
|
|
/>
|
|
</div>
|
|
|
|
{!isDesktop && (
|
|
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
|
<SheetContent className="w-72 p-0">
|
|
<SheetClose onClick={() => setMobileSidebarOpen(false)} />
|
|
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
|
|
</SheetContent>
|
|
</Sheet>
|
|
)}
|
|
|
|
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Custom toolbar */}
|
|
<div className="border-b bg-card/95 backdrop-blur-md px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setMobileSidebarOpen(true)}>
|
|
<PanelLeft className="h-4 w-4" />
|
|
</Button>
|
|
<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="md:hidden">
|
|
<Select
|
|
value={currentView}
|
|
onChange={(e) => changeView(e.target.value as CalendarView)}
|
|
className="h-8 text-sm w-auto pr-8"
|
|
>
|
|
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
|
<option key={view} value={view}>{label}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="hidden md: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-sm sm:text-lg font-semibold font-heading truncate min-w-0 flex-shrink">{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-32 sm: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="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Create Event</span>
|
|
</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) */}
|
|
{panelOpen && isDesktop && (
|
|
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
|
|
<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}
|
|
myPermission={selectedEventPermission}
|
|
isSharedEvent={selectedEventIsShared}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile detail panel overlay */}
|
|
{panelOpen && !isDesktop && (
|
|
<MobileDetailOverlay open onClose={handlePanelClose} className="sm:max-w-[400px]">
|
|
<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}
|
|
myPermission={selectedEventPermission}
|
|
isSharedEvent={selectedEventIsShared}
|
|
/>
|
|
</MobileDetailOverlay>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|