Skip to content

Commit d4dfd7f

Browse files
committed
feat: implement drag-and-drop functionality with zones and items
1 parent 0119f39 commit d4dfd7f

7 files changed

Lines changed: 652 additions & 0 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"next-themes": "^0.4.6",
4343
"react": "^18.3.1",
4444
"react-countdown": "^2.3.6",
45+
"react-dnd": "^16.0.1",
46+
"react-dnd-html5-backend": "^16.0.1",
4547
"react-dom": "^18.3.1",
4648
"react-hook-form": "^7.60.0",
4749
"react-hot-toast": "^2.6.0",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { DndProvider } from 'react-dnd';
5+
import { HTML5Backend } from 'react-dnd-html5-backend';
6+
import { useDragDrop } from '../../hooks/useDragDrop';
7+
import { DragDropItem, DragDropZone } from '../../utils/dragDropUtils';
8+
import { DragPreview } from './DragPreview';
9+
import { DropZones } from './DropZones';
10+
11+
interface DragDropContainerProps {
12+
title?: string;
13+
subtitle?: string;
14+
zones: DragDropZone[];
15+
items: DragDropItem[];
16+
storageKey?: string;
17+
autoSaveDelay?: number;
18+
onAutoSave?: (state: Record<string, DragDropItem[]>) => void | Promise<void>;
19+
}
20+
21+
export const DragDropContainer = ({
22+
title = 'Course Content Organizer',
23+
subtitle = 'Drag lessons, quizzes, and resources across zones. Changes auto-save.',
24+
zones,
25+
items,
26+
storageKey,
27+
autoSaveDelay,
28+
onAutoSave,
29+
}: DragDropContainerProps) => {
30+
const { state, isSaving, lastSavedAt, saveError, reorderInZone, moveToZone, saveNow, resetState } =
31+
useDragDrop({
32+
zones,
33+
items,
34+
storageKey,
35+
autoSaveDelay,
36+
onAutoSave,
37+
});
38+
39+
return (
40+
<DndProvider backend={HTML5Backend}>
41+
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5 md:p-6">
42+
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
43+
<div>
44+
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
45+
<p className="text-sm text-slate-600">{subtitle}</p>
46+
</div>
47+
48+
<div className="flex items-center gap-2 text-xs">
49+
<span
50+
className={`rounded px-2 py-1 font-medium ${
51+
saveError
52+
? 'bg-red-100 text-red-700'
53+
: isSaving
54+
? 'bg-amber-100 text-amber-700'
55+
: 'bg-emerald-100 text-emerald-700'
56+
}`}
57+
>
58+
{saveError
59+
? `Save error: ${saveError}`
60+
: isSaving
61+
? 'Saving...'
62+
: lastSavedAt
63+
? `Saved ${new Date(lastSavedAt).toLocaleTimeString()}`
64+
: 'Ready'}
65+
</span>
66+
<button
67+
type="button"
68+
className="rounded border border-slate-300 bg-white px-2 py-1 text-slate-700 transition hover:bg-slate-100"
69+
onClick={() => void saveNow()}
70+
>
71+
Save now
72+
</button>
73+
<button
74+
type="button"
75+
className="rounded border border-slate-300 bg-white px-2 py-1 text-slate-700 transition hover:bg-slate-100"
76+
onClick={resetState}
77+
>
78+
Reset
79+
</button>
80+
</div>
81+
</div>
82+
83+
<DropZones zones={zones} state={state} onReorder={reorderInZone} onMoveToZone={moveToZone} />
84+
</div>
85+
<DragPreview />
86+
</DndProvider>
87+
);
88+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useDragLayer } from 'react-dnd';
5+
6+
interface DragPreviewProps {
7+
getItemTitle?: (item: unknown) => string;
8+
}
9+
10+
export const DragPreview = ({ getItemTitle }: DragPreviewProps) => {
11+
const { item, isDragging, currentOffset } = useDragLayer((monitor) => ({
12+
item: monitor.getItem(),
13+
isDragging: monitor.isDragging(),
14+
currentOffset: monitor.getSourceClientOffset(),
15+
}));
16+
17+
if (!isDragging || !currentOffset) {
18+
return null;
19+
}
20+
21+
const title = getItemTitle
22+
? getItemTitle(item)
23+
: typeof item === 'object' && item !== null && 'title' in item
24+
? String((item as { title: string }).title)
25+
: 'Moving item';
26+
27+
return (
28+
<div className="pointer-events-none fixed inset-0 z-50">
29+
<div
30+
className="rounded-md border border-sky-300 bg-sky-50 px-3 py-2 text-sm font-medium text-sky-900 shadow-lg"
31+
style={{
32+
transform: `translate(${currentOffset.x + 8}px, ${currentOffset.y + 8}px)`,
33+
position: 'absolute',
34+
}}
35+
>
36+
{title}
37+
</div>
38+
</div>
39+
);
40+
};
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useDrop } from 'react-dnd';
5+
import { DragDropState, DragDropZone } from '../../utils/dragDropUtils';
6+
import { DRAG_ITEM_TYPE, SortableList } from './SortableList';
7+
8+
interface DropZonesProps {
9+
zones: DragDropZone[];
10+
state: DragDropState;
11+
onReorder: (zoneId: string, fromIndex: number, toIndex: number) => void;
12+
onMoveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void;
13+
}
14+
15+
interface DragPayload {
16+
id: string;
17+
fromZoneId: string;
18+
index: number;
19+
}
20+
21+
const ZonePanel = ({
22+
zone,
23+
itemsCount,
24+
children,
25+
onDropToZone,
26+
}: {
27+
zone: DragDropZone;
28+
itemsCount: number;
29+
children: React.ReactNode;
30+
onDropToZone: (itemId: string, fromZoneId: string, toZoneId: string) => void;
31+
}) => {
32+
const [{ isOver, canDrop }, drop] = useDrop(
33+
() => ({
34+
accept: DRAG_ITEM_TYPE,
35+
drop: (dragged: DragPayload, monitor) => {
36+
if (monitor.didDrop()) {
37+
return;
38+
}
39+
if (dragged.fromZoneId !== zone.id) {
40+
onDropToZone(dragged.id, dragged.fromZoneId, zone.id);
41+
dragged.fromZoneId = zone.id;
42+
dragged.index = itemsCount;
43+
}
44+
},
45+
collect: (monitor) => ({
46+
isOver: monitor.isOver({ shallow: true }),
47+
canDrop: monitor.canDrop(),
48+
}),
49+
}),
50+
[itemsCount, onDropToZone, zone.id],
51+
);
52+
53+
return (
54+
<section
55+
ref={drop}
56+
className={`rounded-xl border p-4 transition ${
57+
isOver && canDrop ? 'border-sky-400 bg-sky-50' : 'border-slate-200 bg-white'
58+
}`}
59+
>
60+
<header className="mb-3 flex items-center justify-between">
61+
<div>
62+
<h3 className="text-sm font-semibold text-slate-800">{zone.label}</h3>
63+
{zone.description ? <p className="text-xs text-slate-500">{zone.description}</p> : null}
64+
</div>
65+
<span className="rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">{itemsCount}</span>
66+
</header>
67+
{children}
68+
</section>
69+
);
70+
};
71+
72+
export const DropZones = ({ zones, state, onReorder, onMoveToZone }: DropZonesProps) => {
73+
return (
74+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
75+
{zones.map((zone) => {
76+
const items = state[zone.id] ?? [];
77+
78+
return (
79+
<ZonePanel
80+
key={zone.id}
81+
zone={zone}
82+
itemsCount={items.length}
83+
onDropToZone={(itemId, fromZoneId, toZoneId) =>
84+
onMoveToZone(itemId, fromZoneId, toZoneId, items.length)
85+
}
86+
>
87+
<SortableList
88+
zoneId={zone.id}
89+
items={items}
90+
onReorder={onReorder}
91+
onMoveToZone={onMoveToZone}
92+
/>
93+
</ZonePanel>
94+
);
95+
})}
96+
</div>
97+
);
98+
};
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use client';
2+
3+
import React, { useRef } from 'react';
4+
import { useDrag, useDrop } from 'react-dnd';
5+
import { DragDropItem } from '../../utils/dragDropUtils';
6+
7+
export const DRAG_ITEM_TYPE = 'COURSE_CONTENT_ITEM';
8+
9+
interface SortableListProps {
10+
zoneId: string;
11+
items: DragDropItem[];
12+
onReorder: (zoneId: string, fromIndex: number, toIndex: number) => void;
13+
onMoveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void;
14+
emptyText?: string;
15+
}
16+
17+
interface DragPayload {
18+
id: string;
19+
fromZoneId: string;
20+
index: number;
21+
title: string;
22+
}
23+
24+
const SortableRow = ({
25+
item,
26+
index,
27+
zoneId,
28+
onReorder,
29+
onMoveToZone,
30+
}: {
31+
item: DragDropItem;
32+
index: number;
33+
zoneId: string;
34+
onReorder: (zoneId: string, fromIndex: number, toIndex: number) => void;
35+
onMoveToZone: (itemId: string, fromZoneId: string, toZoneId: string, toIndex?: number) => void;
36+
}) => {
37+
const ref = useRef<HTMLDivElement>(null);
38+
39+
const [{ isDragging }, drag] = useDrag(() => ({
40+
type: DRAG_ITEM_TYPE,
41+
item: {
42+
id: item.id,
43+
fromZoneId: zoneId,
44+
index,
45+
title: item.title,
46+
} satisfies DragPayload,
47+
collect: (monitor) => ({
48+
isDragging: monitor.isDragging(),
49+
}),
50+
}), [index, item.id, item.title, zoneId]);
51+
52+
const [, drop] = useDrop(
53+
() => ({
54+
accept: DRAG_ITEM_TYPE,
55+
hover: (dragged: DragPayload, monitor) => {
56+
if (!ref.current) {
57+
return;
58+
}
59+
60+
if (dragged.fromZoneId !== zoneId) {
61+
return;
62+
}
63+
64+
if (dragged.index === index) {
65+
return;
66+
}
67+
68+
const hoverRect = ref.current.getBoundingClientRect();
69+
const hoverMiddleY = (hoverRect.bottom - hoverRect.top) / 2;
70+
const clientOffset = monitor.getClientOffset();
71+
if (!clientOffset) {
72+
return;
73+
}
74+
75+
const hoverClientY = clientOffset.y - hoverRect.top;
76+
77+
if (dragged.index < index && hoverClientY < hoverMiddleY) {
78+
return;
79+
}
80+
if (dragged.index > index && hoverClientY > hoverMiddleY) {
81+
return;
82+
}
83+
84+
onReorder(zoneId, dragged.index, index);
85+
dragged.index = index;
86+
},
87+
drop: (dragged: DragPayload) => {
88+
if (dragged.fromZoneId !== zoneId) {
89+
onMoveToZone(dragged.id, dragged.fromZoneId, zoneId, index);
90+
dragged.fromZoneId = zoneId;
91+
dragged.index = index;
92+
}
93+
},
94+
}),
95+
[index, onMoveToZone, onReorder, zoneId],
96+
);
97+
98+
drag(drop(ref));
99+
100+
return (
101+
<div
102+
ref={ref}
103+
className={`mb-2 rounded-md border bg-white px-3 py-2 text-sm shadow-sm transition ${
104+
isDragging ? 'opacity-50' : 'opacity-100'
105+
}`}
106+
>
107+
<div className="font-medium text-slate-800">{item.title}</div>
108+
<div className="mt-1 text-xs text-slate-500">#{item.order + 1}</div>
109+
</div>
110+
);
111+
};
112+
113+
export const SortableList = ({
114+
zoneId,
115+
items,
116+
onReorder,
117+
onMoveToZone,
118+
emptyText = 'Drop content here',
119+
}: SortableListProps) => {
120+
if (items.length === 0) {
121+
return (
122+
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-sm text-slate-500">
123+
{emptyText}
124+
</div>
125+
);
126+
}
127+
128+
return (
129+
<div>
130+
{items.map((item, index) => (
131+
<SortableRow
132+
key={item.id}
133+
item={item}
134+
index={index}
135+
zoneId={zoneId}
136+
onReorder={onReorder}
137+
onMoveToZone={onMoveToZone}
138+
/>
139+
))}
140+
</div>
141+
);
142+
};

0 commit comments

Comments
 (0)