Files
blackheart-website/src/app/admin/team/page.tsx
diana.dolgolyova 6cbdba2197 feat: add CSRF protection for admin API routes
Double-submit cookie pattern: login sets bh-csrf-token cookie,
proxy.ts validates X-CSRF-Token header on POST/PUT/DELETE to /api/admin/*.
New adminFetch() helper in src/lib/csrf.ts auto-includes the header.
All admin pages migrated from fetch() to adminFetch().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:53:02 +03:00

344 lines
12 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, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import Link from "next/link";
import {
Loader2,
Plus,
Trash2,
GripVertical,
Check,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { TeamMember } from "@/types/content";
type Member = TeamMember & { id: number };
export default function TeamEditorPage() {
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
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)[]>([]);
useEffect(() => {
adminFetch("/api/admin/team")
.then((r) => r.json())
.then(setMembers)
.finally(() => setLoading(false));
}, []);
const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated);
setSaving(true);
await adminFetch("/api/admin/team/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
});
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}, []);
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]
);
const handleCardMouseDown = useCallback(
(e: React.MouseEvent, index: number) => {
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
if (tag) return;
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
const pendingIndex = index;
let moved = false;
function onMove(ev: MouseEvent) {
const dx = ev.clientX - x;
const dy = ev.clientY - y;
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
moved = true;
cleanup();
startDrag(ev.clientX, ev.clientY, pendingIndex);
}
}
function onUp() {
cleanup();
if (!moved) {
window.location.href = `/admin/team/${members[pendingIndex].id}`;
}
}
function cleanup() {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[startDrag, members]
);
useEffect(() => {
if (dragIndex === null) return;
document.body.style.userSelect = "none";
function onMouseMove(e: MouseEvent) {
setMousePos({ x: e.clientX, y: e.clientY });
let newInsert = members.length;
for (let i = 0; i < members.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 > dragIndex! ? i : i;
break;
}
}
setInsertAt(newInsert);
}
function onMouseUp() {
setDragIndex((prevDrag) => {
setInsertAt((prevInsert) => {
if (prevDrag !== null && prevInsert !== null) {
let targetIndex = prevInsert;
if (prevDrag < targetIndex) targetIndex -= 1;
if (prevDrag !== targetIndex) {
const updated = [...members];
const [moved] = updated.splice(prevDrag, 1);
updated.splice(targetIndex, 0, moved);
saveOrder(updated);
}
}
return null;
});
return null;
});
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragIndex, members, saveOrder]);
async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return;
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
setMembers((prev) => prev.filter((m) => m.id !== id));
}
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
// Build the visual order: remove dragged item, insert placeholder at insertAt
function renderList() {
if (dragIndex === null || insertAt === null) {
// Normal render — no drag
return members.map((member, i) => (
<div
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
</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>
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
));
}
// During drag: build list without the dragged item, with placeholder inserted
const elements: React.ReactNode[] = [];
let visualIndex = 0;
// Determine where to insert placeholder relative to non-dragged items
let placeholderPos = insertAt;
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
for (let i = 0; i < members.length; i++) {
if (i === dragIndex) {
// Keep a hidden ref so midpoint detection still works
elements.push(
<div key={`hidden-${members[i].id}`} 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-rose-500/50 bg-rose-500/5 mb-2"
style={{ height: dragSize.h }}
/>
);
}
const member = members[i];
elements.push(
<div
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
</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>
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
);
visualIndex++;
}
// Placeholder at the end
if (visualIndex === placeholderPos) {
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
style={{ height: dragSize.h }}
/>
);
}
return elements;
}
return (
<div>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Команда</h1>
<div className="flex items-center gap-3">
{(saving || saved) && (
<span className="text-sm text-neutral-400 flex items-center gap-1">
{saving ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Check size={14} className="text-green-400" />
)}
{saving ? "Сохранение..." : "Сохранено!"}
</span>
)}
<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">
{renderList()}
</div>
{/* Floating card following cursor */}
{dragIndex !== null &&
draggedMember &&
createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{
left: mousePos.x - grabOffset.x,
top: mousePos.y - grabOffset.y,
width: dragSize.w,
}}
>
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
<div className="text-rose-400">
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image
src={draggedMember.image}
alt={draggedMember.name}
fill
className="object-cover"
sizes="48px"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{draggedMember.name}</p>
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
</div>
</div>
</div>,
document.body
)}
</div>
);
}