a587736dd3
- Mobile responsiveness improvements across admin and public sections - Admin: bookings modal, open-day page, team page, layout polish - Added rate limiting, CSRF hardening, auth-edge improvements - Scroll reveal, floating contact, back-to-top, Yandex map fixes - Schedule filters refactor, team profile/info component updates - New useTrainerPhotos hook - Added class, team, master-class, and news images
188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { Loader2, Plus, Check, AlertCircle } from "lucide-react";
|
|
import { adminFetch } from "@/lib/csrf";
|
|
import { InputField } from "../_components/FormField";
|
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
|
import type { TeamMember } from "@/types/content";
|
|
|
|
type Member = TeamMember & { id: number };
|
|
|
|
export default function TeamEditorPage() {
|
|
const [members, setMembers] = useState<Member[]>([]);
|
|
const [sectionTitle, setSectionTitle] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
|
|
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const titleLoadedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
adminFetch("/api/admin/team").then((r) => r.json()),
|
|
adminFetch("/api/admin/sections/team").then((r) => r.json()),
|
|
]).then(([membersData, sectionData]) => {
|
|
setMembers(membersData);
|
|
setSectionTitle(sectionData.title || "");
|
|
titleLoadedRef.current = true;
|
|
}).finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
// Auto-save section title with debounce (skip initial load)
|
|
const titleChangeCount = useRef(0);
|
|
const pendingSaveRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!titleLoadedRef.current) return;
|
|
titleChangeCount.current++;
|
|
if (titleChangeCount.current <= 1) return;
|
|
pendingSaveRef.current = true;
|
|
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
|
|
titleTimerRef.current = setTimeout(async () => {
|
|
const res = await adminFetch("/api/admin/sections/team", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: sectionTitle }),
|
|
});
|
|
setSaveStatus(res.ok ? "saved" : "error");
|
|
if (res.ok) pendingSaveRef.current = false;
|
|
setTimeout(() => setSaveStatus("idle"), 2000);
|
|
}, 800);
|
|
return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); };
|
|
}, [sectionTitle]);
|
|
|
|
// Warn before leaving with unsaved title
|
|
useEffect(() => {
|
|
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
if (pendingSaveRef.current) e.preventDefault();
|
|
}
|
|
function onLinkClick(e: MouseEvent) {
|
|
if (!pendingSaveRef.current) return;
|
|
const link = (e.target as HTMLElement).closest("a");
|
|
if (!link || link.target === "_blank") return;
|
|
const href = link.getAttribute("href");
|
|
if (!href || href.startsWith("#") || href.startsWith("/admin/team/")) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
|
|
adminFetch("/api/admin/sections/team", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: sectionTitle }),
|
|
}).finally(() => { window.location.href = href; });
|
|
}
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
document.addEventListener("click", onLinkClick, true);
|
|
return () => {
|
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
document.removeEventListener("click", onLinkClick, true);
|
|
};
|
|
}, [sectionTitle]);
|
|
|
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
|
const previous = members;
|
|
setMembers(updated);
|
|
try {
|
|
const res = await adminFetch("/api/admin/team/reorder", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
|
});
|
|
setSaveStatus(res.ok ? "saved" : "error");
|
|
if (!res.ok) setMembers(previous);
|
|
} catch {
|
|
setSaveStatus("error");
|
|
setMembers(previous);
|
|
}
|
|
setTimeout(() => setSaveStatus("idle"), 2000);
|
|
}, [members]);
|
|
|
|
async function deleteMember(id: number) {
|
|
try {
|
|
const res = await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
|
if (!res.ok) throw new Error();
|
|
setMembers((prev) => prev.filter((m) => m.id !== id));
|
|
} catch {
|
|
setSaveStatus("error");
|
|
setTimeout(() => setSaveStatus("idle"), 3000);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-neutral-400">
|
|
<Loader2 size={18} className="animate-spin" />
|
|
Загрузка...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Toast popup */}
|
|
{saveStatus !== "idle" && (
|
|
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
|
saveStatus === "saved"
|
|
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
|
: "bg-red-950/90 border-red-500/30 text-red-200"
|
|
}`}>
|
|
{saveStatus === "saved" && <><Check size={14} /> Сохранено</>}
|
|
{saveStatus === "error" && <><AlertCircle size={14} /> Ошибка сохранения</>}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h1 className="text-2xl font-bold">Команда</h1>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/admin/team/new"
|
|
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
|
|
>
|
|
<Plus size={16} />
|
|
Добавить
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
<InputField
|
|
label="Заголовок секции"
|
|
value={sectionTitle}
|
|
onChange={setSectionTitle}
|
|
placeholder="Наша команда"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
<ArrayEditor
|
|
items={members}
|
|
onChange={saveOrder}
|
|
createItem={() => ({ id: 0, name: "", role: "", image: "" })}
|
|
inline
|
|
hideAdd
|
|
getItemTitle={(m) => m.name || "Новый участник"}
|
|
renderItem={(member) => (
|
|
<Link
|
|
href={`/admin/team/${member.id}`}
|
|
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-white/[0.04] transition-colors"
|
|
>
|
|
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
|
{member.image ? (
|
|
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="56px" />
|
|
) : (
|
|
<div className="h-full w-full bg-neutral-800" />
|
|
)}
|
|
</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>
|
|
</Link>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|