@@ -185,4 +220,6 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
)}
);
-}
+});
+
+export default CalendarSidebar;
\ No newline at end of file
diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx
index 882a0bf..8362a1f 100644
--- a/frontend/src/components/calendar/EventDetailPanel.tsx
+++ b/frontend/src/components/calendar/EventDetailPanel.tsx
@@ -3,14 +3,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO } from 'date-fns';
import {
- X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
+ X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
} from 'lucide-react';
+import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api';
-import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
+import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarPermission, EventLockInfo } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction';
+import { useEventLock } from '@/hooks/useEventLock';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
+import EventLockBanner from './EventLockBanner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
@@ -126,6 +129,8 @@ interface EventDetailPanelProps {
onSaved?: () => void;
onDeleted?: () => void;
locationName?: string;
+ myPermission?: CalendarPermission | 'owner' | null;
+ isSharedEvent?: boolean;
}
interface EditState {
@@ -218,10 +223,17 @@ export default function EventDetailPanel({
onSaved,
onDeleted,
locationName,
+ myPermission,
+ isSharedEvent = false,
}: EventDetailPanelProps) {
const queryClient = useQueryClient();
- const { data: calendars = [] } = useCalendars();
- const selectableCalendars = calendars.filter((c) => !c.is_system);
+ const { data: calendars = [], sharedData: sharedMemberships = [] } = useCalendars();
+ const selectableCalendars = [
+ ...calendars.filter((c) => !c.is_system),
+ ...sharedMemberships
+ .filter((m) => m.permission === 'create_modify' || m.permission === 'full_access')
+ .map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })),
+ ];
const defaultCalendar = calendars.find((c) => c.is_default);
const { data: locations = [] } = useQuery({
@@ -233,6 +245,12 @@ export default function EventDetailPanel({
staleTime: 5 * 60 * 1000,
});
+ const { acquire: acquireLock, release: releaseLock, isAcquiring: isAcquiringLock } = useEventLock(
+ isSharedEvent && event ? (typeof event.id === 'number' ? event.id : null) : null
+ );
+ const [lockInfo, setLockInfo] = useState
(null);
+
+
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState(() =>
isCreating
@@ -245,14 +263,47 @@ export default function EventDetailPanel({
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState('');
+ // Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
+ // lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
+ const viewLockQuery = useQuery({
+ queryKey: ['event-lock', event?.id],
+ queryFn: async () => {
+ const { data } = await api.get(
+ `/shared-calendars/events/${event!.id}/lock`
+ );
+ return data;
+ },
+ enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating,
+ refetchInterval: 5_000,
+ refetchIntervalInBackground: true,
+ refetchOnMount: 'always',
+ });
+
+ // Clear 423-error lockInfo when poll confirms lock is gone
+ useEffect(() => {
+ if (viewLockQuery.data && !viewLockQuery.data.locked) {
+ setLockInfo(null);
+ }
+ }, [viewLockQuery.data]);
+
+ // Derived: authoritative lock state — poll data wins, 423 error lockInfo as fallback
+ const activeLockInfo: EventLockInfo | null =
+ (viewLockQuery.data?.locked ? viewLockQuery.data : null) ??
+ (lockInfo?.locked ? lockInfo : null);
+
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
+ // Permission helpers
+ const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
+ const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
+
// Reset state when event changes
useEffect(() => {
setIsEditing(false);
setScopeStep(null);
setEditScope(null);
setLocationSearch('');
+ setLockInfo(null);
if (event) setEditState(buildEditStateFromEvent(event));
}, [event?.id]);
@@ -307,15 +358,16 @@ export default function EventDetailPanel({
}
},
onSuccess: () => {
+ if (isSharedEvent) releaseLock();
invalidateAll();
toast.success(isCreating ? 'Event created' : 'Event updated');
if (isCreating) {
onClose();
+ onSaved?.();
} else {
setIsEditing(false);
setEditScope(null);
}
- onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
@@ -343,7 +395,28 @@ export default function EventDetailPanel({
// --- Handlers ---
- const handleEditStart = () => {
+ const handleEditStart = async () => {
+ // For shared events, acquire lock first
+ if (isSharedEvent && event && typeof event.id === 'number') {
+ try {
+ await acquireLock();
+ setLockInfo(null);
+ } catch (err: unknown) {
+ if (axios.isAxiosError(err) && err.response?.status === 423) {
+ const data = err.response.data as { locked_by_name?: string; expires_at?: string; is_permanent?: boolean } | undefined;
+ setLockInfo({
+ locked: true,
+ locked_by_name: data?.locked_by_name || 'another user',
+ expires_at: data?.expires_at || null,
+ is_permanent: data?.is_permanent || false,
+ });
+ return;
+ }
+ toast.error('Failed to acquire edit lock');
+ return;
+ }
+ }
+
if (isRecurring) {
setScopeStep('edit');
} else {
@@ -361,8 +434,6 @@ export default function EventDetailPanel({
} else if (scopeStep === 'delete') {
// Delete with scope — execute immediately
setScopeStep(null);
- // The deleteMutation will read editScope, but we need to set it first
- // Since setState is async, use the mutation directly with the scope
const scopeParam = `?scope=${scope}`;
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
invalidateAll();
@@ -376,6 +447,7 @@ export default function EventDetailPanel({
};
const handleEditCancel = () => {
+ if (isSharedEvent) releaseLock();
setIsEditing(false);
setEditScope(null);
setLocationSearch('');
@@ -507,37 +579,42 @@ export default function EventDetailPanel({
<>
{!event?.is_virtual && (
<>
-
- {confirmingDelete ? (
-
- ) : (
+ {canEdit && (
)}
+ {canDelete && (
+ confirmingDelete ? (
+
+ ) : (
+
+ )
+ )}
>
)}