Drag events between days on mobile
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
/*color: var(--foreground);*/
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,3 +34,39 @@ body {
|
|||||||
touch-action: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +1,105 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import {
|
||||||
import { dropTargetForElements, monitorForElements, draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
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 { 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 { format, addDays, startOfWeek, differenceInDays, parseISO } from 'date-fns';
|
||||||
import { useWindowSize } from '@/hooks/useWindowSize';
|
import { useWindowSize } from '@/hooks/useWindowSize';
|
||||||
import EventModal from '@/components/EventModal';
|
import EventModal from '@/components/EventModal';
|
||||||
import eventsData from '@/data/events';
|
import eventsData from '@/data/events';
|
||||||
//import { Event, EventsByDate } from '@/types';
|
import { Event, EventsByDate } from '@/types';
|
||||||
import types from '@/types';
|
|
||||||
|
|
||||||
type Event = types.Event;
|
|
||||||
type EventsByDate = types.EventsByDate;
|
|
||||||
|
|
||||||
const Calendar = () => {
|
const Calendar = () => {
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(true);
|
||||||
|
const [isSwiping, setIsSwiping] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [tempDate, setTempDate] = useState<Date | null>(null);
|
||||||
|
const [direction, setDirection] = useState<'left' | 'right'>('left');
|
||||||
|
const [isEventDragging, setIsEventDragging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const [currentDate, setCurrentDate] = useState(() => {
|
||||||
setIsMobile(width < 768); // Update isMobile after hydration
|
const now = new Date();
|
||||||
}, [width]);
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
});
|
||||||
|
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
|
||||||
const [activeDay, setActiveDay] = useState(0);
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
const [events, setEvents] = useState<EventsByDate>(eventsData);
|
const [events, setEvents] = useState<EventsByDate>(eventsData);
|
||||||
|
const [dragPreview, setDragPreview] = useState<{
|
||||||
|
event: Event;
|
||||||
|
targetDate: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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);
|
const eventsRef = useRef(eventsData);
|
||||||
eventsRef.current = events;
|
eventsRef.current = events;
|
||||||
@@ -37,6 +111,12 @@ const Calendar = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (width !== undefined) {
|
||||||
|
setIsMobile(width < 768);
|
||||||
|
}
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
const getWeekDays = (date: Date) => {
|
const getWeekDays = (date: Date) => {
|
||||||
const start = startOfWeek(date);
|
const start = startOfWeek(date);
|
||||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||||
@@ -67,30 +147,6 @@ const Calendar = () => {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handlePreviousWeek, handleNextWeek, selectedEvent]);
|
}, [handlePreviousWeek, handleNextWeek, selectedEvent]);
|
||||||
|
|
||||||
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 => 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 => e.id !== movedEvent.id);
|
|
||||||
newEvents[targetDate] = [...(newEvents[targetDate] || []), updatedEvent];
|
|
||||||
|
|
||||||
setEvents(newEvents);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = monitorForElements({
|
const cleanup = monitorForElements({
|
||||||
onDrop: handleDragEnd,
|
onDrop: handleDragEnd,
|
||||||
@@ -98,49 +154,162 @@ const Calendar = () => {
|
|||||||
return () => cleanup();
|
return () => cleanup();
|
||||||
}, [handleDragEnd]);
|
}, [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));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trackTouch: true,
|
|
||||||
delta: 50, // Minimum swipe distance
|
|
||||||
preventScrollOnSwipe: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSwipe = (dir: 'left' | 'right') => {
|
const handleSwipe = (dir: 'left' | 'right') => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setActiveDay(prev => dir === 'left' ? prev + 1 : prev - 1);
|
setDirection(dir);
|
||||||
|
setCurrentDate(prev => dir === 'left' ? addDays(prev, 1) : addDays(prev, -1));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { onTouchStart, onTouchMove, onTouchEnd, swipeDelta } = useSwipe();
|
||||||
|
|
||||||
|
const touchStartTime = useRef(Date.now());
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
setIsSwiping(true);
|
||||||
|
touchStartTime.current = Date.now();
|
||||||
|
onTouchStart(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newEvents;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col">
|
||||||
<Header
|
<Header
|
||||||
currentDate={currentDate}
|
currentDate={currentDate}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
activeDay={activeDay}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div {...swipeHandlers} className="flex-1 overflow-hidden">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
<div className={`flex ${!isMobile && 'gap-4'} h-full p-4`}>
|
<div className={`flex ${!isMobile && 'gap-4'} h-full p-4`}>
|
||||||
{getWeekDays(currentDate).map((date, index) => (
|
{isMobile ? (
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full"
|
||||||
|
style={{
|
||||||
|
x: offset - (containerRef.current?.offsetWidth || 0),
|
||||||
|
width: '300%',
|
||||||
|
transition: isSwiping ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[addDays(currentDate, -1), currentDate, addDays(currentDate, 1)].map((date, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={date.toISOString()}
|
||||||
|
className="w-[100vw] flex-shrink-0 h-full px-2"
|
||||||
|
style={{
|
||||||
|
opacity: 1 - Math.abs(offset)/(containerRef.current?.offsetWidth || 1 * 0.5),
|
||||||
|
scale: 1 - Math.abs(offset)/(containerRef.current?.offsetWidth || 1 * 2),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DayColumn
|
||||||
|
date={date}
|
||||||
|
events={events[format(date, 'yyyy-MM-dd')] || []}
|
||||||
|
isMobile={isMobile}
|
||||||
|
index={index}
|
||||||
|
onEventClick={setSelectedEvent}
|
||||||
|
dragPreview={dragPreview?.targetDate === format(date, 'yyyy-MM-dd') ? dragPreview.event : null}
|
||||||
|
onDragStart={() => setIsEventDragging(true)}
|
||||||
|
onDragEnd={() => setIsEventDragging(false)}
|
||||||
|
handleEventMove={handleEventMove}
|
||||||
|
onDayChange={handleDayChange}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
getWeekDays(currentDate).map((date, index) => (
|
||||||
<DayColumn
|
<DayColumn
|
||||||
key={date.toISOString()}
|
key={date.toISOString()}
|
||||||
date={date}
|
date={date}
|
||||||
events={events[format(date, 'yyyy-MM-dd')] || []}
|
events={events[format(date, 'yyyy-MM-dd')] || []}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
index={index}
|
index={index}
|
||||||
activeDay={activeDay}
|
|
||||||
onEventClick={setSelectedEvent}
|
onEventClick={setSelectedEvent}
|
||||||
|
dragPreview={dragPreview?.targetDate === format(date, 'yyyy-MM-dd') ? dragPreview.event : null}
|
||||||
|
onDragStart={() => setIsEventDragging(true)}
|
||||||
|
onDragEnd={() => setIsEventDragging(false)}
|
||||||
|
handleEventMove={handleEventMove}
|
||||||
|
onDayChange={handleDayChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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 }) => (
|
||||||
|
<motion.div
|
||||||
|
className="bg-blue-100 p-4 rounded shadow mb-2 mx-1 border-2 border-blue-300 pointer-events-none"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 0.4, scale: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{event.time}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DayColumn = ({
|
||||||
|
date,
|
||||||
|
events,
|
||||||
|
dragPreview,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onEventClick,
|
||||||
|
handleEventMove,
|
||||||
|
onDayChange
|
||||||
|
}: DayColumnProps) => {
|
||||||
const columnRef = useRef<HTMLDivElement>(null);
|
const columnRef = useRef<HTMLDivElement>(null);
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
const [isMobile, setIsMobile] = useState(false); // Default to false for SSR
|
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(() => {
|
useEffect(() => {
|
||||||
setIsMobile(width < 768); // Update isMobile after hydration
|
setIsMobile(width < 768); // Update isMobile after hydration
|
||||||
}, [width]);
|
}, [width]);
|
||||||
|
|
||||||
const dayOffset = differenceInDays(date, startOfWeek(date));
|
const dayOffset = differenceInDays(date, startOfWeek(date));
|
||||||
const isActive = isMobile ? activeDay === dayOffset : true;
|
const isActive = isMobile ? true : 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 (
|
|
||||||
//<motion.div
|
|
||||||
//className={`flex-1 ${isMobile ? 'min-w-[90vw]' : ''}`}
|
|
||||||
//data-day={dayOffset}
|
|
||||||
//style={{ transform: `translateX(-${activeDay * 100}%)` }}
|
|
||||||
//animate={{ x: isMobile ? -activeDay * 100 + '%' : 0 }}
|
|
||||||
//transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
||||||
//>
|
|
||||||
//<div className="h-full bg-gray-50 rounded-lg p-2">
|
|
||||||
//<div className="font-bold mb-2">
|
|
||||||
//{format(date, 'EEE, MMM d')}
|
|
||||||
//</div>
|
|
||||||
//{events.map((event: Event) => (
|
|
||||||
//<DraggableEvent
|
|
||||||
//key={event.id}
|
|
||||||
//event={event}
|
|
||||||
//date={format(date, 'yyyy-MM-dd')}
|
|
||||||
//onEventClick={onEventClick}
|
|
||||||
///>
|
|
||||||
//))}
|
|
||||||
//</div>
|
|
||||||
//</motion.div>
|
|
||||||
//);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = columnRef.current;
|
const element = columnRef.current;
|
||||||
@@ -212,31 +403,45 @@ const DayColumn = ({ date, events, index, activeDay, onEventClick }: any) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return cleanup;
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={columnRef}
|
ref={columnRef}
|
||||||
className={`flex-1 ${isMobile ? 'min-w-[90vw]' : ''}`}
|
className={`flex-1 ${isMobile ? 'min-w-[calc(100vw-32px)]' : ''} bg-gray-50 rounded-lg p-2 relative`}
|
||||||
data-day={dayOffset}
|
data-day={dayOffset}
|
||||||
style={{
|
|
||||||
transform: `translateX(-${activeDay * 100}%)`,
|
|
||||||
// Ensure all columns are rendered in DOM for desktop
|
|
||||||
display: isMobile ? undefined : 'block'
|
|
||||||
}}
|
|
||||||
animate={{ x: isMobile ? -activeDay * 100 + '%' : 0 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
||||||
>
|
>
|
||||||
<div className="h-full bg-gray-50 rounded-lg p-2">
|
<div className="font-bold mb-2 text-black text-xl">
|
||||||
<div className="font-bold mb-2">
|
|
||||||
{format(date, 'EEE, MMM d')}
|
{format(date, 'EEE, MMM d')}
|
||||||
</div>
|
</div>
|
||||||
{events.map((event: Event) => (
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
{sortedEvents.map((event, index) => (
|
||||||
<DraggableEvent
|
<DraggableEvent
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
date={format(date, 'yyyy-MM-dd')}
|
date={format(date, 'yyyy-MM-dd')}
|
||||||
onEventClick={onEventClick}
|
onEventClick={onEventClick}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDayChange={(dir) => {
|
||||||
|
// 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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const touchTimer = useRef<number | null>(null);
|
||||||
|
const startPos = useRef({ x: 0, y: 0 });
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const lastEdgeTrigger = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
const element = ref.current;
|
touchTimer.current = window.setTimeout(() => {
|
||||||
if (!element) return;
|
const touch = e.touches[0];
|
||||||
|
startPos.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
setIsDragging(true);
|
||||||
|
onDragStart();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
return draggable({
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
element,
|
if (!isDragging) return;
|
||||||
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
|
const touch = e.touches[0];
|
||||||
if (!location?.current?.dropTargets[0]) {
|
const newX = touch.clientX - startPos.current.x;
|
||||||
window.dispatchEvent(new Event('resize'));
|
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;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (touchTimer.current) {
|
||||||
|
clearTimeout(touchTimer.current);
|
||||||
|
touchTimer.current = null;
|
||||||
}
|
}
|
||||||
});
|
if (isDragging) {
|
||||||
}, [event.id, date]);
|
setIsDragging(false);
|
||||||
|
onDragEnd();
|
||||||
|
}
|
||||||
|
// Force reset dragging state
|
||||||
|
setTimeout(() => setIsEventDragging(false), 100);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
layoutId={event.id}
|
layoutId={event.id}
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
className="bg-white p-4 rounded shadow mb-2 select-none"
|
className="bg-white p-4 rounded shadow mb-2 cursor-grab active:cursor-grabbing transition-all relative"
|
||||||
style={{
|
whileHover={{ scale: 1.01 }}
|
||||||
touchAction: 'none',
|
onTouchStart={handleTouchStart}
|
||||||
cursor: 'grab',
|
onTouchMove={handleTouchMove}
|
||||||
}}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<h3 className="font-medium">{event.title}</h3>
|
{/* Main event content */}
|
||||||
<p className="text-sm text-gray-500">{event.time}</p>
|
<h3 className="font-medium text-black">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-700">{event.time}</p>
|
||||||
|
|
||||||
|
{/* Floating preview */}
|
||||||
|
{isDragging && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-white rounded shadow-lg border-2 border-blue-500"
|
||||||
|
style={{
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Header = ({
|
const Header = ({
|
||||||
currentDate,
|
currentDate,
|
||||||
activeDay,
|
isMobile
|
||||||
onPreviousWeek,
|
|
||||||
onNextWeek
|
|
||||||
}: {
|
}: {
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
activeDay: number;
|
isMobile: boolean;
|
||||||
onPreviousWeek: () => void;
|
|
||||||
onNextWeek: () => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const { width } = useWindowSize();
|
|
||||||
const [isMobile, setIsMobile] = useState(false); // Default to false for SSR
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMobile(width < 768); // Update isMobile after hydration
|
|
||||||
}, [width]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{isMobile ? (
|
<h2 className="text-xl font-bold text-black">
|
||||||
<h2 className="text-xl font-bold">
|
{format(currentDate, 'MMMM yyyy')}
|
||||||
{format(addDays(startOfWeek(currentDate), activeDay), 'MMMM yyyy')}
|
|
||||||
</h2>
|
</h2>
|
||||||
) : (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button onClick={onPreviousWeek}>Previous Week</button>
|
|
||||||
<h2>{format(currentDate, 'MMMM yyyy')}</h2>
|
|
||||||
<button onClick={onNextWeek}>Next Week</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { EventsByDate } from '@/types';
|
import { EventsByDate } from '@/types';
|
||||||
|
|
||||||
const events: EventsByDate = {
|
const events: EventsByDate = {
|
||||||
"2025-03-16": [
|
"2025-03-20": [
|
||||||
{
|
{
|
||||||
id: "event-1",
|
id: "event-1",
|
||||||
title: "Coffee with Alex",
|
title: "Coffee with Alex",
|
||||||
@@ -21,7 +21,7 @@ const events: EventsByDate = {
|
|||||||
time: "02:00 PM",
|
time: "02:00 PM",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"2025-03-17": [
|
"2025-03-21": [
|
||||||
{
|
{
|
||||||
id: "event-3",
|
id: "event-3",
|
||||||
title: "Yoga Session",
|
title: "Yoga Session",
|
||||||
@@ -41,7 +41,7 @@ const events: EventsByDate = {
|
|||||||
time: "03:30 PM",
|
time: "03:30 PM",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"2025-03-18": [
|
"2025-03-22": [
|
||||||
{
|
{
|
||||||
id: "event-5",
|
id: "event-5",
|
||||||
title: "Client Meeting",
|
title: "Client Meeting",
|
||||||
|
|||||||
35
src/hooks/useSwipe.ts
Normal file
35
src/hooks/useSwipe.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
11
src/types.ts
Normal file
11
src/types.ts
Normal file
@@ -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[];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user