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 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,40 +62,38 @@ const SECTION_TYPES = [
|
|||||||
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
|
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SectionEditor({
|
// Sortable Section Card Component
|
||||||
sections,
|
function SortableSectionCard({
|
||||||
selectedSection,
|
section,
|
||||||
onSelectSection,
|
index,
|
||||||
onAddSection,
|
totalCount,
|
||||||
onDeleteSection,
|
isSelected,
|
||||||
onMoveSection,
|
onSelect,
|
||||||
isTemplate,
|
onDelete,
|
||||||
cpt,
|
onMove,
|
||||||
isLoading,
|
}: {
|
||||||
}: SectionEditorProps) {
|
section: Section;
|
||||||
if (isLoading) {
|
index: number;
|
||||||
return (
|
totalCount: number;
|
||||||
<div className="h-full flex items-center justify-center">
|
isSelected: boolean;
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
onSelect: () => void;
|
||||||
</div>
|
onDelete: () => void;
|
||||||
);
|
onMove: (direction: 'up' | 'down') => void;
|
||||||
}
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: section.id });
|
||||||
|
|
||||||
return (
|
const style = {
|
||||||
<div className="space-y-4">
|
transform: CSS.Transform.toString(transform),
|
||||||
{/* Section Header */}
|
transition,
|
||||||
<div className="flex items-center justify-between">
|
};
|
||||||
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
|
|
||||||
{isTemplate && (
|
|
||||||
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
|
||||||
{__('Template: ')} {cpt}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sections List */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
|
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
|
||||||
const Icon = sectionType?.icon || LayoutTemplate;
|
const Icon = sectionType?.icon || LayoutTemplate;
|
||||||
const hasDynamic = Object.values(section.props).some(
|
const hasDynamic = Object.values(section.props).some(
|
||||||
@@ -86,18 +102,25 @@ export function SectionEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={section.id}
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-4 cursor-pointer transition-all',
|
'p-4 cursor-pointer transition-all',
|
||||||
'hover:shadow-md',
|
'hover:shadow-md',
|
||||||
selectedSection?.id === section.id
|
isSelected ? 'ring-2 ring-primary shadow-md' : '',
|
||||||
? 'ring-2 ring-primary shadow-md'
|
isDragging ? 'opacity-50 shadow-lg ring-2 ring-primary/50' : ''
|
||||||
: ''
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelectSection(section)}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" />
|
<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="flex-1 flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||||
@@ -121,7 +144,7 @@ export function SectionEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => onMoveSection(section.id, 'up')}
|
onClick={() => onMove('up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
>
|
>
|
||||||
<ChevronUp className="w-4 h-4" />
|
<ChevronUp className="w-4 h-4" />
|
||||||
@@ -130,8 +153,8 @@ export function SectionEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => onMoveSection(section.id, 'down')}
|
onClick={() => onMove('down')}
|
||||||
disabled={index === sections.length - 1}
|
disabled={index === totalCount - 1}
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -141,7 +164,7 @@ export function SectionEditor({
|
|||||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(__('Delete this section?'))) {
|
if (confirm(__('Delete this section?'))) {
|
||||||
onDeleteSection(section.id);
|
onDelete();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -151,7 +174,88 @@ export function SectionEditor({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
export function SectionEditor({
|
||||||
|
sections,
|
||||||
|
selectedSection,
|
||||||
|
onSelectSection,
|
||||||
|
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">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
|
||||||
|
{isTemplate && (
|
||||||
|
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
||||||
|
{__('Template: ')} {cpt}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{sections.length === 0 && (
|
{sections.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="text-center py-12 text-gray-400">
|
||||||
@@ -159,7 +263,6 @@ export function SectionEditor({
|
|||||||
<p>{__('No sections yet. Add your first section.')}</p>
|
<p>{__('No sections yet. Add your first section.')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Section Button */}
|
{/* Add Section Button */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user