Files
blackheart-website/src/app/admin/team/page.tsx
T
diana.dolgolyova a587736dd3 feat: mobile UX, admin polish, rate limiting, and media assets
- 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
2026-04-10 18:42:54 +03:00

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>
);
}