Files
blackheart-website/src/app/admin/team/page.tsx
diana.dolgolyova 27c1348f89 feat: admin panel with SQLite, auth, and calendar-style schedule editor
Complete admin panel for content management:
- SQLite database with better-sqlite3, seed script from content.ts
- Simple password auth with HMAC-signed cookies (Edge + Node compatible)
- 9 section editors: meta, hero, about, team, classes, schedule, pricing, FAQ, contact
- Team CRUD with image upload and drag reorder
- Schedule editor with Google Calendar-style visual timeline (colored blocks, overlap detection, click-to-add)
- All public components refactored to accept data props from DB (with fallback to static content)
- Middleware protecting /admin/* and /api/admin/* routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:59:12 +03:00

152 lines
4.7 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 } from "react";
import Image from "next/image";
import Link from "next/link";
import {
Loader2,
Plus,
Trash2,
ChevronUp,
ChevronDown,
Pencil,
Save,
Check,
} from "lucide-react";
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);
useEffect(() => {
fetch("/api/admin/team")
.then((r) => r.json())
.then(setMembers)
.finally(() => setLoading(false));
}, []);
const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated);
setSaving(true);
await fetch("/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);
}, []);
function moveItem(index: number, direction: -1 | 1) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= members.length) return;
const updated = [...members];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
saveOrder(updated);
}
async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return;
await fetch(`/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>
);
}
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 space-y-2">
{members.map((member, i) => (
<div
key={member.id}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3"
>
<div className="flex flex-col gap-0.5">
<button
onClick={() => moveItem(i, -1)}
disabled={i === 0}
className="text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronUp size={16} />
</button>
<button
onClick={() => moveItem(i, 1)}
disabled={i === members.length - 1}
className="text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronDown size={16} />
</button>
</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>
<div className="flex items-center gap-1">
<Link
href={`/admin/team/${member.id}`}
className="rounded p-2 text-neutral-400 hover:text-white transition-colors"
>
<Pencil size={16} />
</Link>
<button
onClick={() => deleteMember(member.id)}
className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
</div>
);
}