How to Implement Drag-and-Drop Functionality in LunaTV with @dnd-kit
LunaTV implements drag-and-drop reordering in its admin interface using @dnd-kit's sensor configuration, DndContext wrapper, and useSortable hook to enable smooth vertical sorting of video sources and categories across desktop and mobile devices.
LunaTV is an open-source streaming platform that provides administrators with intuitive content management tools. The admin dashboard leverages the @dnd-kit library to deliver native drag-and-drop interactions for organizing video sources, custom categories, and live channels. This implementation separates sensor detection, drag state management, and sortable row rendering into distinct architectural layers within the Next.js application.
Installing the Required Dependencies
The drag-and-drop system relies on four official @dnd-kit packages defined in package.json (lines 22–25). These provide the core drag engine, sorting logic, movement modifiers, and CSS utilities.
@dnd-kit/core– Provides theDndContext, sensors, and collision detection algorithms.@dnd-kit/sortable– SuppliesSortableContext,useSortable, and sorting strategies.@dnd-kit/modifiers– IncludesrestrictToVerticalAxisandrestrictToParentElementfor constrained movement.@dnd-kit/utilities– Exports theCSShelper for transform string conversions.
Install them using your package manager:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiers @dnd-kit/utilities
Configuring Sensors for Cross-Device Support
LunaTV supports both mouse and touch interactions by combining PointerSensor and TouchSensor within the useSensors hook. According to the source code in src/app/admin/page.tsx (lines 5–12), this configuration ensures the UI responds correctly on desktop while preventing accidental drags on mobile.
The PointerSensor activates after a 5-pixel movement, while the TouchSensor requires a 250ms delay with 5-pixel tolerance to distinguish scrolling from dragging.
import {
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
})
);
Wrapping the List in DndContext
The list of sources or categories is wrapped in a DndContext component instantiated in src/app/admin/page.tsx (lines 998–1005). This provider receives the sensors, collision detection strategy, drag-end handler, and modifiers that constrain movement.
The closestCenter collision detection algorithm ensures dropped items snap to the nearest target. The restrictToVerticalAxis and restrictToParentElement modifiers prevent horizontal movement and keep elements within the table boundaries.
import { DndContext, closestCenter } from '@dnd-kit/core';
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers';
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
{/* Sortable children */}
</DndContext>
Creating Sortable Rows with useSortable
Individual table rows become draggable through the DraggableRow component defined in src/app/admin/page.tsx (lines 2326–2338). This component uses the useSortable hook, which returns essential properties: setNodeRef for the DOM element, transform and transition for CSS animation, and attributes and listeners for accessibility and event handling.
The CSS.Transform.toString() utility converts the transform object into a valid CSS value. Setting touchAction: 'none' on the grip handle prevents browser scrolling while dragging on touch devices.
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical } from 'lucide-react';
function DraggableRow({ item }: { item: { id: string; name: string } }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr ref={setNodeRef} style={style} className="select-none">
<td
className="cursor-grab"
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td>{item.name}</td>
</tr>
);
}
Managing Sort Order with SortableContext
Inside the DndContext, a SortableContext (lines 1010–1014 in src/app/admin/page.tsx) manages the array order and recycling of sortable items. It requires a stable array of unique IDs derived from the data—such as sources.map(s => s.key) or categories.map(c => `${c.query}:${c.type}`)—and a sorting strategy.
The verticalListSortingStrategy calculates positions for items stacked in a single column, ensuring smooth rearrangement within table bodies.
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
<SortableContext
items={items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{items.map((item) => (
<DraggableRow key={item.id} item={item} />
))}
</tbody>
</SortableContext>
Handling Drag Events and Persisting Order
The handleDragEnd function (implemented in src/app/admin/page.tsx, lines 998–1005) processes the drag completion event. It extracts active (source) and over (destination) IDs, calculates new indices using arrayMove, and updates local React state immediately for visual feedback.
When the orderChanged flag is set, a "Save Order" button appears (lines 1230–1234). Clicking it invokes handleSaveOrder, which posts the reordered array to /api/admin/source to persist the configuration server-side.
function handleDragEnd(event: any) {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over?.id);
const reordered = arrayMove(items, oldIndex, newIndex);
setItems(reordered);
fetch('/api/admin/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reorder',
order: reordered.map((i) => i.id),
}),
});
}
}
Summary
- Sensor configuration in
src/app/admin/page.tsxcombinesPointerSensorandTouchSensorto support desktop and mobile interactions with distinct activation constraints. - DndContext wraps the entire list, applying
closestCentercollision detection and modifiers that restrict movement to the vertical axis and parent boundaries. - useSortable hook transforms each table row into a draggable element by providing refs, CSS transforms, and event listeners.
- SortableContext manages item order using
verticalListSortingStrategyand requires a stable array of unique identifiers. - Persistence occurs via
handleDragEndfor optimistic UI updates andhandleSaveOrderfor POST requests to/api/admin/source.
Frequently Asked Questions
What sensors does LunaTV use for drag-and-drop interactions?
LunaTV configures both PointerSensor and TouchSensor using the useSensors hook in src/app/admin/page.tsx. The pointer sensor activates after a 5-pixel drag distance, while the touch sensor requires a 250-millisecond press with 5-pixel tolerance to differentiate dragging from scrolling.
How does LunaTV restrict drag movement to vertical only?
The implementation passes the restrictToVerticalAxis modifier from @dnd-kit/modifiers to the DndContext component. Additionally, restrictToParentElement prevents draggable items from leaving the table container, ensuring the UI remains stable during reordering.
Where is the drag-and-drop logic implemented in the LunaTV codebase?
All drag-and-drop logic resides in src/app/admin/page.tsx. This file contains the sensor setup (lines 5–12), the DndContext and handleDragEnd definitions (lines 998–1005), the SortableContext configuration (lines 1010–1014), and the DraggableRow component (lines 2326–2338).
How does LunaTV persist reordered items to the backend?
When a drag operation completes, the handleDragEnd function updates local state optimistically. If the order changes, a "Save Order" button appears; clicking it triggers handleSaveOrder, which sends a POST request to /api/admin/source with the new item order array, ensuring the configuration survives page reloads.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →