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,9 +1,9 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
import { Plus, X, Trash2, GripVertical } from "lucide-react";
import { Plus, X, Trash2 } from "lucide-react";
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
interface ScheduleData {
@@ -31,29 +31,31 @@ const LEVELS = [
{ value: "Продвинутый", label: "Продвинутый" },
];
const CLASS_TYPES = [
"Exotic Pole Dance",
"Pole Dance",
"Body Plastic",
"Stretching",
"Pole Exotic",
"Twerk",
const COLOR_PALETTE = [
"bg-rose-500/80 border-rose-400",
"bg-violet-500/80 border-violet-400",
"bg-amber-500/80 border-amber-400",
"bg-emerald-500/80 border-emerald-400",
"bg-pink-500/80 border-pink-400",
"bg-sky-500/80 border-sky-400",
"bg-indigo-500/80 border-indigo-400",
"bg-orange-500/80 border-orange-400",
"bg-teal-500/80 border-teal-400",
"bg-fuchsia-500/80 border-fuchsia-400",
];
const TYPE_COLORS: Record<string, string> = {
"Exotic Pole Dance": "bg-rose-500/80 border-rose-400",
"Pole Dance": "bg-violet-500/80 border-violet-400",
"Body Plastic": "bg-amber-500/80 border-amber-400",
"Stretching": "bg-emerald-500/80 border-emerald-400",
"Pole Exotic": "bg-pink-500/80 border-pink-400",
"Twerk": "bg-sky-500/80 border-sky-400",
};
function getTypeColor(type: string, classTypes: string[]): string {
const idx = classTypes.indexOf(type);
if (idx >= 0) return COLOR_PALETTE[idx % COLOR_PALETTE.length];
return "bg-neutral-600/80 border-neutral-500";
}
// Calendar config
const HOUR_START = 9;
const HOUR_END = 23;
const HOUR_HEIGHT = 60; // px per hour
const TOTAL_HOURS = HOUR_END - HOUR_START;
const SNAP_MINUTES = 15;
function parseTime(timeStr: string): { h: number; m: number } | null {
const [h, m] = (timeStr || "").split(":").map(Number);
@@ -103,17 +105,39 @@ function getOverlaps(classes: ScheduleClass[], index: number): boolean {
return false;
}
// ---------- Drag state ----------
interface DragState {
sourceDayIndex: number;
classIndex: number;
/** offset from top of block where user grabbed */
grabOffsetY: number;
/** duration in minutes (preserved during drag) */
durationMin: number;
/** current preview: snapped start minute */
previewStartMin: number;
/** current preview: target day index */
previewDayIndex: number;
/** did the pointer actually move? (to distinguish click from drag) */
moved: boolean;
}
// ---------- Class Block on Calendar ----------
function ClassBlock({
cls,
index,
isOverlapping,
isDragging,
classTypes,
onClick,
onDragStart,
}: {
cls: ScheduleClass;
index: number;
isOverlapping: boolean;
isDragging: boolean;
classTypes: string[];
onClick: () => void;
onDragStart: (e: React.MouseEvent) => void;
}) {
const parts = cls.time.split("");
const startMin = timeToMinutes(parts[0]?.trim() || "");
@@ -123,16 +147,26 @@ function ClassBlock({
const top = minutesToY(startMin);
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
const colors = TYPE_COLORS[cls.type] || "bg-neutral-600/80 border-neutral-500";
const colors = getTypeColor(cls.type, classTypes);
return (
<button
type="button"
onClick={onClick}
style={{ top: `${top}px`, height: `${height}px` }}
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white transition-opacity hover:opacity-90 cursor-pointer overflow-hidden ${colors} ${
<div
data-class-block
onMouseDown={onDragStart}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
top: `${top}px`,
height: `${height}px`,
...(isOverlapping
? { backgroundImage: "repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(239,68,68,0.35) 4px, rgba(239,68,68,0.35) 8px)" }
: {}),
}}
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
}`}
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
>
<div className="font-semibold truncate leading-tight">
@@ -144,10 +178,10 @@ function ClassBlock({
{height > 48 && (
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
)}
{isOverlapping && height > 30 && (
<div className="text-red-200 font-medium leading-tight"> Пересечение</div>
{isOverlapping && (
<div className="text-red-200 font-bold leading-tight"> Пересечение</div>
)}
</button>
</div>
);
}
@@ -155,19 +189,21 @@ function ClassBlock({
function ClassModal({
cls,
trainers,
classTypes,
onSave,
onDelete,
onClose,
}: {
cls: ScheduleClass;
trainers: string[];
classTypes: string[];
onSave: (cls: ScheduleClass) => void;
onDelete?: () => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState<ScheduleClass>(cls);
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
const typeOptions = CLASS_TYPES.map((t) => ({ value: t, label: t }));
const typeOptions = classTypes.map((t) => ({ value: t, label: t }));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
@@ -257,10 +293,14 @@ function ClassModal({
function CalendarGrid({
location,
trainers,
addresses,
classTypes,
onChange,
}: {
location: ScheduleLocation;
trainers: string[];
addresses: string[];
classTypes: string[];
onChange: (loc: ScheduleLocation) => void;
}) {
const [editingClass, setEditingClass] = useState<{
@@ -272,6 +312,16 @@ function CalendarGrid({
cls: ScheduleClass;
} | null>(null);
// Hover highlight state
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
// Drag state
const [drag, setDrag] = useState<DragState | null>(null);
const dragRef = useRef<DragState | null>(null);
const justDraggedRef = useRef(false);
const columnRefs = useRef<(HTMLDivElement | null)[]>([]);
const gridRef = useRef<HTMLDivElement | null>(null);
const sortedDays = sortDaysByWeekday(location.days);
const usedDays = new Set(location.days.map((d) => d.day));
const availableDays = DAYS.filter((d) => !usedDays.has(d.day));
@@ -281,15 +331,157 @@ function CalendarGrid({
hours.push(h);
}
// --- Drag handlers ---
function startDrag(dayIndex: number, classIndex: number, e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
const col = columnRefs.current[dayIndex];
if (!col) return;
const cls = sortedDays[dayIndex].classes[classIndex];
const parts = cls.time.split("");
const startMin = timeToMinutes(parts[0]?.trim() || "");
const endMin = timeToMinutes(parts[1]?.trim() || "");
if (!startMin || !endMin) return;
const colRect = col.getBoundingClientRect();
const blockTop = minutesToY(startMin);
const grabOffsetY = e.clientY - colRect.top - blockTop;
const state: DragState = {
sourceDayIndex: dayIndex,
classIndex,
grabOffsetY,
durationMin: endMin - startMin,
previewStartMin: startMin,
previewDayIndex: dayIndex,
moved: false,
};
dragRef.current = state;
setDrag(state);
}
const handleMouseMove = useCallback((e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
// Determine which day column the mouse is over
let targetDayIndex = d.previewDayIndex;
for (let i = 0; i < columnRefs.current.length; i++) {
const col = columnRefs.current[i];
if (!col) continue;
const rect = col.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX < rect.right) {
targetDayIndex = i;
break;
}
}
// Calculate Y position in the target column
const col = columnRefs.current[targetDayIndex];
if (!col) return;
const colRect = col.getBoundingClientRect();
const y = e.clientY - colRect.top - d.grabOffsetY;
const rawMinutes = yToMinutes(y);
const snapped = Math.round(rawMinutes / SNAP_MINUTES) * SNAP_MINUTES;
// Clamp to grid bounds
const clamped = Math.max(
HOUR_START * 60,
Math.min(snapped, HOUR_END * 60 - d.durationMin)
);
const hasMoved =
clamped !== d.previewStartMin || targetDayIndex !== d.previewDayIndex || d.moved;
const updated: DragState = {
...d,
previewStartMin: clamped,
previewDayIndex: targetDayIndex,
moved: hasMoved,
};
dragRef.current = updated;
setDrag(updated);
}, []);
const handleMouseUp = useCallback(() => {
const d = dragRef.current;
dragRef.current = null;
if (!d) {
setDrag(null);
return;
}
if (d.moved) {
// Suppress the click event that fires right after mouseup
justDraggedRef.current = true;
requestAnimationFrame(() => {
justDraggedRef.current = false;
});
// Commit the move
const newStart = formatMinutes(d.previewStartMin);
const newEnd = formatMinutes(d.previewStartMin + d.durationMin);
const sourceDay = sortedDays[d.sourceDayIndex];
const cls = sourceDay.classes[d.classIndex];
const updatedCls: ScheduleClass = { ...cls, time: `${newStart}${newEnd}` };
if (d.previewDayIndex === d.sourceDayIndex) {
// Same day — just update time
const classes = [...sourceDay.classes];
classes[d.classIndex] = updatedCls;
commitDayUpdate(d.sourceDayIndex, { ...sourceDay, classes });
} else {
// Move to different day
const targetDay = sortedDays[d.previewDayIndex];
// Remove from source
const sourceClasses = sourceDay.classes.filter((_, i) => i !== d.classIndex);
// Add to target
const targetClasses = [...targetDay.classes, updatedCls];
const days = [...location.days];
const sourceActual = days.findIndex((dd) => dd.day === sourceDay.day);
const targetActual = days.findIndex((dd) => dd.day === targetDay.day);
if (sourceActual !== -1) days[sourceActual] = { ...sourceDay, classes: sourceClasses };
if (targetActual !== -1) days[targetActual] = { ...targetDay, classes: targetClasses };
onChange({ ...location, days });
}
}
setDrag(null);
}, [sortedDays, location, onChange]);
// Attach global mouse listeners while dragging
useEffect(() => {
if (!drag) return;
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [drag, handleMouseMove, handleMouseUp]);
function handleCellClick(dayIndex: number, e: React.MouseEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const minutes = yToMinutes(y);
// Snap to 15-min intervals
const snapped = Math.round(minutes / 15) * 15;
if (drag || justDraggedRef.current) return;
// Use hover position if available, otherwise calculate from click
let snapped: number;
if (hover && hover.dayIndex === dayIndex) {
snapped = hover.startMin;
} else {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const rawMin = yToMinutes(y);
snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
}
const startTime = formatMinutes(snapped);
const endTime = formatMinutes(snapped + 60);
setHover(null);
setNewClass({
dayIndex,
cls: {
@@ -300,8 +492,7 @@ function CalendarGrid({
});
}
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
// Find the actual index in location.days (since we display sorted)
function commitDayUpdate(dayIndex: number, updatedDay: ScheduleDay) {
const actualDay = sortedDays[dayIndex];
const actualIndex = location.days.findIndex((d) => d.day === actualDay.day);
if (actualIndex === -1) return;
@@ -311,6 +502,10 @@ function CalendarGrid({
onChange({ ...location, days });
}
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
commitDayUpdate(dayIndex, updatedDay);
}
function deleteDay(dayIndex: number) {
const actualDay = sortedDays[dayIndex];
const days = location.days.filter((d) => d.day !== actualDay.day);
@@ -335,6 +530,18 @@ function CalendarGrid({
}
: null;
// Build drag ghost preview
const dragPreview = drag?.moved ? (() => {
const sourceDay = sortedDays[drag.sourceDayIndex];
const cls = sourceDay.classes[drag.classIndex];
const colors = getTypeColor(cls.type, classTypes);
const top = minutesToY(drag.previewStartMin);
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
const newStart = formatMinutes(drag.previewStartMin);
const newEnd = formatMinutes(drag.previewStartMin + drag.durationMin);
return { colors, top, height, dayIndex: drag.previewDayIndex, newStart, newEnd, type: cls.type };
})() : null;
return (
<div className="space-y-4">
{/* Location name/address */}
@@ -344,17 +551,19 @@ function CalendarGrid({
value={location.name}
onChange={(v) => onChange({ ...location, name: v })}
/>
<InputField
<SelectField
label="Адрес"
value={location.address}
onChange={(v) => onChange({ ...location, address: v })}
options={addresses.map((a) => ({ value: a, label: a }))}
placeholder="Выберите адрес"
/>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2">
{CLASS_TYPES.map((type) => {
const colors = TYPE_COLORS[type] || "";
{classTypes.map((type) => {
const colors = getTypeColor(type, classTypes);
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
return (
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
@@ -366,12 +575,8 @@ function CalendarGrid({
</div>
{/* Calendar */}
{sortedDays.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/20 p-8 text-center text-neutral-500">
Добавьте дни недели чтобы увидеть расписание
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-white/10">
{sortedDays.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
<div className="min-w-[600px]">
{/* Day headers */}
<div className="flex border-b border-white/10 bg-neutral-800/50">
@@ -384,14 +589,6 @@ function CalendarGrid({
<div className="flex items-center justify-center gap-1">
<span className="text-sm font-medium text-white">{day.dayShort}</span>
<span className="text-xs text-neutral-500">({day.classes.length})</span>
<button
type="button"
onClick={() => deleteDay(di)}
className="ml-1 rounded p-0.5 text-neutral-600 hover:text-red-400 transition-colors"
title="Удалить день"
>
<X size={12} />
</button>
</div>
</div>
))}
@@ -413,75 +610,119 @@ function CalendarGrid({
</div>
{/* Day columns */}
{sortedDays.map((day, di) => (
<div
key={day.day}
className="flex-1 border-l border-white/10 relative cursor-crosshair"
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
onClick={(e) => {
// Only add if clicking on empty space (not on a class block)
if ((e.target as HTMLElement).closest("button")) return;
handleCellClick(di, e);
}}
>
{/* Hour lines */}
{hours.slice(0, -1).map((h) => (
<div
key={h}
className="absolute left-0 right-0 border-t border-white/5"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
/>
))}
{/* Half-hour lines */}
{hours.slice(0, -1).map((h) => (
<div
key={`${h}-30`}
className="absolute left-0 right-0 border-t border-white/[0.02]"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
/>
))}
{sortedDays.map((day, di) => {
const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass;
const hoverTop = showHover ? minutesToY(hover.startMin) : 0;
const hoverHeight = HOUR_HEIGHT; // 1 hour
const hoverEndMin = showHover ? hover.startMin + 60 : 0;
{/* Class blocks */}
{day.classes.map((cls, ci) => (
<ClassBlock
key={ci}
cls={cls}
index={ci}
isOverlapping={getOverlaps(day.classes, ci)}
onClick={() => setEditingClass({ dayIndex: di, classIndex: ci })}
/>
))}
</div>
))}
return (
<div
key={day.day}
ref={(el) => { columnRefs.current[di] = el; }}
className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
onMouseMove={(e) => {
if (drag) return;
// Ignore if hovering over a class block
if ((e.target as HTMLElement).closest("[data-class-block]")) {
setHover(null);
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const rawMin = yToMinutes(y);
// Snap to 15-min and offset so the block is centered on cursor
const snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
setHover({ dayIndex: di, startMin: clamped });
}}
onMouseLeave={() => setHover(null)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-class-block]")) return;
handleCellClick(di, e);
}}
>
{/* Hour lines */}
{hours.slice(0, -1).map((h) => (
<div
key={h}
className="absolute left-0 right-0 border-t border-white/5"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
/>
))}
{/* Half-hour lines */}
{hours.slice(0, -1).map((h) => (
<div
key={`${h}-30`}
className="absolute left-0 right-0 border-t border-white/[0.02]"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
/>
))}
{/* Hover highlight — 1h preview */}
{showHover && (
<div
style={{ top: `${hoverTop}px`, height: `${hoverHeight}px` }}
className="absolute left-1 right-1 rounded-md border border-dashed border-gold/40 bg-gold/10 px-2 py-1 text-xs text-gold/70 pointer-events-none"
>
<div className="font-medium">
{formatMinutes(hover.startMin)}{formatMinutes(hoverEndMin)}
</div>
<div className="text-gold/50 text-[10px]">Нажмите чтобы добавить</div>
</div>
)}
{/* Class blocks */}
{day.classes.map((cls, ci) => (
<ClassBlock
key={ci}
cls={cls}
index={ci}
isOverlapping={getOverlaps(day.classes, ci)}
isDragging={
drag !== null &&
drag.sourceDayIndex === di &&
drag.classIndex === ci &&
drag.moved
}
classTypes={classTypes}
onClick={() => {
if (justDraggedRef.current) return;
setEditingClass({ dayIndex: di, classIndex: ci });
}}
onDragStart={(e) => startDrag(di, ci, e)}
/>
))}
{/* Drag preview ghost */}
{dragPreview && dragPreview.dayIndex === di && (
<div
style={{ top: `${dragPreview.top}px`, height: `${dragPreview.height}px` }}
className={`absolute left-1 right-1 rounded-md border-l-3 border-dashed px-2 py-0.5 text-xs text-white/80 pointer-events-none ${dragPreview.colors} opacity-60`}
>
<div className="font-semibold truncate leading-tight">
{dragPreview.newStart}{dragPreview.newEnd}
</div>
{dragPreview.height > 30 && (
<div className="truncate text-white/60 leading-tight">{dragPreview.type}</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
{/* Add day buttons */}
{availableDays.length > 0 && (
<div className="flex flex-wrap gap-2">
<span className="flex items-center text-sm text-neutral-500">
<Plus size={14} className="mr-1" /> Добавить день:
</span>
{availableDays.map((d) => (
<button
key={d.day}
type="button"
onClick={() => addDay(d.day, d.dayShort)}
className="rounded-lg border border-dashed border-white/20 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
{d.dayShort}
</button>
))}
</div>
)}
{/* Edit modal */}
{editingData?.cls && editingClass && (
<ClassModal
cls={editingData.cls}
trainers={trainers}
classTypes={classTypes}
onSave={(updated) => {
const day = sortedDays[editingClass.dayIndex];
const classes = [...day.classes];
@@ -502,6 +743,7 @@ function CalendarGrid({
<ClassModal
cls={newClass.cls}
trainers={trainers}
classTypes={classTypes}
onSave={(created) => {
const day = sortedDays[newClass.dayIndex];
const classes = [...day.classes, created];
@@ -518,6 +760,8 @@ function CalendarGrid({
export default function ScheduleEditorPage() {
const [activeLocation, setActiveLocation] = useState(0);
const [trainers, setTrainers] = useState<string[]>([]);
const [addresses, setAddresses] = useState<string[]>([]);
const [classTypes, setClassTypes] = useState<string[]>([]);
useEffect(() => {
fetch("/api/admin/team")
@@ -526,6 +770,20 @@ export default function ScheduleEditorPage() {
setTrainers(members.map((m) => m.name));
})
.catch(() => {});
fetch("/api/admin/sections/contact")
.then((r) => r.json())
.then((contact: { addresses?: string[] }) => {
setAddresses(contact.addresses ?? []);
})
.catch(() => {});
fetch("/api/admin/sections/classes")
.then((r) => r.json())
.then((classes: { items?: { name: string }[] }) => {
setClassTypes((classes.items ?? []).map((c) => c.name));
})
.catch(() => {});
}, []);
return (
@@ -588,15 +846,14 @@ export default function ScheduleEditorPage() {
))}
<button
type="button"
onClick={() =>
update({
...data,
locations: [
...data.locations,
{ name: "Новая локация", address: "", days: [] },
],
})
}
onClick={() => {
const newLocations = [
...data.locations,
{ name: "Новая локация", address: "", days: DAYS.map((d) => ({ day: d.day, dayShort: d.dayShort, classes: [] })) },
];
update({ ...data, locations: newLocations });
setActiveLocation(newLocations.length - 1);
}}
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
>
<Plus size={14} className="inline" /> Локация
@@ -607,6 +864,8 @@ export default function ScheduleEditorPage() {
<CalendarGrid
location={location}
trainers={trainers}
addresses={addresses}
classTypes={classTypes}
onChange={updateLocation}
/>
)}