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:
2026-03-11 18:40:33 +03:00
parent 27c1348f89
commit ed5a164d59
4 changed files with 836 additions and 256 deletions

View File

@@ -1,16 +1,15 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import Link from "next/link";
import {
Loader2,
Plus,
Trash2,
ChevronUp,
ChevronDown,
GripVertical,
Pencil,
Save,
Check,
} from "lucide-react";
import type { TeamMember } from "@/types/content";
@@ -23,6 +22,13 @@ export default function TeamEditorPage() {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
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)[]>([]);
useEffect(() => {
fetch("/api/admin/team")
.then((r) => r.json())
@@ -43,13 +49,68 @@ export default function TeamEditorPage() {
setTimeout(() => setSaved(false), 2000);
}, []);
function moveItem(index: number, direction: -1 | 1) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= members.length) return;
const updated = [...members];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
saveOrder(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 = members.length;
for (let i = 0; i < members.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 > dragIndex! ? i : 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 = [...members];
const [moved] = updated.splice(prevDrag, 1);
updated.splice(targetIndex, 0, moved);
saveOrder(updated);
}
}
return null;
});
return null;
});
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragIndex, members, saveOrder]);
async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return;
@@ -66,6 +127,117 @@ export default function TeamEditorPage() {
);
}
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
// Build the visual order: remove dragged item, insert placeholder at insertAt
function renderList() {
if (dragIndex === null || insertAt === null) {
// Normal render — no drag
return members.map((member, i) => (
<div
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleMouseDown(e, i)}
>
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<div className="flex items-center gap-1">
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
<Pencil size={16} />
</Link>
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
</div>
));
}
// During drag: build list without the dragged item, with placeholder inserted
const elements: React.ReactNode[] = [];
let visualIndex = 0;
// Determine where to insert placeholder relative to non-dragged items
let placeholderPos = insertAt;
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
for (let i = 0; i < members.length; i++) {
if (i === dragIndex) {
// Keep a hidden ref so midpoint detection still works
elements.push(
<div key={`hidden-${members[i].id}`} 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-2"
style={{ height: dragSize.h }}
/>
);
}
const member = members[i];
elements.push(
<div
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleMouseDown(e, i)}
>
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<div className="flex items-center gap-1">
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
<Pencil size={16} />
</Link>
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
</div>
);
visualIndex++;
}
// Placeholder at the end
if (visualIndex === placeholderPos) {
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
style={{ height: dragSize.h }}
/>
);
}
return elements;
}
return (
<div>
<div className="flex items-center justify-between gap-4">
@@ -91,61 +263,43 @@ export default function TeamEditorPage() {
</div>
</div>
<div className="mt-6 space-y-2">
{members.map((member, i) => (
<div
key={member.id}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3"
>
<div className="flex flex-col gap-0.5">
<button
onClick={() => moveItem(i, -1)}
disabled={i === 0}
className="text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronUp size={16} />
</button>
<button
onClick={() => moveItem(i, 1)}
disabled={i === members.length - 1}
className="text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronDown size={16} />
</button>
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover"
sizes="48px"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<div className="flex items-center gap-1">
<Link
href={`/admin/team/${member.id}`}
className="rounded p-2 text-neutral-400 hover:text-white transition-colors"
>
<Pencil size={16} />
</Link>
<button
onClick={() => deleteMember(member.id)}
className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
<div className="mt-6">
{renderList()}
</div>
{/* Floating card following cursor */}
{dragIndex !== null &&
draggedMember &&
createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{
left: mousePos.x - grabOffset.x,
top: mousePos.y - grabOffset.y,
width: dragSize.w,
}}
>
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
<div className="text-rose-400">
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image
src={draggedMember.image}
alt={draggedMember.name}
fill
className="object-cover"
sizes="48px"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{draggedMember.name}</p>
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
</div>
</div>
</div>,
document.body
)}
</div>
);
}