Browse Source

Drag events between days on mobile

main
Kevin Mok 6 days ago
parent
commit
6d592a6dfc
  1. 38
      src/app/globals.css
  2. 585
      src/components/Calendar.tsx
  3. 6
      src/data/events.ts
  4. 35
      src/hooks/useSwipe.ts
  5. 11
      src/types.ts

38
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;
}

585
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<Date | null>(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<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,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 = () => {
<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);
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 (
<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>
);

6
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",

35
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 };
}

11
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[];
};
Loading…
Cancel
Save