Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions src/components/drag-drop/DragDropContainer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, DragDropItem[]>) => void | Promise<void>;
}

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 (
<DndProvider backend={HTML5Backend}>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5 md:p-6">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
<p className="text-sm text-slate-600">{subtitle}</p>
</div>

<div className="flex items-center gap-2 text-xs">
<span
className={`rounded px-2 py-1 font-medium ${
saveError
? 'bg-red-100 text-red-700'
: isSaving
? 'bg-amber-100 text-amber-700'
: 'bg-emerald-100 text-emerald-700'
}`}
>
{saveError
? `Save error: ${saveError}`
: isSaving
? 'Saving...'
: lastSavedAt
? `Saved ${new Date(lastSavedAt).toLocaleTimeString()}`
: 'Ready'}
</span>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-1 text-slate-700 transition hover:bg-slate-100"
onClick={() => void saveNow()}
>
Save now
</button>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-1 text-slate-700 transition hover:bg-slate-100"
onClick={resetState}
>
Reset
</button>
</div>
</div>

<DropZones zones={zones} state={state} onReorder={reorderInZone} onMoveToZone={moveToZone} />
</div>
<DragPreview />
</DndProvider>
);
};
40 changes: 40 additions & 0 deletions src/components/drag-drop/DragPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="pointer-events-none fixed inset-0 z-50">
<div
className="rounded-md border border-sky-300 bg-sky-50 px-3 py-2 text-sm font-medium text-sky-900 shadow-lg"
style={{
transform: `translate(${currentOffset.x + 8}px, ${currentOffset.y + 8}px)`,
position: 'absolute',
}}
>
{title}
</div>
</div>
);
};
98 changes: 98 additions & 0 deletions src/components/drag-drop/DropZones.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
ref={drop}
className={`rounded-xl border p-4 transition ${
isOver && canDrop ? 'border-sky-400 bg-sky-50' : 'border-slate-200 bg-white'
}`}
>
<header className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-800">{zone.label}</h3>
{zone.description ? <p className="text-xs text-slate-500">{zone.description}</p> : null}
</div>
<span className="rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">{itemsCount}</span>
</header>
{children}
</section>
);
};

export const DropZones = ({ zones, state, onReorder, onMoveToZone }: DropZonesProps) => {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{zones.map((zone) => {
const items = state[zone.id] ?? [];

return (
<ZonePanel
key={zone.id}
zone={zone}
itemsCount={items.length}
onDropToZone={(itemId, fromZoneId, toZoneId) =>
onMoveToZone(itemId, fromZoneId, toZoneId, items.length)
}
>
<SortableList
zoneId={zone.id}
items={items}
onReorder={onReorder}
onMoveToZone={onMoveToZone}
/>
</ZonePanel>
);
})}
</div>
);
};
142 changes: 142 additions & 0 deletions src/components/drag-drop/SortableList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className={`mb-2 rounded-md border bg-white px-3 py-2 text-sm shadow-sm transition ${
isDragging ? 'opacity-50' : 'opacity-100'
}`}
>
<div className="font-medium text-slate-800">{item.title}</div>
<div className="mt-1 text-xs text-slate-500">#{item.order + 1}</div>
</div>
);
};

export const SortableList = ({
zoneId,
items,
onReorder,
onMoveToZone,
emptyText = 'Drop content here',
}: SortableListProps) => {
if (items.length === 0) {
return (
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-sm text-slate-500">
{emptyText}
</div>
);
}

return (
<div>
{items.map((item, index) => (
<SortableRow
key={item.id}
item={item}
index={index}
zoneId={zoneId}
onReorder={onReorder}
onMoveToZone={onMoveToZone}
/>
))}
</div>
);
};
Loading
Loading