From 0e9ace902d158da7fd78c23d156367af9e3f525d Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Mon, 12 Jan 2026 12:10:57 +0700 Subject: [PATCH] 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 --- .../Pages/components/SectionEditor.tsx | 271 ++++++++++++------ .../src/routes/Appearance/Pages/index.tsx | 8 + 2 files changed, 195 insertions(+), 84 deletions(-) diff --git a/admin-spa/src/routes/Appearance/Pages/components/SectionEditor.tsx b/admin-spa/src/routes/Appearance/Pages/components/SectionEditor.tsx index 717ecd1..07636de 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/SectionEditor.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/SectionEditor.tsx @@ -1,4 +1,21 @@ 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 { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -30,6 +47,7 @@ interface SectionEditorProps { onAddSection: (type: string) => void; onDeleteSection: (id: string) => void; onMoveSection: (id: string, direction: 'up' | 'down') => void; + onReorderSections: (sections: Section[]) => void; isTemplate: boolean; cpt?: string; isLoading: boolean; @@ -44,6 +62,120 @@ const SECTION_TYPES = [ { 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 ( + +
+
e.stopPropagation()} + > + +
+ +
+
+ +
+
+
+ {sectionType?.label || section.type} +
+
+ {section.layoutVariant || 'default'} + {hasDynamic && ( + ◆ {__('Dynamic')} + )} +
+
+
+ +
e.stopPropagation()}> + + + +
+
+
+ ); +} + export function SectionEditor({ sections, selectedSection, @@ -51,10 +183,33 @@ export function SectionEditor({ onAddSection, onDeleteSection, onMoveSection, + onReorderSections, isTemplate, cpt, isLoading, }: 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) { return (
@@ -75,91 +230,39 @@ export function SectionEditor({ )}
- {/* Sections List */} -
- {sections.map((section, index) => { - 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 ( - onSelectSection(section)} - > -
- - -
-
- -
-
-
- {sectionType?.label || section.type} -
-
- {section.layoutVariant || 'default'} - {hasDynamic && ( - ◆ {__('Dynamic')} - )} -
-
-
- -
e.stopPropagation()}> - - - -
-
-
- ); - })} - - {sections.length === 0 && ( -
- -

{__('No sections yet. Add your first section.')}

+ {/* Sections List with Drag-and-Drop */} + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {sections.map((section, index) => ( + onSelectSection(section)} + onDelete={() => onDeleteSection(section.id)} + onMove={(direction) => onMoveSection(section.id, direction)} + /> + ))}
- )} -
+ + + + {sections.length === 0 && ( +
+ +

{__('No sections yet. Add your first section.')}

+
+ )} {/* Add Section Button */} diff --git a/admin-spa/src/routes/Appearance/Pages/index.tsx b/admin-spa/src/routes/Appearance/Pages/index.tsx index 77f69ab..7d93a5c 100644 --- a/admin-spa/src/routes/Appearance/Pages/index.tsx +++ b/admin-spa/src/routes/Appearance/Pages/index.tsx @@ -161,6 +161,13 @@ export default function AppearancePages() { setHasUnsavedChanges(true); }; + // Reorder sections (drag-and-drop) + const handleReorderSections = (newSections: Section[]) => { + if (!structure) return; + setStructure({ ...structure, sections: newSections }); + setHasUnsavedChanges(true); + }; + return (
{/* Header */} @@ -217,6 +224,7 @@ export default function AppearancePages() { onAddSection={handleAddSection} onDeleteSection={handleDeleteSection} onMoveSection={handleMoveSection} + onReorderSections={handleReorderSections} isTemplate={selectedPage.type === 'template'} cpt={selectedPage.cpt} isLoading={pageLoading}