feat: drag-to-move and resize handles for calendar events
Add resize handles (top/bottom) on hover to change event duration, and drag-to-move support with live preview and 5-minute snapping. Includes 3px dead zone to distinguish clicks from drags, optimistic UI updates with server rollback on failure, and recurring/provider icons on time-grid events. Also fixes Google delete 410 Gone and Outlook delete 404/encodeURIComponent issues. v1.66.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c040dd982d
commit
13de53fe0a
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.65.1",
|
||||
"version": "1.66.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": {
|
||||
|
||||
@ -3059,6 +3059,47 @@ h3 {
|
||||
background: rgba(0, 154, 154, 0.15);
|
||||
}
|
||||
|
||||
/* Event resize handles */
|
||||
.event-resize-handle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.event-resize-handle::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.event-resize-handle-top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.event-resize-handle-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.time-slot-event:hover .event-resize-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.time-slot-event.event-dragging {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Make day columns scrollable when using time grid */
|
||||
.time-grid-wrapper .weekly-days-grid {
|
||||
flex: 1;
|
||||
|
||||
@ -2202,6 +2202,20 @@ export default function WeeklyView() {
|
||||
initialStartTime?: string;
|
||||
}>({ isOpen: false });
|
||||
|
||||
// Calendar event resize/drag state
|
||||
const [eventDragState, setEventDragState] = useState<{
|
||||
eventId: string;
|
||||
mode: 'move' | 'resize-top' | 'resize-bottom';
|
||||
startY: number;
|
||||
originalStartTime: string;
|
||||
originalEndTime: string;
|
||||
currentStartTime: string;
|
||||
currentEndTime: string;
|
||||
calendarId?: string;
|
||||
source?: string;
|
||||
hasMoved?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Dark Mode Persistence & Class Toggle
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@ -2232,6 +2246,7 @@ export default function WeeklyView() {
|
||||
|
||||
const profileLoadedRef = useRef(false);
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const dragJustEndedRef = useRef(false);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
@ -3686,6 +3701,114 @@ export default function WeeklyView() {
|
||||
);
|
||||
};
|
||||
|
||||
// Snap minutes to nearest 5-minute increment
|
||||
const snapMinutes = (mins: number) => Math.round(mins / 5) * 5;
|
||||
|
||||
// Handle calendar event drag/resize
|
||||
const handleEventDragStart = (e: React.MouseEvent, event: CalendarEvent, mode: 'move' | 'resize-top' | 'resize-bottom') => {
|
||||
if (!event.editable) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEventDragState({
|
||||
eventId: event.id,
|
||||
mode,
|
||||
startY: e.clientY,
|
||||
originalStartTime: event.startTime,
|
||||
originalEndTime: event.endTime,
|
||||
currentStartTime: event.startTime,
|
||||
currentEndTime: event.endTime,
|
||||
calendarId: event.calendarId,
|
||||
source: event.source,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventDragState) return;
|
||||
const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = e.clientY - eventDragState.startY;
|
||||
// Require minimum 3px movement to start actual drag
|
||||
if (!eventDragState.hasMoved && Math.abs(deltaY) < 3) return;
|
||||
if (!eventDragState.hasMoved) {
|
||||
setEventDragState(prev => prev ? { ...prev, hasMoved: true } : null);
|
||||
}
|
||||
const deltaMinutes = snapMinutes(deltaY / pixelsPerMinute);
|
||||
const origStart = new Date(eventDragState.originalStartTime);
|
||||
const origEnd = new Date(eventDragState.originalEndTime);
|
||||
|
||||
if (eventDragState.mode === 'move') {
|
||||
const newStart = new Date(origStart.getTime() + deltaMinutes * 60000);
|
||||
const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000);
|
||||
setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString(), currentEndTime: newEnd.toISOString() } : null);
|
||||
} else if (eventDragState.mode === 'resize-bottom') {
|
||||
const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000);
|
||||
if (newEnd.getTime() > origStart.getTime() + 5 * 60000) {
|
||||
setEventDragState(prev => prev ? { ...prev, currentEndTime: newEnd.toISOString() } : null);
|
||||
}
|
||||
} else if (eventDragState.mode === 'resize-top') {
|
||||
const newStart = new Date(origStart.getTime() + deltaMinutes * 60000);
|
||||
if (newStart.getTime() < origEnd.getTime() - 5 * 60000) {
|
||||
setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString() } : null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = async () => {
|
||||
if (!eventDragState) return;
|
||||
const startChanged = eventDragState.currentStartTime !== eventDragState.originalStartTime;
|
||||
const endChanged = eventDragState.currentEndTime !== eventDragState.originalEndTime;
|
||||
if (eventDragState.hasMoved && (startChanged || endChanged)) {
|
||||
// Optimistically update the UI
|
||||
setRawCalendarEvents(prev => prev.map(ev =>
|
||||
ev.id === eventDragState.eventId
|
||||
? { ...ev, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime }
|
||||
: ev
|
||||
));
|
||||
// Save to server
|
||||
try {
|
||||
const res = await fetch("/api/calendar/events", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
calendarId: eventDragState.calendarId,
|
||||
eventId: eventDragState.eventId,
|
||||
start: { dateTime: eventDragState.currentStartTime },
|
||||
end: { dateTime: eventDragState.currentEndTime },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Revert on failure
|
||||
setRawCalendarEvents(prev => prev.map(ev =>
|
||||
ev.id === eventDragState.eventId
|
||||
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
|
||||
: ev
|
||||
));
|
||||
}
|
||||
} catch {
|
||||
setRawCalendarEvents(prev => prev.map(ev =>
|
||||
ev.id === eventDragState.eventId
|
||||
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
|
||||
: ev
|
||||
));
|
||||
}
|
||||
}
|
||||
const didMove = eventDragState.hasMoved;
|
||||
setEventDragState(null);
|
||||
if (didMove) {
|
||||
dragJustEndedRef.current = true;
|
||||
setTimeout(() => { dragJustEndedRef.current = false; }, 200);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [eventDragState, effectiveCellDuration]);
|
||||
|
||||
// Get all-day events for a specific date
|
||||
const getAllDayEventsForDate = useCallback(
|
||||
(date: Date): CalendarEvent[] => {
|
||||
@ -7540,10 +7663,15 @@ export default function WeeklyView() {
|
||||
)}
|
||||
{/* Calendar Events in time slot */}
|
||||
{slotEvents.map((event) => {
|
||||
const eventHeight = getEventDuration(event);
|
||||
const startTime = new Date(event.startTime);
|
||||
const endTime = new Date(event.endTime);
|
||||
const timeStr = `${startTime.getHours().toString().padStart(2, "0")}:${startTime.getMinutes().toString().padStart(2, "0")} - ${endTime.getHours().toString().padStart(2, "0")}:${endTime.getMinutes().toString().padStart(2, "0")}`;
|
||||
// During drag, use the drag state's current times for position/height
|
||||
const isDragging = eventDragState?.eventId === event.id && eventDragState?.hasMoved;
|
||||
const displayStartTime = isDragging ? new Date(eventDragState!.currentStartTime) : new Date(event.startTime);
|
||||
const displayEndTime = isDragging ? new Date(eventDragState!.currentEndTime) : new Date(event.endTime);
|
||||
const durationMs = displayEndTime.getTime() - displayStartTime.getTime();
|
||||
const durationMinutes = durationMs / (1000 * 60);
|
||||
const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||||
const eventHeight = Math.max(durationMinutes * pxPerMin, 15);
|
||||
const timeStr = `${displayStartTime.getHours().toString().padStart(2, "0")}:${displayStartTime.getMinutes().toString().padStart(2, "0")} - ${displayEndTime.getHours().toString().padStart(2, "0")}:${displayEndTime.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
||||
// Calculate offset within the slot based on event start time
|
||||
const [slotHour, slotMinute] = slot
|
||||
@ -7551,13 +7679,11 @@ export default function WeeklyView() {
|
||||
.map(Number);
|
||||
const slotStartMinutes = slotHour * 60 + slotMinute;
|
||||
const eventStartMinutes =
|
||||
startTime.getHours() * 60 +
|
||||
startTime.getMinutes();
|
||||
displayStartTime.getHours() * 60 +
|
||||
displayStartTime.getMinutes();
|
||||
const offsetMinutes =
|
||||
eventStartMinutes - slotStartMinutes;
|
||||
const pixelsPerMinute =
|
||||
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||||
const topOffset = offsetMinutes * pixelsPerMinute;
|
||||
const topOffset = offsetMinutes * pxPerMin;
|
||||
|
||||
// Convert hex to rgba for background, or use default
|
||||
const eventColor = event.calendarColor || "#009a9a";
|
||||
@ -7575,16 +7701,16 @@ export default function WeeklyView() {
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="time-slot-event"
|
||||
className={`time-slot-event${isDragging ? ' event-dragging' : ''}`}
|
||||
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
|
||||
style={{
|
||||
height: `${Math.max(eventHeight, 15)}px`,
|
||||
height: `${eventHeight}px`,
|
||||
minHeight: `15px`,
|
||||
position: "absolute",
|
||||
top: `${topOffset}px`,
|
||||
left: `calc(${colLeft}% + 1px)`,
|
||||
width: `calc(${colWidth}% - 3px)`,
|
||||
zIndex: 1,
|
||||
zIndex: isDragging ? 50 : 1,
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
@ -7592,11 +7718,15 @@ export default function WeeklyView() {
|
||||
borderLeftColor: borderColor,
|
||||
color: borderColor,
|
||||
cursor: event.editable
|
||||
? "pointer"
|
||||
? (isDragging ? "grabbing" : "grab")
|
||||
: "default",
|
||||
opacity: isDragging && eventDragState?.mode === 'move' ? 0.7 : 1,
|
||||
transition: isDragging ? 'none' : undefined,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (dragJustEndedRef.current) return; // Don't open modal after drag
|
||||
if (event.editable) {
|
||||
setCalendarEventModal({
|
||||
isOpen: true,
|
||||
@ -7604,7 +7734,22 @@ export default function WeeklyView() {
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (event.editable && e.button === 0) {
|
||||
handleEventDragStart(e, event, 'move');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Top resize handle */}
|
||||
{event.editable && (
|
||||
<div
|
||||
className="event-resize-handle event-resize-handle-top"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEventDragStart(e, event, 'resize-top');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="event-title-row">
|
||||
<span className="event-indicator">📅</span>
|
||||
<span className="event-title">
|
||||
@ -7612,6 +7757,44 @@ export default function WeeklyView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="event-time-row">{timeStr}</div>
|
||||
{(event.isRecurring || profile.showCalendarProviderIcon) && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: 2,
|
||||
right: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
opacity: 0.5,
|
||||
pointerEvents: "none",
|
||||
}}>
|
||||
{event.isRecurring && <Repeat size={11} />}
|
||||
{profile.showCalendarProviderIcon && (
|
||||
<img
|
||||
src={
|
||||
event.source === "google" ? "/icons/google-calendar.svg"
|
||||
: event.source === "apple" ? "/icons/apple-calendar.svg"
|
||||
: event.source === "outlook" ? "/icons/outlook-calendar.svg"
|
||||
: event.source === "synology" ? "/icons/synology-calendar.svg"
|
||||
: undefined
|
||||
}
|
||||
alt={event.source}
|
||||
style={{ width: 13, height: 13 }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Bottom resize handle */}
|
||||
{event.editable && (
|
||||
<div
|
||||
className="event-resize-handle event-resize-handle-bottom"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEventDragStart(e, event, 'resize-bottom');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -234,7 +234,12 @@ export const deleteEvent = async (
|
||||
calendarId,
|
||||
eventId,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// 410 Gone means already deleted — treat as success
|
||||
if (error?.code === 410 || error?.status === 410) {
|
||||
console.log('[GOOGLE] Event already deleted (410 Gone), treating as success');
|
||||
return;
|
||||
}
|
||||
console.error('Error deleting event:', error);
|
||||
throw new Error('Failed to delete event');
|
||||
}
|
||||
|
||||
@ -377,14 +377,17 @@ export const updateEvent = async (
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const encodedEventId = encodeURIComponent(eventId);
|
||||
const encodedCalendarId = encodeURIComponent(calendarId);
|
||||
|
||||
// Try non-calendar-scoped endpoint first (more reliable for all ID types)
|
||||
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
|
||||
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodedEventId}`, {
|
||||
method: 'PATCH', headers: patchHeaders, body
|
||||
});
|
||||
|
||||
if (!response.ok && (response.status === 404 || response.status === 400)) {
|
||||
// Fallback: try calendar-scoped endpoint
|
||||
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events/${eventId}`, {
|
||||
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodedCalendarId}/events/${encodedEventId}`, {
|
||||
method: 'PATCH', headers: patchHeaders, body
|
||||
});
|
||||
}
|
||||
@ -415,19 +418,28 @@ export const deleteEvent = async (
|
||||
eventId: string
|
||||
) => {
|
||||
const headers = { 'Authorization': `Bearer ${accessToken}` };
|
||||
// Encode the event ID for URL safety (base64 padding = may need encoding)
|
||||
const encodedEventId = encodeURIComponent(eventId);
|
||||
const encodedCalendarId = encodeURIComponent(calendarId);
|
||||
|
||||
// Try non-calendar-scoped endpoint first (more reliable for all ID types)
|
||||
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
|
||||
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodedEventId}`, {
|
||||
method: 'DELETE', headers
|
||||
});
|
||||
|
||||
if (!response.ok && (response.status === 404 || response.status === 400)) {
|
||||
// Fallback: try calendar-scoped endpoint
|
||||
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events/${eventId}`, {
|
||||
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodedCalendarId}/events/${encodedEventId}`, {
|
||||
method: 'DELETE', headers
|
||||
});
|
||||
}
|
||||
|
||||
// 404/410 on delete = already gone, treat as success
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
console.log('[OUTLOOK] Event already deleted or not found, treating as success');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Failed to delete Outlook event: ${err}`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user