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:
@@ -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 (
|
||||
<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({
|
||||
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 (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
@@ -75,91 +230,39 @@ export function SectionEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections List */}
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<Card
|
||||
key={section.id}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer transition-all',
|
||||
'hover:shadow-md',
|
||||
selectedSection?.id === section.id
|
||||
? 'ring-2 ring-primary shadow-md'
|
||||
: ''
|
||||
)}
|
||||
onClick={() => onSelectSection(section)}
|
||||
>
|
||||
<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>
|
||||
{/* Sections List with Drag-and-Drop */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map(s => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sections.map((section, index) => (
|
||||
<SortableSectionCard
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={index}
|
||||
totalCount={sections.length}
|
||||
isSelected={selectedSection?.id === section.id}
|
||||
onSelect={() => onSelectSection(section)}
|
||||
onDelete={() => onDeleteSection(section.id)}
|
||||
onMove={(direction) => onMoveSection(section.id, direction)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</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 */}
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col">
|
||||
{/* 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}
|
||||
|
||||
Reference in New Issue
Block a user