Drag events between days on mobile
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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(() => {
|
||||
setIsMobile(width < 768); // Update isMobile after hydration
|
||||
}, [width]);
|
||||
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<Event | null>(null);
|
||||
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);
|
||||
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,30 +147,6 @@ 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;
|
||||
|
||||
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(() => {
|
||||
const cleanup = monitorForElements({
|
||||
onDrop: handleDragEnd,
|
||||
@@ -98,49 +154,162 @@ const Calendar = () => {
|
||||
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));
|
||||
}
|
||||
},
|
||||
trackTouch: true,
|
||||
delta: 50, // Minimum swipe distance
|
||||
preventScrollOnSwipe: true
|
||||
});
|
||||
|
||||
const handleSwipe = (dir: 'left' | 'right') => {
|
||||
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 (
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header
|
||||
currentDate={currentDate}
|
||||
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`}>
|
||||
{getWeekDays(currentDate).map((date, index) => (
|
||||
<DayColumn
|
||||
key={date.toISOString()}
|
||||
date={date}
|
||||
events={events[format(date, 'yyyy-MM-dd')] || []}
|
||||
isMobile={isMobile}
|
||||
index={index}
|
||||
activeDay={activeDay}
|
||||
onEventClick={setSelectedEvent}
|
||||
/>
|
||||
))}
|
||||
{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
|
||||
key={date.toISOString()}
|
||||
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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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 { 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 (
|
||||
//<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>
|
||||
//);
|
||||
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 (
|
||||
<motion.div
|
||||
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}
|
||||
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">
|
||||
{format(date, 'EEE, MMM d')}
|
||||
</div>
|
||||
{events.map((event: Event) => (
|
||||
<div className="font-bold mb-2 text-black text-xl">
|
||||
{format(date, 'EEE, MMM d')}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{sortedEvents.map((event, index) => (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
date={format(date, 'yyyy-MM-dd')}
|
||||
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>
|
||||
@@ -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 [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 element = ref.current;
|
||||
if (!element) return;
|
||||
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);
|
||||
};
|
||||
|
||||
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 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;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layoutId={event.id}
|
||||
onClick={() => 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}
|
||||
>
|
||||
<h3 className="font-medium">{event.title}</h3>
|
||||
<p className="text-sm text-gray-500">{event.time}</p>
|
||||
{/* Main event content */}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
{isMobile ? (
|
||||
<h2 className="text-xl font-bold">
|
||||
{format(addDays(startOfWeek(currentDate), activeDay), 'MMMM yyyy')}
|
||||
</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>
|
||||
)}
|
||||
<h2 className="text-xl font-bold text-black">
|
||||
{format(currentDate, 'MMMM yyyy')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
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