- Replace event dispatchers with BookingContext (Hero, Header, FloatingContact) - Add focus trap hook for modals (SignupModal, NewsModal) - Extract shared components: CollapsibleSection, ConfirmDialog, PriceField, AdminSkeleton - Add delete confirmation dialog to ArrayEditor - Replace hardcoded colors (#050505, #0a0a0a, #c9a96e, #2ecc71) with theme tokens - Add CSS variables --color-surface-deep/dark for consistent dark surfaces - Improve contrast: muted text neutral-500 → neutral-400 in dark mode - Fix modal z-index hierarchy (modals z-60, header z-50, floats z-40) - Consolidate duplicate formatDate → shared formatting.ts - Add useMemo to TeamProfile groupMap computation - Fix typography: responsive price text in Pricing section - Add ARIA labels/expanded to FAQ, OpenDay, ArrayEditor grip handles - Hide number input spinners globally - Reorder admin sidebar: Dashboard → SEO → Bookings → site section order - Use shared PriceField in Open Day editor - Fix schedule grid first time slot (09:00) clipped by container - Fix pre-existing type errors (bookings, hero, db interfaces)
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
|
import { ConfirmDialog } from "./ConfirmDialog";
|
|
|
|
interface ArrayEditorProps<T> {
|
|
items: T[];
|
|
onChange: (items: T[]) => void;
|
|
renderItem: (item: T, index: number, update: (item: T) => void) => React.ReactNode;
|
|
createItem: () => T;
|
|
label?: string;
|
|
addLabel?: string;
|
|
collapsible?: boolean;
|
|
getItemTitle?: (item: T, index: number) => string;
|
|
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
|
hiddenItems?: Set<number>;
|
|
addPosition?: "top" | "bottom";
|
|
}
|
|
|
|
export function ArrayEditor<T>({
|
|
items,
|
|
onChange,
|
|
renderItem,
|
|
createItem,
|
|
label,
|
|
addLabel = "Добавить",
|
|
collapsible = false,
|
|
getItemTitle,
|
|
getItemBadge,
|
|
hiddenItems,
|
|
addPosition = "bottom",
|
|
}: ArrayEditorProps<T>) {
|
|
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
|
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);
|
|
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
|
|
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
|
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
|
|
|
function toggleCollapse(index: number) {
|
|
setCollapsed(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(index)) next.delete(index);
|
|
else next.add(index);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
useEffect(() => { setMounted(true); }, []);
|
|
|
|
// Scroll to newly added item
|
|
useEffect(() => {
|
|
if (newItemIndex !== null && itemRefs.current[newItemIndex]) {
|
|
itemRefs.current[newItemIndex]?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
setNewItemIndex(null);
|
|
}
|
|
}, [newItemIndex, items]);
|
|
|
|
function updateItem(index: number, item: T) {
|
|
const updated = [...items];
|
|
updated[index] = item;
|
|
onChange(updated);
|
|
}
|
|
|
|
function removeItem(index: number) {
|
|
onChange(items.filter((_, i) => i !== index));
|
|
}
|
|
|
|
const startDrag = useCallback(
|
|
(clientX: number, clientY: number, index: number) => {
|
|
const el = itemRefs.current[index];
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
setDragIndex(index);
|
|
setInsertAt(index);
|
|
setMousePos({ x: clientX, y: clientY });
|
|
setDragSize({ w: rect.width, h: rect.height });
|
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleGripMouseDown = useCallback(
|
|
(e: React.MouseEvent, index: number) => {
|
|
e.preventDefault();
|
|
startDrag(e.clientX, e.clientY, index);
|
|
},
|
|
[startDrag]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (dragIndex === null) return;
|
|
|
|
document.body.style.userSelect = "none";
|
|
|
|
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() {
|
|
// Read current values from state updaters but defer onChange to avoid
|
|
// calling parent setState during React's render/updater cycle
|
|
let capturedDrag: number | null = null;
|
|
let capturedInsert: number | null = null;
|
|
|
|
setDragIndex((prev) => { capturedDrag = prev; return null; });
|
|
setInsertAt((prev) => { capturedInsert = prev; return null; });
|
|
|
|
// Defer the reorder to next microtask so React finishes its batch first
|
|
queueMicrotask(() => {
|
|
if (capturedDrag !== null && capturedInsert !== null) {
|
|
let targetIndex = capturedInsert;
|
|
if (capturedDrag < targetIndex) targetIndex -= 1;
|
|
if (capturedDrag !== targetIndex) {
|
|
const updated = [...items];
|
|
const [moved] = updated.splice(capturedDrag, 1);
|
|
updated.splice(targetIndex, 0, moved);
|
|
onChange(updated);
|
|
setDroppedIndex(targetIndex);
|
|
setTimeout(() => setDroppedIndex(null), 1500);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener("mousemove", onMouseMove);
|
|
window.addEventListener("mouseup", onMouseUp);
|
|
return () => {
|
|
document.body.style.userSelect = "";
|
|
window.removeEventListener("mousemove", onMouseMove);
|
|
window.removeEventListener("mouseup", onMouseUp);
|
|
};
|
|
}, [dragIndex, items, onChange]);
|
|
|
|
function renderList() {
|
|
if (dragIndex === null || insertAt === null) {
|
|
return items.map((item, i) => {
|
|
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
|
|
const isHidden = hiddenItems?.has(i) ?? false;
|
|
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
|
return (
|
|
<div
|
|
key={i}
|
|
ref={(el) => { itemRefs.current[i] = el; }}
|
|
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${
|
|
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
|
} ${isHidden ? "hidden" : ""}`}
|
|
>
|
|
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<div
|
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
|
aria-label="Перетащить для сортировки"
|
|
role="button"
|
|
>
|
|
<GripVertical size={16} />
|
|
</div>
|
|
{collapsible && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCollapse(i)}
|
|
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
|
>
|
|
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
|
{getItemBadge?.(item, i)}
|
|
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(i)}
|
|
aria-label="Удалить элемент"
|
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
{collapsible ? (
|
|
<div
|
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
|
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div className="px-4 pb-4">
|
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="px-4 pb-4">
|
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
|
</div>
|
|
)}
|
|
</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-gold/40 bg-gold/5 mb-3"
|
|
style={{ height: collapsible ? 48 : dragSize.h }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const item = items[i];
|
|
const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`;
|
|
elements.push(
|
|
<div
|
|
key={i}
|
|
ref={(el) => { itemRefs.current[i] = el; }}
|
|
className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors"
|
|
>
|
|
{collapsible ? (
|
|
<div className="flex items-center gap-2 p-4">
|
|
<GripVertical size={16} className="text-neutral-500 shrink-0" />
|
|
<span className="text-sm font-medium text-neutral-300 truncate">{dragTitle}</span>
|
|
{getItemBadge?.(item, i)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-start justify-between gap-2 p-4 pb-0 mb-3">
|
|
<div
|
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
|
aria-label="Перетащить для сортировки"
|
|
role="button"
|
|
>
|
|
<GripVertical size={16} />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeItem(i)}
|
|
aria-label="Удалить элемент"
|
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="px-4 pb-4">
|
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
visualIndex++;
|
|
}
|
|
|
|
if (visualIndex === placeholderPos) {
|
|
elements.push(
|
|
<div
|
|
key="placeholder"
|
|
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
|
style={{ height: collapsible ? 48 : dragSize.h }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return elements;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{(label || (collapsible && items.length > 1)) && (
|
|
<div className="flex items-center justify-between mb-3">
|
|
{label ? <h3 className="text-sm font-medium text-neutral-300">{label}</h3> : <div />}
|
|
{collapsible && items.length > 1 && (() => {
|
|
const allCollapsed = collapsed.size >= items.length;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
|
className="rounded p-1 text-neutral-500 hover:text-white transition-colors"
|
|
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
|
>
|
|
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
|
</button>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{addPosition === "top" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onChange([createItem(), ...items]);
|
|
setNewItemIndex(0);
|
|
// Shift collapsed indices and ensure new item is expanded
|
|
setCollapsed(prev => {
|
|
const next = new Set<number>();
|
|
for (const idx of prev) next.add(idx + 1);
|
|
return next;
|
|
});
|
|
}}
|
|
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
|
>
|
|
<Plus size={16} />
|
|
{addLabel}
|
|
</button>
|
|
)}
|
|
|
|
<div>
|
|
{renderList()}
|
|
</div>
|
|
|
|
{addPosition === "bottom" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onChange([...items, createItem()]);
|
|
setNewItemIndex(items.length);
|
|
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
|
}}
|
|
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
|
>
|
|
<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-gold/60 bg-neutral-900/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4">
|
|
<GripVertical size={16} className="text-gold shrink-0" />
|
|
<span className="text-sm text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={confirmDelete !== null}
|
|
title="Удалить элемент?"
|
|
message="Это действие нельзя отменить."
|
|
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|