From d4dfd7f9d4e0bea404219fa085287050081d33ba Mon Sep 17 00:00:00 2001 From: jaymhorsh Date: Fri, 27 Mar 2026 08:43:13 +0100 Subject: [PATCH] feat: implement drag-and-drop functionality with zones and items --- package.json | 2 + .../drag-drop/DragDropContainer.tsx | 88 ++++++++++ src/components/drag-drop/DragPreview.tsx | 40 +++++ src/components/drag-drop/DropZones.tsx | 98 +++++++++++ src/components/drag-drop/SortableList.tsx | 142 +++++++++++++++ src/hooks/useDragDrop.tsx | 163 ++++++++++++++++++ src/utils/dragDropUtils.ts | 119 +++++++++++++ 7 files changed, 652 insertions(+) create mode 100644 src/components/drag-drop/DragDropContainer.tsx create mode 100644 src/components/drag-drop/DragPreview.tsx create mode 100644 src/components/drag-drop/DropZones.tsx create mode 100644 src/components/drag-drop/SortableList.tsx create mode 100644 src/hooks/useDragDrop.tsx create mode 100644 src/utils/dragDropUtils.ts diff --git a/package.json b/package.json index 3052422..af80b0f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "next-themes": "^0.4.6", "react": "^18.3.1", "react-countdown": "^2.3.6", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-hook-form": "^7.60.0", "react-hot-toast": "^2.6.0", diff --git a/src/components/drag-drop/DragDropContainer.tsx b/src/components/drag-drop/DragDropContainer.tsx new file mode 100644 index 0000000..a66abbe --- /dev/null +++ b/src/components/drag-drop/DragDropContainer.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { useDragDrop } from '../../hooks/useDragDrop'; +import { DragDropItem, DragDropZone } from '../../utils/dragDropUtils'; +import { DragPreview } from './DragPreview'; +import { DropZones } from './DropZones'; + +interface DragDropContainerProps { + title?: string; + subtitle?: string; + zones: DragDropZone[]; + items: DragDropItem[]; + storageKey?: string; + autoSaveDelay?: number; + onAutoSave?: (state: Record) => void | Promise; +} + +export const DragDropContainer = ({ + title = 'Course Content Organizer', + subtitle = 'Drag lessons, quizzes, and resources across zones. Changes auto-save.', + zones, + items, + storageKey, + autoSaveDelay, + onAutoSave, +}: DragDropContainerProps) => { + const { state, isSaving, lastSavedAt, saveError, reorderInZone, moveToZone, saveNow, resetState } = + useDragDrop({ + zones, + items, + storageKey, + autoSaveDelay, + onAutoSave, + }); + + return ( + +
+
+
+

{title}

+

{subtitle}

+
+ +
+ + {saveError + ? `Save error: ${saveError}` + : isSaving + ? 'Saving...' + : lastSavedAt + ? `Saved ${new Date(lastSavedAt).toLocaleTimeString()}` + : 'Ready'} + + + +
+
+ + +
+ +
+ ); +}; diff --git a/src/components/drag-drop/DragPreview.tsx b/src/components/drag-drop/DragPreview.tsx new file mode 100644 index 0000000..5a58911 --- /dev/null +++ b/src/components/drag-drop/DragPreview.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import { useDragLayer } from 'react-dnd'; + +interface DragPreviewProps { + getItemTitle?: (item: unknown) => string; +} + +export const DragPreview = ({ getItemTitle }: DragPreviewProps) => { + const { item, isDragging, currentOffset } = useDragLayer((monitor) => ({ + item: monitor.getItem(), + isDragging: monitor.isDragging(), + currentOffset: monitor.getSourceClientOffset(), + })); + + if (!isDragging || !currentOffset) { + return null; + } + + const title = getItemTitle + ? getItemTitle(item) + : typeof item === 'object' && item !== null && 'title' in item + ? String((item as { title: string }).title) + : 'Moving item'; + + return ( +
+
+ {title} +
+
+ ); +}; diff --git a/src/components/drag-drop/DropZones.tsx b/src/components/drag-drop/DropZones.tsx new file mode 100644 index 0000000..66fa3a5 --- /dev/null +++ b/src/components/drag-drop/DropZones.tsx @@ -0,0 +1,98 @@ +'use client'; + +import React from 'react'; +import { useDrop } from 'react-dnd'; +import { DragDropState, DragDropZone } from '../../utils/dragDropUtils'; +import { DRAG_ITEM_TYPE, SortableList } from './SortableList'; + +interface DropZonesProps { + zones: DragDropZone[]; + state: DragDropState; + onReorder: (zoneId: string, fromIndex: number, toIndex: number) => void; + onMoveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void; +} + +interface DragPayload { + id: string; + fromZoneId: string; + index: number; +} + +const ZonePanel = ({ + zone, + itemsCount, + children, + onDropToZone, +}: { + zone: DragDropZone; + itemsCount: number; + children: React.ReactNode; + onDropToZone: (itemId: string, fromZoneId: string, toZoneId: string) => void; +}) => { + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DRAG_ITEM_TYPE, + drop: (dragged: DragPayload, monitor) => { + if (monitor.didDrop()) { + return; + } + if (dragged.fromZoneId !== zone.id) { + onDropToZone(dragged.id, dragged.fromZoneId, zone.id); + dragged.fromZoneId = zone.id; + dragged.index = itemsCount; + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), + [itemsCount, onDropToZone, zone.id], + ); + + return ( +
+
+
+

{zone.label}

+ {zone.description ?

{zone.description}

: null} +
+ {itemsCount} +
+ {children} +
+ ); +}; + +export const DropZones = ({ zones, state, onReorder, onMoveToZone }: DropZonesProps) => { + return ( +
+ {zones.map((zone) => { + const items = state[zone.id] ?? []; + + return ( + + onMoveToZone(itemId, fromZoneId, toZoneId, items.length) + } + > + + + ); + })} +
+ ); +}; diff --git a/src/components/drag-drop/SortableList.tsx b/src/components/drag-drop/SortableList.tsx new file mode 100644 index 0000000..0a81b79 --- /dev/null +++ b/src/components/drag-drop/SortableList.tsx @@ -0,0 +1,142 @@ +'use client'; + +import React, { useRef } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; +import { DragDropItem } from '../../utils/dragDropUtils'; + +export const DRAG_ITEM_TYPE = 'COURSE_CONTENT_ITEM'; + +interface SortableListProps { + zoneId: string; + items: DragDropItem[]; + onReorder: (zoneId: string, fromIndex: number, toIndex: number) => void; + onMoveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void; + emptyText?: string; +} + +interface DragPayload { + id: string; + fromZoneId: string; + index: number; + title: string; +} + +const SortableRow = ({ + item, + index, + zoneId, + onReorder, + onMoveToZone, +}: { + item: DragDropItem; + index: number; + zoneId: string; + onReorder: (zoneId: string, fromIndex: number, toIndex: number) => void; + onMoveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void; +}) => { + const ref = useRef(null); + + const [{ isDragging }, drag] = useDrag(() => ({ + type: DRAG_ITEM_TYPE, + item: { + id: item.id, + fromZoneId: zoneId, + index, + title: item.title, + } satisfies DragPayload, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), [index, item.id, item.title, zoneId]); + + const [, drop] = useDrop( + () => ({ + accept: DRAG_ITEM_TYPE, + hover: (dragged: DragPayload, monitor) => { + if (!ref.current) { + return; + } + + if (dragged.fromZoneId !== zoneId) { + return; + } + + if (dragged.index === index) { + return; + } + + const hoverRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverRect.bottom - hoverRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) { + return; + } + + const hoverClientY = clientOffset.y - hoverRect.top; + + if (dragged.index < index && hoverClientY < hoverMiddleY) { + return; + } + if (dragged.index > index && hoverClientY > hoverMiddleY) { + return; + } + + onReorder(zoneId, dragged.index, index); + dragged.index = index; + }, + drop: (dragged: DragPayload) => { + if (dragged.fromZoneId !== zoneId) { + onMoveToZone(dragged.id, dragged.fromZoneId, zoneId, index); + dragged.fromZoneId = zoneId; + dragged.index = index; + } + }, + }), + [index, onMoveToZone, onReorder, zoneId], + ); + + drag(drop(ref)); + + return ( +
+
{item.title}
+
#{item.order + 1}
+
+ ); +}; + +export const SortableList = ({ + zoneId, + items, + onReorder, + onMoveToZone, + emptyText = 'Drop content here', +}: SortableListProps) => { + if (items.length === 0) { + return ( +
+ {emptyText} +
+ ); + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}; diff --git a/src/hooks/useDragDrop.tsx b/src/hooks/useDragDrop.tsx new file mode 100644 index 0000000..95ab77c --- /dev/null +++ b/src/hooks/useDragDrop.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + createInitialState, + DragDropItem, + DragDropState, + DragDropZone, + moveItemBetweenZones, + reorderItems, +} from '../utils/dragDropUtils'; + +interface UseDragDropOptions { + zones: DragDropZone[]; + items: DragDropItem[]; + storageKey?: string; + autoSaveDelay?: number; + onAutoSave?: (state: DragDropState) => void | Promise; +} + +interface UseDragDropReturn { + state: DragDropState; + isSaving: boolean; + lastSavedAt: number | null; + saveError: string | null; + reorderInZone: (zoneId: string, fromIndex: number, toIndex: number) => void; + moveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void; + resetState: () => void; + saveNow: () => Promise; +} + +const defaultStorageKey = 'teachlink.dragdrop.state'; + +export const useDragDrop = ({ + zones, + items, + storageKey = defaultStorageKey, + autoSaveDelay = 700, + onAutoSave, +}: UseDragDropOptions): UseDragDropReturn => { + const initialState = useMemo(() => createInitialState(zones, items), [zones, items]); + + const [state, setState] = useState(initialState); + const [isSaving, setIsSaving] = useState(false); + const [lastSavedAt, setLastSavedAt] = useState(null); + const [saveError, setSaveError] = useState(null); + + const timeoutRef = useRef | null>(null); + + const persistState = useCallback( + async (nextState: DragDropState) => { + setIsSaving(true); + setSaveError(null); + + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem(storageKey, JSON.stringify(nextState)); + } + + if (onAutoSave) { + await onAutoSave(nextState); + } + + setLastSavedAt(Date.now()); + } catch (error) { + setSaveError(error instanceof Error ? error.message : 'Failed to save drag-and-drop state'); + } finally { + setIsSaving(false); + } + }, + [onAutoSave, storageKey], + ); + + const queueAutoSave = useCallback( + (nextState: DragDropState) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + void persistState(nextState); + }, autoSaveDelay); + }, + [autoSaveDelay, persistState], + ); + + const updateState = useCallback( + (updater: (prev: DragDropState) => DragDropState) => { + setState((prev) => { + const next = updater(prev); + queueAutoSave(next); + return next; + }); + }, + [queueAutoSave], + ); + + const reorderInZone = useCallback( + (zoneId: string, fromIndex: number, toIndex: number) => { + updateState((prev) => { + const zoneItems = prev[zoneId] ?? []; + return { + ...prev, + [zoneId]: reorderItems(zoneItems, fromIndex, toIndex), + }; + }); + }, + [updateState], + ); + + const moveToZone = useCallback( + (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => { + updateState((prev) => moveItemBetweenZones(prev, itemId, fromZoneId, toZoneId, toIndex)); + }, + [updateState], + ); + + const resetState = useCallback(() => { + setState(initialState); + queueAutoSave(initialState); + }, [initialState, queueAutoSave]); + + const saveNow = useCallback(async () => { + await persistState(state); + }, [persistState, state]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return; + } + + try { + const restored = JSON.parse(raw) as DragDropState; + setState(restored); + } catch { + setState(initialState); + } + }, [initialState, storageKey]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { + state, + isSaving, + lastSavedAt, + saveError, + reorderInZone, + moveToZone, + resetState, + saveNow, + }; +}; diff --git a/src/utils/dragDropUtils.ts b/src/utils/dragDropUtils.ts new file mode 100644 index 0000000..0e81d3f --- /dev/null +++ b/src/utils/dragDropUtils.ts @@ -0,0 +1,119 @@ +export interface DragDropItem { + id: string; + title: string; + type?: string; + zoneId: string; + order: number; + metadata?: Record; +} + +export interface DragDropZone { + id: string; + label: string; + description?: string; + accepts?: string[]; +} + +export type DragDropState = Record; + +export const sortItemsByOrder = (items: DragDropItem[]): DragDropItem[] => { + return [...items].sort((a, b) => a.order - b.order); +}; + +export const normalizeOrder = (items: DragDropItem[]): DragDropItem[] => { + return items.map((item, index) => ({ + ...item, + order: index, + })); +}; + +export const reorderItems = ( + items: DragDropItem[], + fromIndex: number, + toIndex: number, +): DragDropItem[] => { + if (fromIndex === toIndex) { + return normalizeOrder(items); + } + + const updated = [...items]; + const [moved] = updated.splice(fromIndex, 1); + if (!moved) { + return normalizeOrder(items); + } + updated.splice(toIndex, 0, moved); + + return normalizeOrder(updated); +}; + +export const moveItemBetweenZones = ( + state: DragDropState, + itemId: string, + fromZoneId: string, + toZoneId: string, + toIndex?: number, +): DragDropState => { + if (!state[fromZoneId] || !state[toZoneId]) { + return state; + } + + const sourceItems = [...state[fromZoneId]]; + const destinationItems = fromZoneId === toZoneId ? sourceItems : [...state[toZoneId]]; + + const sourceIndex = sourceItems.findIndex((item) => item.id === itemId); + if (sourceIndex === -1) { + return state; + } + + const [movedItem] = sourceItems.splice(sourceIndex, 1); + if (!movedItem) { + return state; + } + + const insertAt = + typeof toIndex === 'number' + ? Math.max(0, Math.min(toIndex, destinationItems.length)) + : destinationItems.length; + + destinationItems.splice(insertAt, 0, { + ...movedItem, + zoneId: toZoneId, + }); + + if (fromZoneId === toZoneId) { + return { + ...state, + [toZoneId]: normalizeOrder(destinationItems), + }; + } + + return { + ...state, + [fromZoneId]: normalizeOrder(sourceItems), + [toZoneId]: normalizeOrder(destinationItems), + }; +}; + +export const createInitialState = ( + zones: DragDropZone[], + items: DragDropItem[], +): DragDropState => { + const state: DragDropState = {}; + + zones.forEach((zone) => { + state[zone.id] = []; + }); + + items.forEach((item) => { + if (!state[item.zoneId]) { + state[item.zoneId] = []; + } + state[item.zoneId].push(item); + }); + + Object.keys(state).forEach((zoneId) => { + state[zoneId] = normalizeOrder(sortItemsByOrder(state[zoneId])); + }); + + return state; +};