feat: Drag-and-drop section reordering

- Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- SortableSectionCard component with useSortable hook
- DndContext and SortableContext wrappers
- PointerSensor with 8px distance activation
- KeyboardSensor for accessibility
- Visual feedback: opacity change AND ring during drag
- GripVertical handle for intuitive dragging
- onReorderSections callback using arrayMove
This commit is contained in:
Dwindi Ramadhana
2026-01-12 12:10:57 +07:00
parent f4f7ff10f0
commit 0e9ace902d
2 changed files with 195 additions and 84 deletions

View File

@@ -1,4 +1,21 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -30,6 +47,7 @@ interface SectionEditorProps {
onAddSection: (type: string) => void; onAddSection: (type: string) => void;
onDeleteSection: (id: string) => void; onDeleteSection: (id: string) => void;
onMoveSection: (id: string, direction: 'up' | 'down') => void; onMoveSection: (id: string, direction: 'up' | 'down') => void;
onReorderSections: (sections: Section[]) => void;
isTemplate: boolean; isTemplate: boolean;
cpt?: string; cpt?: string;
isLoading: boolean; isLoading: boolean;
@@ -44,6 +62,120 @@ const SECTION_TYPES = [
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare }, { type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
]; ];
// Sortable Section Card Component
function SortableSectionCard({
section,
index,
totalCount,
isSelected,
onSelect,
onDelete,
onMove,
}: {
section: Section;
index: number;
totalCount: number;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onMove: (direction: 'up' | 'down') => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
const Icon = sectionType?.icon || LayoutTemplate;
const hasDynamic = Object.values(section.props).some(
p => typeof p === 'object' && p?.type === 'dynamic'
);
return (
<Card
ref={setNodeRef}
style={style}
className={cn(
'p-4 cursor-pointer transition-all',
'hover:shadow-md',
isSelected ? 'ring-2 ring-primary shadow-md' : '',
isDragging ? 'opacity-50 shadow-lg ring-2 ring-primary/50' : ''
)}
onClick={onSelect}
>
<div className="flex items-center gap-3">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-gray-400 hover:text-gray-600" />
</div>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
<Icon className="w-5 h-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-sm">
{sectionType?.label || section.type}
</div>
<div className="text-xs text-gray-500">
{section.layoutVariant || 'default'}
{hasDynamic && (
<span className="ml-2 text-primary"> {__('Dynamic')}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMove('up')}
disabled={index === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMove('down')}
disabled={index === totalCount - 1}
>
<ChevronDown className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm(__('Delete this section?'))) {
onDelete();
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}
export function SectionEditor({ export function SectionEditor({
sections, sections,
selectedSection, selectedSection,
@@ -51,10 +183,33 @@ export function SectionEditor({
onAddSection, onAddSection,
onDeleteSection, onDeleteSection,
onMoveSection, onMoveSection,
onReorderSections,
isTemplate, isTemplate,
cpt, cpt,
isLoading, isLoading,
}: SectionEditorProps) { }: SectionEditorProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const newSections = arrayMove(sections, oldIndex, newIndex);
onReorderSections(newSections);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
@@ -75,91 +230,39 @@ export function SectionEditor({
)} )}
</div> </div>
{/* Sections List */} {/* Sections List with Drag-and-Drop */}
<div className="space-y-3"> <DndContext
{sections.map((section, index) => { sensors={sensors}
const sectionType = SECTION_TYPES.find(s => s.type === section.type); collisionDetection={closestCenter}
const Icon = sectionType?.icon || LayoutTemplate; onDragEnd={handleDragEnd}
const hasDynamic = Object.values(section.props).some( >
p => typeof p === 'object' && p?.type === 'dynamic' <SortableContext
); items={sections.map(s => s.id)}
strategy={verticalListSortingStrategy}
return ( >
<Card <div className="space-y-3">
key={section.id} {sections.map((section, index) => (
className={cn( <SortableSectionCard
'p-4 cursor-pointer transition-all', key={section.id}
'hover:shadow-md', section={section}
selectedSection?.id === section.id index={index}
? 'ring-2 ring-primary shadow-md' totalCount={sections.length}
: '' isSelected={selectedSection?.id === section.id}
)} onSelect={() => onSelectSection(section)}
onClick={() => onSelectSection(section)} onDelete={() => onDeleteSection(section.id)}
> onMove={(direction) => onMoveSection(section.id, direction)}
<div className="flex items-center gap-3"> />
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" /> ))}
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
<Icon className="w-5 h-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-sm">
{sectionType?.label || section.type}
</div>
<div className="text-xs text-gray-500">
{section.layoutVariant || 'default'}
{hasDynamic && (
<span className="ml-2 text-primary"> {__('Dynamic')}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMoveSection(section.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMoveSection(section.id, 'down')}
disabled={index === sections.length - 1}
>
<ChevronDown className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm(__('Delete this section?'))) {
onDeleteSection(section.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
})}
{sections.length === 0 && (
<div className="text-center py-12 text-gray-400">
<LayoutTemplate className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{__('No sections yet. Add your first section.')}</p>
</div> </div>
)} </SortableContext>
</div> </DndContext>
{sections.length === 0 && (
<div className="text-center py-12 text-gray-400">
<LayoutTemplate className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{__('No sections yet. Add your first section.')}</p>
</div>
)}
{/* Add Section Button */} {/* Add Section Button */}
<DropdownMenu> <DropdownMenu>

View File

@@ -161,6 +161,13 @@ export default function AppearancePages() {
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
}; };
// Reorder sections (drag-and-drop)
const handleReorderSections = (newSections: Section[]) => {
if (!structure) return;
setStructure({ ...structure, sections: newSections });
setHasUnsavedChanges(true);
};
return ( return (
<div className="h-[calc(100vh-64px)] flex flex-col"> <div className="h-[calc(100vh-64px)] flex flex-col">
{/* Header */} {/* Header */}
@@ -217,6 +224,7 @@ export default function AppearancePages() {
onAddSection={handleAddSection} onAddSection={handleAddSection}
onDeleteSection={handleDeleteSection} onDeleteSection={handleDeleteSection}
onMoveSection={handleMoveSection} onMoveSection={handleMoveSection}
onReorderSections={handleReorderSections}
isTemplate={selectedPage.type === 'template'} isTemplate={selectedPage.type === 'template'}
cpt={selectedPage.cpt} cpt={selectedPage.cpt}
isLoading={pageLoading} isLoading={pageLoading}