Files
blackheart-website/src/app/admin/_components/SectionEditor.tsx
diana.dolgolyova ed5a164d59 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>
2026-03-11 18:40:33 +03:00

117 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, Check, AlertCircle } from "lucide-react";
interface SectionEditorProps<T> {
sectionKey: string;
title: string;
children: (data: T, update: (data: T) => void) => React.ReactNode;
}
const DEBOUNCE_MS = 800;
export function SectionEditor<T>({
sectionKey,
title,
children,
}: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
useEffect(() => {
fetch(`/api/admin/sections/${sectionKey}`)
.then((r) => {
if (!r.ok) throw new Error("Failed to load");
return r.json();
})
.then(setData)
.catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false));
}, [sectionKey]);
const save = useCallback(async (dataToSave: T) => {
setStatus("saving");
setError("");
try {
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
if (!res.ok) throw new Error("Failed to save");
setStatus("saved");
setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000);
} catch {
setStatus("error");
setError("Ошибка сохранения");
}
}, [sectionKey]);
// Auto-save with debounce whenever data changes (skip initial load)
useEffect(() => {
if (!data) return;
if (initialLoadRef.current) {
initialLoadRef.current = false;
return;
}
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
save(data);
}, DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [data, save]);
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
if (!data) {
return <p className="text-red-400">{error || "Данные не найдены"}</p>;
}
return (
<div>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex items-center gap-2 text-sm text-neutral-400">
{status === "saving" && (
<>
<Loader2 size={14} className="animate-spin" />
<span>Сохранение...</span>
</>
)}
{status === "saved" && (
<>
<Check size={14} className="text-emerald-400" />
<span className="text-emerald-400">Сохранено</span>
</>
)}
{status === "error" && (
<>
<AlertCircle size={14} className="text-red-400" />
<span className="text-red-400">{error}</span>
</>
)}
</div>
</div>
<div className="mt-6 space-y-6">{children(data, setData)}</div>
</div>
);
}