From 6d592a6dfcf71ce987ec7da20f668d11cb7a2bae Mon Sep 17 00:00:00 2001 From: Kevin Mok Date: Fri, 28 Mar 2025 12:55:19 -0400 Subject: [PATCH] Drag events between days on mobile --- src/app/globals.css | 38 ++- src/components/Calendar.tsx | 585 +++++++++++++++++++++++++----------- src/data/events.ts | 6 +- src/hooks/useSwipe.ts | 35 +++ src/types.ts | 11 + 5 files changed, 498 insertions(+), 177 deletions(-) create mode 100644 src/hooks/useSwipe.ts create mode 100644 src/types.ts diff --git a/src/app/globals.css b/src/app/globals.css index 14436ae..9ef3160 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -21,7 +21,7 @@ body { background: var(--background); - color: var(--foreground); + /*color: var(--foreground);*/ font-family: Arial, Helvetica, sans-serif; } @@ -34,3 +34,39 @@ body { touch-action: none; } } + +[data-day] { + transform-style: preserve-3d; + backface-visibility: hidden; +} + +.draggable-event { + touch-action: none; + user-select: none; +} + +.day-column { + position: relative; + contain: layout paint style; +} + +.draggable-event { + position: relative; + z-index: 1; + transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease; +} + +.drag-preview { + z-index: 0 !important; + transition: opacity 0.2s ease; +} + +.event-item { + position: relative; + transition: transform 0.2s ease; +} + +.preview-item { + margin: 2px 0; + transform-origin: top center; +} diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index 1631a99..1893ad0 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -1,31 +1,105 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from 'react'; -import { dropTargetForElements, monitorForElements, draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { + useState, + useEffect, + useRef, + useCallback, + useMemo } from 'react'; +import { + dropTargetForElements, + monitorForElements, + draggable +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { motion, AnimatePresence } from 'framer-motion'; -import { useSwipeable } from 'react-swipeable'; +import { useSwipe } from '@/hooks/useSwipe'; import { format, addDays, startOfWeek, differenceInDays, parseISO } from 'date-fns'; import { useWindowSize } from '@/hooks/useWindowSize'; import EventModal from '@/components/EventModal'; import eventsData from '@/data/events'; -//import { Event, EventsByDate } from '@/types'; -import types from '@/types'; - -type Event = types.Event; -type EventsByDate = types.EventsByDate; +import { Event, EventsByDate } from '@/types'; const Calendar = () => { const { width } = useWindowSize(); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - setIsMobile(width < 768); // Update isMobile after hydration - }, [width]); + const [isMobile, setIsMobile] = useState(true); + const [isSwiping, setIsSwiping] = useState(false); + const [offset, setOffset] = useState(0); + const [tempDate, setTempDate] = useState(null); + const [direction, setDirection] = useState<'left' | 'right'>('left'); + const [isEventDragging, setIsEventDragging] = useState(false); + + const [currentDate, setCurrentDate] = useState(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); + }); - const [currentDate, setCurrentDate] = useState(new Date()); - const [activeDay, setActiveDay] = useState(0); const [selectedEvent, setSelectedEvent] = useState(null); const [events, setEvents] = useState(eventsData); + const [dragPreview, setDragPreview] = useState<{ + event: Event; + targetDate: string; + } | null>(null); + + const containerRef = useRef(null); + + const handleDragEnd = useCallback((event: any) => { + const { source, location } = event; + if (!location?.current?.dropTargets[0]) return; + + const dropTarget = location.current.dropTargets[0].data; + const movedEvent = Object.values(eventsRef.current) + .flat() + .find((e: Event) => e.id === source.data.id); + + if (movedEvent) { + const newEvents = { ...eventsRef.current }; + const sourceDate = source.data.date; + const targetDate = dropTarget.date; + + // Update the event's date when moving between weeks + const updatedEvent = { ...movedEvent, date: targetDate }; + + newEvents[sourceDate] = (newEvents[sourceDate] || []).filter((e: Event) => e.id !== movedEvent.id); + newEvents[targetDate] = [...(newEvents[targetDate] || []), updatedEvent]; + + setEvents(newEvents); + } + setDragPreview(null); + }, []); + + useEffect(() => { + const cleanup = monitorForElements({ + onDrop: handleDragEnd, + onDrag: ({ source, location }) => { + console.log('Drag source data:', source?.data); + console.log('Current drop targets:', location?.current?.dropTargets); + + if (!source?.data) { + console.warn('No drag data found. Source:', source); + setDragPreview(null); + return; + } + + const dropTarget = location?.current?.dropTargets[0]?.data; + if (dropTarget?.date) { + const movedEvent = Object.values(events) + .flat() + .find((e: Event) => e.id === source.data.id); + + if (movedEvent) { + setDragPreview({ + event: movedEvent, + targetDate: dropTarget.date + }); + } + } else { + setDragPreview(null); + } + } + }); + + return () => cleanup(); + }, [events, handleDragEnd]); const eventsRef = useRef(eventsData); eventsRef.current = events; @@ -37,6 +111,12 @@ const Calendar = () => { }; }, []); + useEffect(() => { + if (width !== undefined) { + setIsMobile(width < 768); + } + }, [width]); + const getWeekDays = (date: Date) => { const start = startOfWeek(date); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); @@ -67,57 +147,102 @@ const Calendar = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [handlePreviousWeek, handleNextWeek, selectedEvent]); - const handleDragEnd = useCallback((event: any) => { - const { source, location } = event; - if (!location?.current?.dropTargets[0]) return; + useEffect(() => { + const cleanup = monitorForElements({ + onDrop: handleDragEnd, + }); + return () => cleanup(); + }, [handleDragEnd]); - const dropTarget = location.current.dropTargets[0].data; - const movedEvent = Object.values(eventsRef.current) - .flat() - .find(e => e.id === source.data.id); + const handleSwipe = (dir: 'left' | 'right') => { + if (isMobile) { + setDirection(dir); + setCurrentDate(prev => dir === 'left' ? addDays(prev, 1) : addDays(prev, -1)); + } + }; - if (movedEvent) { - const newEvents = { ...eventsRef.current }; - const sourceDate = source.data.date; - const targetDate = dropTarget.date; + const { onTouchStart, onTouchMove, onTouchEnd, swipeDelta } = useSwipe(); - // Update the event's date when moving between weeks - const updatedEvent = { ...movedEvent, date: targetDate }; + const touchStartTime = useRef(Date.now()); - newEvents[sourceDate] = (newEvents[sourceDate] || []).filter(e => e.id !== movedEvent.id); - newEvents[targetDate] = [...(newEvents[targetDate] || []), updatedEvent]; + const handleTouchStart = (e: React.TouchEvent) => { + setIsSwiping(true); + touchStartTime.current = Date.now(); + onTouchStart(e); + }; - setEvents(newEvents); + const handleTouchMove = (e: React.TouchEvent) => { + if (isEventDragging || !isSwiping) return; + onTouchMove(e); + setOffset(swipeDelta); + + if (swipeDelta < -50 && !tempDate) { + setTempDate(addDays(currentDate, 1)); + } else if (swipeDelta > 50 && !tempDate) { + setTempDate(addDays(currentDate, -1)); } - }, []); + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + setIsSwiping(false); + onTouchEnd(e); + + const containerWidth = containerRef.current?.offsetWidth || window.innerWidth; + const targetIndex = Math.round(-offset / containerWidth); + const newDate = addDays(currentDate, targetIndex); + + // Animate to the new center position + setCurrentDate(newDate); + setOffset(0); + setTempDate(null); + }; useEffect(() => { - const cleanup = monitorForElements({ - onDrop: handleDragEnd, + const todayKey = format(new Date(), 'yyyy-MM-dd'); + if (!events[todayKey]) { + setEvents((prev: EventsByDate) => ({ + ...prev, + [todayKey]: [] + })); + } + }, []); + + const handleDayChange = useCallback((direction: 'left' | 'right') => { + setCurrentDate(prev => { + const newDate = addDays(prev, direction === 'left' ? -1 : 1); + // Reset dragging state to enable future swipes + setIsEventDragging(false); + // Smooth transition + setOffset(direction === 'left' ? -window.innerWidth : window.innerWidth); + setTimeout(() => setOffset(0), 10); + return newDate; }); - return () => cleanup(); - }, [handleDragEnd]); + }, []); - const swipeHandlers = useSwipeable({ - onSwipedLeft: (e) => { - if (!e.event.target?.closest?.('[data-draggable-event]')) { - setActiveDay(prev => Math.min(6, prev + 1)); - } - }, - onSwipedRight: (e) => { - if (!e.event.target?.closest?.('[data-draggable-event]')) { - setActiveDay(prev => Math.max(0, prev - 1)); + const handleEventMove = (eventId: string, newDate: Date) => { + setEvents(prev => { + const newEvents = { ...prev }; + const oldDateKey = Object.keys(newEvents).find(key => + newEvents[key].some((e: Event) => e.id === eventId) + ); + + if (oldDateKey) { + const event = newEvents[oldDateKey].find((e: Event) => e.id === eventId); + if (event) { + const newDateKey = format(newDate, 'yyyy-MM-dd'); + + // Remove from old date + newEvents[oldDateKey] = newEvents[oldDateKey].filter((e: Event) => e.id !== eventId); + + // Add to new date + newEvents[newDateKey] = [ + ...(newEvents[newDateKey] || []), + { ...event, date: newDateKey } + ]; + } } - }, - trackTouch: true, - delta: 50, // Minimum swipe distance - preventScrollOnSwipe: true - }); - - const handleSwipe = (dir: 'left' | 'right') => { - if (isMobile) { - setActiveDay(prev => dir === 'left' ? prev + 1 : prev - 1); - } + return newEvents; + }); }; return ( @@ -125,22 +250,66 @@ const Calendar = () => {
-
+
- {getWeekDays(currentDate).map((date, index) => ( - - ))} + {isMobile ? ( + + {[addDays(currentDate, -1), currentDate, addDays(currentDate, 1)].map((date, index) => ( + + setIsEventDragging(true)} + onDragEnd={() => setIsEventDragging(false)} + handleEventMove={handleEventMove} + onDayChange={handleDayChange} + /> + + ))} + + ) : ( + getWeekDays(currentDate).map((date, index) => ( + setIsEventDragging(true)} + onDragEnd={() => setIsEventDragging(false)} + handleEventMove={handleEventMove} + onDayChange={handleDayChange} + /> + )) + )}
@@ -156,51 +325,73 @@ const Calendar = () => { ); }; -const DayColumn = ({ date, events, index, activeDay, onEventClick }: any) => { +interface DayColumnProps { + date: Date; + events: Event[]; + isMobile: boolean; + index: number; + onEventClick: (event: Event) => void; + dragPreview: Event | null; + onDragStart: () => void; + onDragEnd: () => void; + handleEventMove: (eventId: string, newDate: Date) => void; + onDayChange: (direction: 'left' | 'right') => void; +} + +const PreviewComponent = ({ event }: { event: Event }) => ( + +

{event.title}

+

{event.time}

+
+); + +const DayColumn = ({ + date, + events, + dragPreview, + onDragStart, + onDragEnd, + onEventClick, + handleEventMove, + onDayChange +}: DayColumnProps) => { const columnRef = useRef(null); const { width } = useWindowSize(); const [isMobile, setIsMobile] = useState(false); // Default to false for SSR + const parseTimeToMinutes = (time: string) => { + const [timePart, modifier] = time.split(' '); + let [hours, minutes] = timePart.split(':').map(Number); + + if (modifier === 'PM' && hours !== 12) { + hours += 12; // Convert PM times to 24-hour format + } + if (modifier === 'AM' && hours === 12) { + hours = 0; // Handle midnight (12:00 AM) + } + + return hours * 60 + minutes; + }; + + const sortedEvents = useMemo(() => + [...events].sort((a, b) => + parseTimeToMinutes(a.time) - parseTimeToMinutes(b.time) + ), + [events] + ); + + useEffect(() => { setIsMobile(width < 768); // Update isMobile after hydration }, [width]); const dayOffset = differenceInDays(date, startOfWeek(date)); - const isActive = isMobile ? activeDay === dayOffset : true; - - //useEffect(() => { - //const element = document.querySelector(`[data-day="${dayOffset}"]`); - //if (!element) return; - - //return dropTargetForElements({ - //element, - //getData: () => ({ date: format(date, 'yyyy-MM-dd') }), - //}); - //}, [date, dayOffset]); - - //return ( - // - //
- //
- //{format(date, 'EEE, MMM d')} - //
- //{events.map((event: Event) => ( - // - //))} - //
- //
- //); + const isActive = isMobile ? true : true; useEffect(() => { const element = columnRef.current; @@ -212,31 +403,45 @@ const DayColumn = ({ date, events, index, activeDay, onEventClick }: any) => { }); return cleanup; - }, [date, activeDay]); // Add activeDay to dependencies + }, [date]); + + const previewPosition = useMemo(() => { + if (!dragPreview) return -1; + + const previewMinutes = parseTimeToMinutes(dragPreview.time); + + return sortedEvents.findIndex(event => { + const eventMinutes = parseTimeToMinutes(event.time); + return eventMinutes >= previewMinutes; + }); + }, [sortedEvents, dragPreview]); return ( -
-
- {format(date, 'EEE, MMM d')} -
- {events.map((event: Event) => ( +
+ {format(date, 'EEE, MMM d')} +
+ +
+ {sortedEvents.map((event, index) => ( { + // Update calendar view first + onDayChange(dir); + // Then move the event to the new date + const newDate = addDays(date, dir === 'left' ? -1 : 1); + handleEventMove(event.id, newDate); + }} /> ))}
@@ -244,83 +449,117 @@ const DayColumn = ({ date, events, index, activeDay, onEventClick }: any) => { ); }; -const DraggableEvent = ({ event, date, onEventClick }: { event: Event; date: string; onEventClick: (event: Event) => void }) => { +const DraggableEvent = ({ + event, + date, + onEventClick, + onDragStart, + onDragEnd, + onDayChange +}: { + event: Event; + date: string; + onEventClick: (event: Event) => void; + onDragStart: () => void; + onDragEnd: () => void; + onDayChange: (direction: 'left' | 'right') => void; +}) => { const ref = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const touchTimer = useRef(null); + const startPos = useRef({ x: 0, y: 0 }); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const lastEdgeTrigger = useRef(0); + + const handleTouchStart = (e: React.TouchEvent) => { + touchTimer.current = window.setTimeout(() => { + const touch = e.touches[0]; + startPos.current = { x: touch.clientX, y: touch.clientY }; + setIsDragging(true); + onDragStart(); + e.stopPropagation(); + }, 500); + }; - useEffect(() => { - const element = ref.current; - if (!element) return; + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging) return; + + const touch = e.touches[0]; + const newX = touch.clientX - startPos.current.x; + const newY = touch.clientY - startPos.current.y; + setPosition({ x: newX, y: newY }); + e.stopPropagation(); + + // Fixed edge detection logic + const screenWidth = window.innerWidth; + const now = Date.now(); + + if (touch.clientX < 50 && now - lastEdgeTrigger.current > 500) { + onDayChange('left'); // Changed to 'left' for previous day + lastEdgeTrigger.current = now; + } else if (touch.clientX > screenWidth - 50 && now - lastEdgeTrigger.current > 500) { + onDayChange('right'); // Changed to 'right' for next day + lastEdgeTrigger.current = now; + } + }; - return draggable({ - element, - getInitialData: () => ({ id: event.id, date }), - onDragStart: () => { - element.style.zIndex = '9999'; - element.style.transform = 'scale(1.05)'; - element.style.boxShadow = '0 8px 16px rgba(0,0,0,0.2)'; - }, - onDrop: ({ location }) => { - element.style.zIndex = ''; - element.style.transform = ''; - element.style.boxShadow = ''; - - // Force reflow to ensure drop targets update - if (!location?.current?.dropTargets[0]) { - window.dispatchEvent(new Event('resize')); - } - } - }); - }, [event.id, date]); + const handleTouchEnd = () => { + if (touchTimer.current) { + clearTimeout(touchTimer.current); + touchTimer.current = null; + } + if (isDragging) { + setIsDragging(false); + onDragEnd(); + } + // Force reset dragging state + setTimeout(() => setIsEventDragging(false), 100); + }; return ( onEventClick(event)} - className="bg-white p-4 rounded shadow mb-2 select-none" - style={{ - touchAction: 'none', - cursor: 'grab', - }} + className="bg-white p-4 rounded shadow mb-2 cursor-grab active:cursor-grabbing transition-all relative" + whileHover={{ scale: 1.01 }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} > -

{event.title}

-

{event.time}

+ {/* Main event content */} +

{event.title}

+

{event.time}

+ + {/* Floating preview */} + {isDragging && ( + + )} ); }; const Header = ({ currentDate, - activeDay, - onPreviousWeek, - onNextWeek + isMobile }: { currentDate: Date; - activeDay: number; - onPreviousWeek: () => void; - onNextWeek: () => void; + isMobile: boolean; }) => { - const { width } = useWindowSize(); - const [isMobile, setIsMobile] = useState(false); // Default to false for SSR - - useEffect(() => { - setIsMobile(width < 768); // Update isMobile after hydration - }, [width]); - return (
- {isMobile ? ( -

- {format(addDays(startOfWeek(currentDate), activeDay), 'MMMM yyyy')} -

- ) : ( -
- -

{format(currentDate, 'MMMM yyyy')}

- -
- )} +

+ {format(currentDate, 'MMMM yyyy')} +

); diff --git a/src/data/events.ts b/src/data/events.ts index 25fc3c4..734fa39 100644 --- a/src/data/events.ts +++ b/src/data/events.ts @@ -1,7 +1,7 @@ import { EventsByDate } from '@/types'; const events: EventsByDate = { - "2025-03-16": [ + "2025-03-20": [ { id: "event-1", title: "Coffee with Alex", @@ -21,7 +21,7 @@ const events: EventsByDate = { time: "02:00 PM", }, ], - "2025-03-17": [ + "2025-03-21": [ { id: "event-3", title: "Yoga Session", @@ -41,7 +41,7 @@ const events: EventsByDate = { time: "03:30 PM", }, ], - "2025-03-18": [ + "2025-03-22": [ { id: "event-5", title: "Client Meeting", diff --git a/src/hooks/useSwipe.ts b/src/hooks/useSwipe.ts new file mode 100644 index 0000000..7a77875 --- /dev/null +++ b/src/hooks/useSwipe.ts @@ -0,0 +1,35 @@ +import { useRef, useState } from 'react'; + +type SwipeDirection = 'left' | 'right'; +type SwipeHandler = (direction: 'left' | 'right', delta: number) => void; + +const SWIPE_THRESHOLD = 50; // Minimum distance to consider a swipe + +export function useSwipe(onSwipe?: SwipeHandler) { + const touchStart = useRef({ x: 0, y: 0 }); + const [swipeDelta, setSwipeDelta] = useState(0); + + const onTouchStart = (e: React.TouchEvent) => { + touchStart.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; + }; + + const onTouchMove = (e: React.TouchEvent) => { + const deltaX = e.touches[0].clientX - touchStart.current.x; + setSwipeDelta(deltaX); + }; + + const onTouchEnd = (e: React.TouchEvent) => { + const deltaX = e.changedTouches[0].clientX - touchStart.current.x; + const deltaY = e.changedTouches[0].clientY - touchStart.current.y; + + if (Math.abs(deltaX) > SWIPE_THRESHOLD && Math.abs(deltaX) > Math.abs(deltaY)) { + onSwipe?.(deltaX > 0 ? 'right' : 'left', deltaX); + } + setSwipeDelta(0); + }; + + return { onTouchStart, onTouchMove, onTouchEnd, swipeDelta }; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..caccbcd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +export interface Event { + id: string; + title: string; + time: string; + date: string; + // add other properties as needed +} + +export type EventsByDate = { + [date: string]: Event[]; +}; \ No newline at end of file