feat: drag-and-drop reordering + auto-save for admin editors
Replace arrow buttons with mouse-based drag-and-drop in ArrayEditor and team page. Dragged card follows cursor with floating clone, empty placeholder shows at drop position. SectionEditor now auto-saves with 800ms debounce instead of manual save button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
interface ArrayEditorProps<T> {
|
||||
items: T[];
|
||||
@@ -19,6 +21,16 @@ export function ArrayEditor<T>({
|
||||
label,
|
||||
addLabel = "Добавить",
|
||||
}: ArrayEditorProps<T>) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
function updateItem(index: number, item: T) {
|
||||
const updated = [...items];
|
||||
updated[index] = item;
|
||||
@@ -29,12 +41,159 @@ export function ArrayEditor<T>({
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function moveItem(index: number, direction: -1 | 1) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= items.length) return;
|
||||
const updated = [...items];
|
||||
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
|
||||
onChange(updated);
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const el = itemRefs.current[index];
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setDragIndex(index);
|
||||
setInsertAt(index);
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
setDragSize({ w: rect.width, h: rect.height });
|
||||
setGrabOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragIndex === null) return;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
|
||||
let newInsert = items.length;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (i === dragIndex) continue;
|
||||
const el = itemRefs.current[i];
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
newInsert = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setInsertAt(newInsert);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
setDragIndex((prevDrag) => {
|
||||
setInsertAt((prevInsert) => {
|
||||
if (prevDrag !== null && prevInsert !== null) {
|
||||
let targetIndex = prevInsert;
|
||||
if (prevDrag < targetIndex) targetIndex -= 1;
|
||||
if (prevDrag !== targetIndex) {
|
||||
const updated = [...items];
|
||||
const [moved] = updated.splice(prevDrag, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
onChange(updated);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [dragIndex, items, onChange]);
|
||||
|
||||
function renderList() {
|
||||
if (dragIndex === null || insertAt === null) {
|
||||
return items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let visualIndex = 0;
|
||||
let placeholderPos = insertAt;
|
||||
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (i === dragIndex) {
|
||||
elements.push(
|
||||
<div key={`hidden-${i}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visualIndex === placeholderPos) {
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||
style={{ height: dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const item = items[i];
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
);
|
||||
visualIndex++;
|
||||
}
|
||||
|
||||
if (visualIndex === placeholderPos) {
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||
style={{ height: dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,42 +202,8 @@ export function ArrayEditor<T>({
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(i, -1)}
|
||||
disabled={i === 0}
|
||||
className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(i, 1)}
|
||||
disabled={i === items.length - 1}
|
||||
className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -89,6 +214,26 @@ export function ArrayEditor<T>({
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
</button>
|
||||
|
||||
{/* Floating clone following cursor */}
|
||||
{mounted && dragIndex !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: mousePos.x - grabOffset.x,
|
||||
top: mousePos.y - grabOffset.y,
|
||||
width: dragSize.w,
|
||||
height: dragSize.h,
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
|
||||
<GripVertical size={16} className="text-rose-400 shrink-0" />
|
||||
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user