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>
This commit is contained in:
2026-03-11 16:59:12 +03:00
parent d5afaf92ba
commit 27c1348f89
44 changed files with 3709 additions and 69 deletions

3
.gitignore vendored
View File

@@ -36,6 +36,9 @@ yarn-error.log*
# vercel
.vercel
# database
/db/
# claude
.claude/

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;

892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,11 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"seed": "tsx src/data/seed.ts"
},
"dependencies": {
"better-sqlite3": "^12.6.2",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"react": "19.2.3",
@@ -16,6 +18,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -24,6 +27,7 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,94 @@
"use client";
import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react";
interface ArrayEditorProps<T> {
items: T[];
onChange: (items: T[]) => void;
renderItem: (item: T, index: number, update: (item: T) => void) => React.ReactNode;
createItem: () => T;
label?: string;
addLabel?: string;
}
export function ArrayEditor<T>({
items,
onChange,
renderItem,
createItem,
label,
addLabel = "Добавить",
}: ArrayEditorProps<T>) {
function updateItem(index: number, item: T) {
const updated = [...items];
updated[index] = item;
onChange(updated);
}
function removeItem(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function moveItem(index: number, direction: -1 | 1) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= items.length) return;
const updated = [...items];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
onChange(updated);
}
return (
<div>
{label && (
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
)}
<div className="space-y-3">
{items.map((item, i) => (
<div
key={i}
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4"
>
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex gap-1">
<button
type="button"
onClick={() => moveItem(i, -1)}
disabled={i === 0}
className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={() => moveItem(i, 1)}
disabled={i === items.length - 1}
className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
>
<ChevronDown size={16} />
</button>
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
))}
</div>
<button
type="button"
onClick={() => onChange([...items, createItem()])}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={16} />
{addLabel}
</button>
</div>
);
}

View File

@@ -0,0 +1,171 @@
interface InputFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: "text" | "url" | "tel";
}
export function InputField({
label,
value,
onChange,
placeholder,
type = "text",
}: InputFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
/>
</div>
);
}
interface TextareaFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function TextareaField({
label,
value,
onChange,
placeholder,
rows = 3,
}: TextareaFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-y"
/>
</div>
);
}
interface SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function SelectField({
label,
value,
onChange,
options,
placeholder,
}: SelectFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
interface TimeRangeFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
// Parse "HH:MMHH:MM" into start and end
const parts = value.split("");
const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || "";
function update(s: string, e: string) {
if (s && e) {
onChange(`${s}${e}`);
} else if (s) {
onChange(s);
} else {
onChange("");
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex items-center gap-2">
<input
type="time"
value={start}
onChange={(e) => update(e.target.value, end)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
<span className="text-neutral-500"></span>
<input
type="time"
value={end}
onChange={(e) => update(start, e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
</div>
);
}
interface ToggleFieldProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? "bg-gold" : "bg-neutral-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
checked ? "translate-x-5" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-300">{label}</span>
</label>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Save, Loader2, Check } from "lucide-react";
interface SectionEditorProps<T> {
sectionKey: string;
title: string;
children: (data: T, update: (data: T) => void) => React.ReactNode;
}
export function SectionEditor<T>({
sectionKey,
title,
children,
}: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
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 handleSave = useCallback(async () => {
if (!data) return;
setSaving(true);
setSaved(false);
setError("");
try {
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to save");
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch {
setError("Ошибка сохранения");
} finally {
setSaving(false);
}
}, [data, sectionKey]);
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>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : saved ? (
<Check size={16} />
) : (
<Save size={16} />
)}
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
</button>
</div>
{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
<div className="mt-6 space-y-6">{children(data, setData)}</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface AboutData {
title: string;
paragraphs: string[];
}
export default function AboutEditorPage() {
return (
<SectionEditor<AboutData> sectionKey="about" title="О студии">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Параграфы"
items={data.paragraphs}
onChange={(paragraphs) => update({ ...data, paragraphs })}
renderItem={(text, _i, updateItem) => (
<TextareaField
label={`Параграф`}
value={text}
onChange={updateItem}
rows={3}
/>
)}
createItem={() => ""}
addLabel="Добавить параграф"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
const ICON_OPTIONS = [
"sparkles", "flame", "wind", "zap", "star", "monitor",
"heart", "music", "dumbbell", "trophy",
];
interface ClassesData {
title: string;
items: {
name: string;
description: string;
icon: string;
detailedDescription?: string;
images?: string[];
}[];
}
export default function ClassesEditorPage() {
return (
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Направления"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Иконка
</label>
<select
value={item.icon}
onChange={(e) =>
updateItem({ ...item, icon: e.target.value })
}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
>
{ICON_OPTIONS.map((icon) => (
<option key={icon} value={icon}>
{icon}
</option>
))}
</select>
</div>
</div>
<TextareaField
label="Краткое описание"
value={item.description}
onChange={(v) => updateItem({ ...item, description: v })}
rows={2}
/>
<TextareaField
label="Подробное описание"
value={item.detailedDescription || ""}
onChange={(v) =>
updateItem({ ...item, detailedDescription: v })
}
rows={4}
/>
</div>
)}
createItem={() => ({
name: "",
description: "",
icon: "sparkles",
detailedDescription: "",
images: [],
})}
addLabel="Добавить направление"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import type { ContactInfo } from "@/types/content";
export default function ContactEditorPage() {
return (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Телефон"
value={data.phone}
onChange={(v) => update({ ...data, phone: v })}
type="tel"
/>
<InputField
label="Instagram"
value={data.instagram}
onChange={(v) => update({ ...data, instagram: v })}
type="url"
/>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<ArrayEditor
label="Адреса"
items={data.addresses}
onChange={(addresses) => update({ ...data, addresses })}
renderItem={(addr, _i, updateItem) => (
<InputField label="Адрес" value={addr} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить адрес"
/>
<TextareaField
label="URL карты (Yandex Maps iframe)"
value={data.mapEmbedUrl}
onChange={(v) => update({ ...data, mapEmbedUrl: v })}
rows={2}
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface FAQData {
title: string;
items: { question: string; answer: string }[];
}
export default function FAQEditorPage() {
return (
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Вопросы и ответы"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<InputField
label="Вопрос"
value={item.question}
onChange={(v) => updateItem({ ...item, question: v })}
/>
<TextareaField
label="Ответ"
value={item.answer}
onChange={(v) => updateItem({ ...item, answer: v })}
rows={3}
/>
</div>
)}
createItem={() => ({ question: "", answer: "" })}
addLabel="Добавить вопрос"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField";
interface HeroData {
headline: string;
subheadline: string;
ctaText: string;
ctaHref: string;
}
export default function HeroEditorPage() {
return (
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
{(data, update) => (
<>
<InputField
label="Заголовок"
value={data.headline}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText}
onChange={(v) => update({ ...data, ctaText: v })}
/>
<InputField
label="Ссылка кнопки"
value={data.ctaHref}
onChange={(v) => update({ ...data, ctaHref: v })}
type="url"
/>
</>
)}
</SectionEditor>
);
}

146
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
LayoutDashboard,
Sparkles,
Users,
BookOpen,
Calendar,
DollarSign,
HelpCircle,
Phone,
FileText,
Globe,
LogOut,
Menu,
X,
ChevronLeft,
} from "lucide-react";
const NAV_ITEMS = [
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
{ href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/contact", label: "Контакты", icon: Phone },
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Don't render admin shell on login page
if (pathname === "/admin/login") {
return <>{children}</>;
}
async function handleLogout() {
await fetch("/api/logout", { method: "POST" });
router.push("/admin/login");
}
function isActive(href: string) {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
}
return (
<div className="flex min-h-screen bg-neutral-950 text-white">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:static lg:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<Link href="/admin" className="text-lg font-bold">
BLACK HEART
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-neutral-400 hover:text-white"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
active
? "bg-gold/10 text-gold font-medium"
: "text-neutral-400 hover:text-white hover:bg-white/5"
}`}
>
<Icon size={18} />
{item.label}
</Link>
);
})}
</nav>
<div className="border-t border-white/10 p-3 space-y-1">
<Link
href="/"
target="_blank"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
>
<ChevronLeft size={18} />
Открыть сайт
</Link>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
>
<LogOut size={18} />
Выйти
</button>
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (mobile) */}
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-neutral-400 hover:text-white"
>
<Menu size={24} />
</button>
<span className="font-bold">BLACK HEART</span>
</header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminLoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push("/admin");
} else {
setError("Неверный пароль");
}
} catch {
setError("Ошибка соединения");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
>
<div className="text-center">
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
Пароль
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
placeholder="Введите пароль"
autoFocus
/>
</div>
{error && (
<p className="text-sm text-red-400 text-center">{error}</p>
)}
<button
type="submit"
disabled={loading || !password}
className="w-full rounded-lg bg-gold px-4 py-3 font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
>
{loading ? "Вход..." : "Войти"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
interface MetaData {
title: string;
description: string;
}
export default function MetaEditorPage() {
return (
<SectionEditor<MetaData> sectionKey="meta" title="SEO / Мета">
{(data, update) => (
<>
<InputField
label="Заголовок сайта (title)"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<TextareaField
label="Описание (description)"
value={data.description}
onChange={(v) => update({ ...data, description: v })}
rows={3}
/>
</>
)}
</SectionEditor>
);
}

58
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,58 @@
import Link from "next/link";
import {
Globe,
Sparkles,
FileText,
Users,
BookOpen,
Calendar,
DollarSign,
HelpCircle,
Phone,
} from "lucide-react";
const CARDS = [
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles, desc: "Заголовок, подзаголовок, кнопка" },
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
];
export default function AdminDashboard() {
return (
<div>
<h1 className="text-2xl font-bold">Панель управления</h1>
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{CARDS.map((card) => {
const Icon = card.icon;
return (
<Link
key={card.href}
href={card.href}
className="group rounded-xl border border-white/10 bg-neutral-900 p-5 transition-all hover:border-gold/30 hover:bg-neutral-900/80"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
<Icon size={20} />
</div>
<div>
<h2 className="font-medium text-white group-hover:text-gold transition-colors">
{card.label}
</h2>
<p className="text-xs text-neutral-500">{card.desc}</p>
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface PricingData {
title: string;
subtitle: string;
items: { name: string; price: string; note?: string }[];
rentalTitle: string;
rentalItems: { name: string; price: string; note?: string }[];
rules: string[];
}
export default function PricingEditorPage() {
return (
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Подзаголовок"
value={data.subtitle}
onChange={(v) => update({ ...data, subtitle: v })}
/>
<ArrayEditor
label="Абонементы"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="grid gap-3 sm:grid-cols-3">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<InputField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
<InputField
label="Примечание"
value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v })}
/>
</div>
)}
createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить абонемент"
/>
<InputField
label="Заголовок аренды"
value={data.rentalTitle}
onChange={(v) => update({ ...data, rentalTitle: v })}
/>
<ArrayEditor
label="Аренда"
items={data.rentalItems}
onChange={(rentalItems) => update({ ...data, rentalItems })}
renderItem={(item, _i, updateItem) => (
<div className="grid gap-3 sm:grid-cols-3">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<InputField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
<InputField
label="Примечание"
value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v })}
/>
</div>
)}
createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить вариант аренды"
/>
<ArrayEditor
label="Правила"
items={data.rules}
onChange={(rules) => update({ ...data, rules })}
renderItem={(rule, _i, updateItem) => (
<InputField label="Правило" value={rule} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить правило"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,618 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
import { Plus, X, Trash2, GripVertical } from "lucide-react";
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
interface ScheduleData {
title: string;
locations: ScheduleLocation[];
}
const DAYS = [
{ day: "Понедельник", dayShort: "ПН" },
{ day: "Вторник", dayShort: "ВТ" },
{ day: "Среда", dayShort: "СР" },
{ day: "Четверг", dayShort: "ЧТ" },
{ day: "Пятница", dayShort: "ПТ" },
{ day: "Суббота", dayShort: "СБ" },
{ day: "Воскресенье", dayShort: "ВС" },
];
const DAY_ORDER: Record<string, number> = Object.fromEntries(
DAYS.map((d, i) => [d.day, i])
);
const LEVELS = [
{ value: "", label: "Без уровня" },
{ value: "Начинающий", label: "Начинающий" },
{ value: "Продвинутый", label: "Продвинутый" },
];
const CLASS_TYPES = [
"Exotic Pole Dance",
"Pole Dance",
"Body Plastic",
"Stretching",
"Pole Exotic",
"Twerk",
];
const TYPE_COLORS: Record<string, string> = {
"Exotic Pole Dance": "bg-rose-500/80 border-rose-400",
"Pole Dance": "bg-violet-500/80 border-violet-400",
"Body Plastic": "bg-amber-500/80 border-amber-400",
"Stretching": "bg-emerald-500/80 border-emerald-400",
"Pole Exotic": "bg-pink-500/80 border-pink-400",
"Twerk": "bg-sky-500/80 border-sky-400",
};
// Calendar config
const HOUR_START = 9;
const HOUR_END = 23;
const HOUR_HEIGHT = 60; // px per hour
const TOTAL_HOURS = HOUR_END - HOUR_START;
function parseTime(timeStr: string): { h: number; m: number } | null {
const [h, m] = (timeStr || "").split(":").map(Number);
if (isNaN(h) || isNaN(m)) return null;
return { h, m };
}
function timeToMinutes(timeStr: string): number {
const t = parseTime(timeStr);
if (!t) return 0;
return t.h * 60 + t.m;
}
function minutesToY(minutes: number): number {
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT;
}
function yToMinutes(y: number): number {
return Math.round((y / HOUR_HEIGHT) * 60 + HOUR_START * 60);
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function sortDaysByWeekday(days: ScheduleDay[]): ScheduleDay[] {
return [...days].sort((a, b) => (DAY_ORDER[a.day] ?? 99) - (DAY_ORDER[b.day] ?? 99));
}
/** Check if two time ranges overlap */
function hasOverlap(a: ScheduleClass, b: ScheduleClass): boolean {
const [aStart, aEnd] = a.time.split("").map((s) => timeToMinutes(s.trim()));
const [bStart, bEnd] = b.time.split("").map((s) => timeToMinutes(s.trim()));
if (!aStart || !aEnd || !bStart || !bEnd) return false;
return aStart < bEnd && bStart < aEnd;
}
/** Get all overlapping indices for a given class in the list */
function getOverlaps(classes: ScheduleClass[], index: number): boolean {
const cls = classes[index];
for (let i = 0; i < classes.length; i++) {
if (i === index) continue;
if (hasOverlap(cls, classes[i])) return true;
}
return false;
}
// ---------- Class Block on Calendar ----------
function ClassBlock({
cls,
index,
isOverlapping,
onClick,
}: {
cls: ScheduleClass;
index: number;
isOverlapping: boolean;
onClick: () => void;
}) {
const parts = cls.time.split("");
const startMin = timeToMinutes(parts[0]?.trim() || "");
const endMin = timeToMinutes(parts[1]?.trim() || "");
if (!startMin || !endMin || endMin <= startMin) return null;
const top = minutesToY(startMin);
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
const colors = TYPE_COLORS[cls.type] || "bg-neutral-600/80 border-neutral-500";
return (
<button
type="button"
onClick={onClick}
style={{ top: `${top}px`, height: `${height}px` }}
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white transition-opacity hover:opacity-90 cursor-pointer overflow-hidden ${colors} ${
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
>
<div className="font-semibold truncate leading-tight">
{parts[0]?.trim()}{parts[1]?.trim()}
</div>
{height > 30 && (
<div className="truncate text-white/80 leading-tight">{cls.type}</div>
)}
{height > 48 && (
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
)}
{isOverlapping && height > 30 && (
<div className="text-red-200 font-medium leading-tight"> Пересечение</div>
)}
</button>
);
}
// ---------- Edit Modal ----------
function ClassModal({
cls,
trainers,
onSave,
onDelete,
onClose,
}: {
cls: ScheduleClass;
trainers: string[];
onSave: (cls: ScheduleClass) => void;
onDelete?: () => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState<ScheduleClass>(cls);
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
const typeOptions = CLASS_TYPES.map((t) => ({ value: t, label: t }));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="w-full max-w-md rounded-xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">
{onDelete ? "Редактировать занятие" : "Новое занятие"}
</h3>
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<TimeRangeField
label="Время"
value={draft.time}
onChange={(v) => setDraft({ ...draft, time: v })}
/>
<SelectField
label="Тренер"
value={draft.trainer}
onChange={(v) => setDraft({ ...draft, trainer: v })}
options={trainerOptions}
placeholder="Выберите тренера"
/>
<SelectField
label="Тип"
value={draft.type}
onChange={(v) => setDraft({ ...draft, type: v })}
options={typeOptions}
placeholder="Выберите тип"
/>
<SelectField
label="Уровень"
value={draft.level || ""}
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
options={LEVELS}
/>
<div className="flex gap-6">
<ToggleField
label="Есть места"
checked={draft.hasSlots ?? false}
onChange={(v) => setDraft({ ...draft, hasSlots: v })}
/>
<ToggleField
label="Набор открыт"
checked={draft.recruiting ?? false}
onChange={(v) => setDraft({ ...draft, recruiting: v })}
/>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
type="button"
onClick={() => {
onSave(draft);
onClose();
}}
className="flex-1 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
>
Сохранить
</button>
{onDelete && (
<button
type="button"
onClick={() => {
onDelete();
onClose();
}}
className="rounded-lg border border-red-500/30 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
);
}
// ---------- Calendar Grid for one location ----------
function CalendarGrid({
location,
trainers,
onChange,
}: {
location: ScheduleLocation;
trainers: string[];
onChange: (loc: ScheduleLocation) => void;
}) {
const [editingClass, setEditingClass] = useState<{
dayIndex: number;
classIndex: number;
} | null>(null);
const [newClass, setNewClass] = useState<{
dayIndex: number;
cls: ScheduleClass;
} | null>(null);
const sortedDays = sortDaysByWeekday(location.days);
const usedDays = new Set(location.days.map((d) => d.day));
const availableDays = DAYS.filter((d) => !usedDays.has(d.day));
const hours: number[] = [];
for (let h = HOUR_START; h <= HOUR_END; h++) {
hours.push(h);
}
function handleCellClick(dayIndex: number, e: React.MouseEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const minutes = yToMinutes(y);
// Snap to 15-min intervals
const snapped = Math.round(minutes / 15) * 15;
const startTime = formatMinutes(snapped);
const endTime = formatMinutes(snapped + 60);
setNewClass({
dayIndex,
cls: {
time: `${startTime}${endTime}`,
trainer: "",
type: "",
},
});
}
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
// Find the actual index in location.days (since we display sorted)
const actualDay = sortedDays[dayIndex];
const actualIndex = location.days.findIndex((d) => d.day === actualDay.day);
if (actualIndex === -1) return;
const days = [...location.days];
days[actualIndex] = updatedDay;
onChange({ ...location, days });
}
function deleteDay(dayIndex: number) {
const actualDay = sortedDays[dayIndex];
const days = location.days.filter((d) => d.day !== actualDay.day);
onChange({ ...location, days });
}
function addDay(dayName: string, dayShort: string) {
onChange({
...location,
days: sortDaysByWeekday([
...location.days,
{ day: dayName, dayShort, classes: [] },
]),
});
}
// Get the class being edited
const editingData = editingClass
? {
day: sortedDays[editingClass.dayIndex],
cls: sortedDays[editingClass.dayIndex]?.classes[editingClass.classIndex],
}
: null;
return (
<div className="space-y-4">
{/* Location name/address */}
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Название локации"
value={location.name}
onChange={(v) => onChange({ ...location, name: v })}
/>
<InputField
label="Адрес"
value={location.address}
onChange={(v) => onChange({ ...location, address: v })}
/>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2">
{CLASS_TYPES.map((type) => {
const colors = TYPE_COLORS[type] || "";
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
return (
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
{type}
</div>
);
})}
</div>
{/* Calendar */}
{sortedDays.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/20 p-8 text-center text-neutral-500">
Добавьте дни недели чтобы увидеть расписание
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-white/10">
<div className="min-w-[600px]">
{/* Day headers */}
<div className="flex border-b border-white/10 bg-neutral-800/50">
<div className="w-14 shrink-0" />
{sortedDays.map((day, di) => (
<div
key={day.day}
className="flex-1 border-l border-white/10 px-2 py-2 text-center"
>
<div className="flex items-center justify-center gap-1">
<span className="text-sm font-medium text-white">{day.dayShort}</span>
<span className="text-xs text-neutral-500">({day.classes.length})</span>
<button
type="button"
onClick={() => deleteDay(di)}
className="ml-1 rounded p-0.5 text-neutral-600 hover:text-red-400 transition-colors"
title="Удалить день"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
{/* Time grid */}
<div className="flex">
{/* Time labels */}
<div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}>
{hours.slice(0, -1).map((h) => (
<div
key={h}
className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT - 6}px` }}
>
{String(h).padStart(2, "0")}:00
</div>
))}
</div>
{/* Day columns */}
{sortedDays.map((day, di) => (
<div
key={day.day}
className="flex-1 border-l border-white/10 relative cursor-crosshair"
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
onClick={(e) => {
// Only add if clicking on empty space (not on a class block)
if ((e.target as HTMLElement).closest("button")) return;
handleCellClick(di, e);
}}
>
{/* Hour lines */}
{hours.slice(0, -1).map((h) => (
<div
key={h}
className="absolute left-0 right-0 border-t border-white/5"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
/>
))}
{/* Half-hour lines */}
{hours.slice(0, -1).map((h) => (
<div
key={`${h}-30`}
className="absolute left-0 right-0 border-t border-white/[0.02]"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
/>
))}
{/* Class blocks */}
{day.classes.map((cls, ci) => (
<ClassBlock
key={ci}
cls={cls}
index={ci}
isOverlapping={getOverlaps(day.classes, ci)}
onClick={() => setEditingClass({ dayIndex: di, classIndex: ci })}
/>
))}
</div>
))}
</div>
</div>
</div>
)}
{/* Add day buttons */}
{availableDays.length > 0 && (
<div className="flex flex-wrap gap-2">
<span className="flex items-center text-sm text-neutral-500">
<Plus size={14} className="mr-1" /> Добавить день:
</span>
{availableDays.map((d) => (
<button
key={d.day}
type="button"
onClick={() => addDay(d.day, d.dayShort)}
className="rounded-lg border border-dashed border-white/20 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
{d.dayShort}
</button>
))}
</div>
)}
{/* Edit modal */}
{editingData?.cls && editingClass && (
<ClassModal
cls={editingData.cls}
trainers={trainers}
onSave={(updated) => {
const day = sortedDays[editingClass.dayIndex];
const classes = [...day.classes];
classes[editingClass.classIndex] = updated;
updateDay(editingClass.dayIndex, { ...day, classes });
}}
onDelete={() => {
const day = sortedDays[editingClass.dayIndex];
const classes = day.classes.filter((_, i) => i !== editingClass.classIndex);
updateDay(editingClass.dayIndex, { ...day, classes });
}}
onClose={() => setEditingClass(null)}
/>
)}
{/* New class modal */}
{newClass && (
<ClassModal
cls={newClass.cls}
trainers={trainers}
onSave={(created) => {
const day = sortedDays[newClass.dayIndex];
const classes = [...day.classes, created];
updateDay(newClass.dayIndex, { ...day, classes });
}}
onClose={() => setNewClass(null)}
/>
)}
</div>
);
}
// ---------- Main Page ----------
export default function ScheduleEditorPage() {
const [activeLocation, setActiveLocation] = useState(0);
const [trainers, setTrainers] = useState<string[]>([]);
useEffect(() => {
fetch("/api/admin/team")
.then((r) => r.json())
.then((members: { name: string }[]) => {
setTrainers(members.map((m) => m.name));
})
.catch(() => {});
}, []);
return (
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
{(data, update) => {
const location = data.locations[activeLocation];
function updateLocation(updated: ScheduleLocation) {
const locations = [...data.locations];
locations[activeLocation] = updated;
update({ ...data, locations });
}
function deleteLocation(index: number) {
if (data.locations.length <= 1) return;
const locations = data.locations.filter((_, i) => i !== index);
update({ ...data, locations });
if (activeLocation >= locations.length) {
setActiveLocation(locations.length - 1);
}
}
return (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
{/* Location tabs */}
<div className="flex flex-wrap gap-2">
{data.locations.map((loc, i) => (
<div
key={i}
className={`flex items-center gap-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
i === activeLocation
? "bg-gold/10 text-gold border border-gold/30"
: "border border-white/10 text-neutral-400 hover:text-white"
}`}
>
<button
type="button"
onClick={() => setActiveLocation(i)}
className="text-left"
>
{loc.name}
</button>
{data.locations.length > 1 && (
<button
type="button"
onClick={() => deleteLocation(i)}
className="ml-1 rounded p-0.5 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить локацию"
>
<X size={12} />
</button>
)}
</div>
))}
<button
type="button"
onClick={() =>
update({
...data,
locations: [
...data.locations,
{ name: "Новая локация", address: "", days: [] },
],
})
}
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
>
<Plus size={14} className="inline" /> Локация
</button>
</div>
{location && (
<CalendarGrid
location={location}
trainers={trainers}
onChange={updateLocation}
/>
)}
</>
);
}}
</SectionEditor>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload } from "lucide-react";
import { InputField, TextareaField } from "../../_components/FormField";
interface MemberForm {
name: string;
role: string;
image: string;
instagram: string;
description: string;
}
export default function TeamMemberEditorPage() {
const router = useRouter();
const { id } = useParams<{ id: string }>();
const isNew = id === "new";
const [data, setData] = useState<MemberForm>({
name: "",
role: "",
image: "/images/team/placeholder.webp",
instagram: "",
description: "",
});
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (isNew) return;
fetch(`/api/admin/team/${id}`)
.then((r) => r.json())
.then((member) =>
setData({
name: member.name,
role: member.role,
image: member.image,
instagram: member.instagram || "",
description: member.description || "",
})
)
.finally(() => setLoading(false));
}, [id, isNew]);
async function handleSave() {
setSaving(true);
setSaved(false);
if (isNew) {
const res = await fetch("/api/admin/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
router.push("/admin/team");
}
} else {
const res = await fetch(`/api/admin/team/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
}
setSaving(false);
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
try {
const res = await fetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) {
setData((prev) => ({ ...prev, image: result.path }));
}
} catch {
// Upload failed silently
} finally {
setUploading(false);
}
}
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">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/admin/team")}
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-2xl font-bold">
{isNew ? "Новый участник" : data.name}
</h1>
</div>
<button
onClick={handleSave}
disabled={saving || !data.name || !data.role}
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : saved ? (
<Check size={16} />
) : (
<Save size={16} />
)}
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
</button>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
{/* Photo */}
<div>
<p className="text-sm text-neutral-400 mb-2">Фото</p>
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-white/10">
<Image
src={data.image}
alt={data.name || "Фото"}
fill
className="object-cover"
sizes="240px"
/>
</div>
<label className="mt-3 flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить фото"}
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
{/* Fields */}
<div className="space-y-4">
<InputField
label="Имя"
value={data.name}
onChange={(v) => setData({ ...data, name: v })}
/>
<InputField
label="Роль / Специализация"
value={data.role}
onChange={(v) => setData({ ...data, role: v })}
/>
<InputField
label="Instagram"
value={data.instagram}
onChange={(v) => setData({ ...data, instagram: v })}
type="url"
placeholder="https://instagram.com/..."
/>
<TextareaField
label="Описание"
value={data.description}
onChange={(v) => setData({ ...data, description: v })}
rows={6}
/>
</div>
</div>
</div>
);
}

151
src/app/admin/team/page.tsx Normal file
View File

@@ -0,0 +1,151 @@
"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>
);
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
import { revalidatePath } from "next/cache";
type Params = { params: Promise<{ key: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
const { key } = await params;
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
}
const data = getSection(key);
if (!data) {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
return NextResponse.json(data);
}
export async function PUT(request: NextRequest, { params }: Params) {
const { key } = await params;
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
}
const data = await request.json();
setSection(key, data);
revalidatePath("/");
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
import { revalidatePath } from "next/cache";
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
const { id } = await params;
const member = getTeamMember(Number(id));
if (!member) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(member);
}
export async function PUT(request: NextRequest, { params }: Params) {
const { id } = await params;
const data = await request.json();
updateTeamMember(Number(id), data);
revalidatePath("/");
return NextResponse.json({ ok: true });
}
export async function DELETE(_request: NextRequest, { params }: Params) {
const { id } = await params;
deleteTeamMember(Number(id));
revalidatePath("/");
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { reorderTeamMembers } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function PUT(request: NextRequest) {
const { ids } = await request.json() as { ids: number[] };
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "ids array required" }, { status: 400 });
}
reorderTeamMembers(ids);
revalidatePath("/");
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getTeamMembers, createTeamMember } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function GET() {
const members = getTeamMembers();
return NextResponse.json(members);
}
export async function POST(request: NextRequest) {
const data = await request.json() as {
name: string;
role: string;
image: string;
instagram?: string;
description?: string;
};
if (!data.name || !data.role || !data.image) {
return NextResponse.json(
{ error: "name, role, and image are required" },
{ status: 400 }
);
}
const id = createTeamMember(data);
revalidatePath("/");
return NextResponse.json({ id }, { status: 201 });
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const folder = (formData.get("folder") as string) || "team";
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Only JPEG, PNG, WebP, and AVIF are allowed" },
{ status: 400 }
);
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "File too large (max 5MB)" },
{ status: 400 }
);
}
// Sanitize filename
const ext = path.extname(file.name) || ".webp";
const baseName = file.name
.replace(ext, "")
.toLowerCase()
.replace(/[^a-z0-9а-яё-]/gi, "-")
.replace(/-+/g, "-")
.slice(0, 50);
const fileName = `${baseName}-${Date.now()}${ext}`;
const dir = path.join(process.cwd(), "public", "images", folder);
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dir, fileName);
await writeFile(filePath, buffer);
const publicPath = `/images/${folder}/${fileName}`;
return NextResponse.json({ path: publicPath });
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyPassword, signToken, COOKIE_NAME } from "@/lib/auth";
export async function POST(request: NextRequest) {
const body = await request.json() as { password?: string };
if (!body.password || !verifyPassword(body.password)) {
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
}
const token = signToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
return response;
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { COOKIE_NAME } from "@/lib/auth";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(COOKIE_NAME, "", {
httpOnly: true,
path: "/",
maxAge: 0,
});
return response;
}

View File

@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import { Inter, Oswald } from "next/font/google";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { siteContent } from "@/data/content";
import { getContent } from "@/lib/content";
import "./globals.css";
const inter = Inter({
@@ -15,16 +13,19 @@ const oswald = Oswald({
subsets: ["latin", "cyrillic"],
});
export const metadata: Metadata = {
title: siteContent.meta.title,
description: siteContent.meta.description,
openGraph: {
title: siteContent.meta.title,
description: siteContent.meta.description,
locale: "ru_RU",
type: "website",
},
};
export function generateMetadata(): Metadata {
const { meta } = getContent();
return {
title: meta.title,
description: meta.description,
openGraph: {
title: meta.title,
description: meta.description,
locale: "ru_RU",
type: "website",
},
};
}
export default function RootLayout({
children,
@@ -36,9 +37,7 @@ export default function RootLayout({
<body
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
>
<Header />
<main>{children}</main>
<Footer />
{children}
</body>
</html>
);

View File

@@ -1,13 +1,21 @@
import { Button } from "@/components/ui/Button";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
<h1 className="font-display text-6xl font-bold">404</h1>
<p className="body-text mt-4 text-lg">Страница не найдена</p>
<div className="mt-8">
<Button href="/">На главную</Button>
</div>
</div>
<>
<Header />
<main>
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
<h1 className="font-display text-6xl font-bold">404</h1>
<p className="body-text mt-4 text-lg">Страница не найдена</p>
<div className="mt-8">
<Button href="/">На главную</Button>
</div>
</div>
</main>
<Footer />
</>
);
}

View File

@@ -7,19 +7,35 @@ import { Pricing } from "@/components/sections/Pricing";
import { FAQ } from "@/components/sections/FAQ";
import { Contact } from "@/components/sections/Contact";
import { BackToTop } from "@/components/ui/BackToTop";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content";
export default function HomePage() {
const content = getContent();
return (
<>
<Hero />
<About />
<Team />
<Classes />
<Schedule />
<Pricing />
<FAQ />
<Contact />
<BackToTop />
<Header />
<main>
<Hero data={content.hero} />
<About
data={content.about}
stats={{
trainers: content.team.members.length,
classes: content.classes.items.length,
locations: content.schedule.locations.length,
}}
/>
<Team data={content.team} />
<Classes data={content.classes} />
<Schedule data={content.schedule} />
<Pricing data={content.pricing} />
<FAQ data={content.faq} />
<Contact data={content.contact} />
<BackToTop />
</main>
<Footer />
</>
);
}

View File

@@ -1,16 +1,25 @@
import { Users, Layers, MapPin } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import type { SiteContent } from "@/types/content";
const stats = [
{ icon: <Users size={22} />, value: "16", label: "тренеров" },
{ icon: <Layers size={22} />, value: "6", label: "направлений" },
{ icon: <MapPin size={22} />, value: "2", label: "зала в Минске" },
];
interface AboutStats {
trainers: number;
classes: number;
locations: number;
}
export function About() {
const { about } = siteContent;
interface AboutProps {
data: SiteContent["about"];
stats: AboutStats;
}
export function About({ data: about, stats }: AboutProps) {
const statItems = [
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров" },
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений" },
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске" },
];
return (
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
@@ -33,7 +42,7 @@ export function About() {
{/* Stats */}
<Reveal>
<div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8">
{stats.map((stat, i) => (
{statItems.map((stat, i) => (
<div
key={i}
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"

View File

@@ -2,12 +2,11 @@
import Image from "next/image";
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
import type { ClassItem } from "@/types";
import type { ClassItem, SiteContent } from "@/types";
import { UI_CONFIG } from "@/lib/config";
const iconMap: Record<string, React.ReactNode> = {
@@ -19,8 +18,11 @@ const iconMap: Record<string, React.ReactNode> = {
monitor: <Monitor size={20} />,
};
export function Classes() {
const { classes } = siteContent;
interface ClassesProps {
data: SiteContent["classes"];
}
export function Classes({ data: classes }: ClassesProps) {
const { activeIndex, select, setHovering } = useShowcaseRotation({
totalItems: classes.items.length,
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,

View File

@@ -1,12 +1,15 @@
import { MapPin, Phone, Clock, Instagram } from "lucide-react";
import { siteContent } from "@/data/content";
import { BRAND } from "@/lib/constants";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { IconBadge } from "@/components/ui/IconBadge";
import type { ContactInfo } from "@/types/content";
export function Contact() {
const { contact } = siteContent;
interface ContactProps {
data: ContactInfo;
}
export function Contact({ data: contact }: ContactProps) {
return (
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">

View File

@@ -2,16 +2,18 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { UI_CONFIG } from "@/lib/config";
import type { SiteContent } from "@/types/content";
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
export function FAQ() {
const { faq } = siteContent;
interface FAQProps {
data: SiteContent["faq"];
}
export function FAQ({ data: faq }: FAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false);

View File

@@ -1,13 +1,16 @@
"use client";
import { siteContent } from "@/data/content";
import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { HeroLogo } from "@/components/ui/HeroLogo";
import { ChevronDown } from "lucide-react";
import type { SiteContent } from "@/types/content";
export function Hero() {
const { hero } = siteContent;
interface HeroProps {
data: SiteContent["hero"];
}
export function Hero({ data: hero }: HeroProps) {
return (
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">

View File

@@ -2,15 +2,18 @@
import { useState } from "react";
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { BookingModal } from "@/components/ui/BookingModal";
import type { SiteContent } from "@/types/content";
type Tab = "prices" | "rental" | "rules";
export function Pricing() {
const { pricing } = siteContent;
interface PricingProps {
data: SiteContent["pricing"];
}
export function Pricing({ data: pricing }: PricingProps) {
const [activeTab, setActiveTab] = useState<Tab>("prices");
const [bookingOpen, setBookingOpen] = useState(false);

View File

@@ -2,16 +2,19 @@
import { useState, useMemo } from "react";
import { MapPin } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { DayCard } from "./schedule/DayCard";
import { ScheduleFilters } from "./schedule/ScheduleFilters";
import { MobileSchedule } from "./schedule/MobileSchedule";
import type { StatusFilter } from "./schedule/constants";
import type { SiteContent } from "@/types/content";
export function Schedule() {
const { schedule } = siteContent;
interface ScheduleProps {
data: SiteContent["schedule"];
}
export function Schedule({ data: schedule }: ScheduleProps) {
const [locationIndex, setLocationIndex] = useState(0);
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
const [filterType, setFilterType] = useState<string | null>(null);

View File

@@ -1,14 +1,17 @@
"use client";
import { useState } from "react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
import type { SiteContent } from "@/types/content";
export function Team() {
const { team } = siteContent;
interface TeamProps {
data: SiteContent["team"];
}
export function Team({ data: team }: TeamProps) {
const [activeIndex, setActiveIndex] = useState(0);
return (

96
src/data/seed.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Seed script — populates the SQLite database from content.ts
* Run: npx tsx src/data/seed.ts
*/
import Database from "better-sqlite3";
import path from "path";
import { siteContent } from "./content";
const DB_PATH =
process.env.DATABASE_PATH ||
path.join(process.cwd(), "db", "blackheart.db");
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS sections (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
role TEXT NOT NULL,
image TEXT NOT NULL,
instagram TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
// Seed sections (team members go in their own table)
const sectionData: Record<string, unknown> = {
meta: siteContent.meta,
hero: siteContent.hero,
about: siteContent.about,
classes: siteContent.classes,
faq: siteContent.faq,
pricing: siteContent.pricing,
schedule: siteContent.schedule,
contact: siteContent.contact,
};
// Team section stores only the title
sectionData.team = { title: siteContent.team.title };
const upsertSection = db.prepare(
`INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
);
const insertMember = db.prepare(
`INSERT INTO team_members (name, role, image, instagram, description, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`
);
const tx = db.transaction(() => {
// Upsert all sections
for (const [key, data] of Object.entries(sectionData)) {
upsertSection.run(key, JSON.stringify(data));
}
// Clear existing team members and re-insert
db.prepare("DELETE FROM team_members").run();
siteContent.team.members.forEach((m, i) => {
insertMember.run(
m.name,
m.role,
m.image,
m.instagram ?? null,
m.description ?? null,
i
);
});
});
tx();
const sectionCount = (
db.prepare("SELECT COUNT(*) as c FROM sections").get() as { c: number }
).c;
const memberCount = (
db.prepare("SELECT COUNT(*) as c FROM team_members").get() as { c: number }
).c;
console.log(`Seeded ${sectionCount} sections and ${memberCount} team members.`);
console.log(`Database: ${DB_PATH}`);
db.close();

53
src/lib/auth-edge.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Edge-compatible auth helpers (for middleware).
* Uses Web Crypto API instead of Node.js crypto.
*/
const COOKIE_NAME = "bh-admin-token";
function getSecret(): string {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET is not set");
return secret;
}
function base64urlEncode(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function hmacSign(data: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
return base64urlEncode(sig);
}
export async function verifyToken(token: string): Promise<boolean> {
try {
const [data, sig] = token.split(".");
if (!data || !sig) return false;
const expectedSig = await hmacSign(data, getSecret());
if (sig !== expectedSig) return false;
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
role: string;
exp: number;
};
return payload.role === "admin" && payload.exp > Date.now();
} catch {
return false;
}
}
export { COOKIE_NAME };

66
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,66 @@
import { cookies } from "next/headers";
import crypto from "crypto";
const COOKIE_NAME = "bh-admin-token";
const TOKEN_TTL = 24 * 60 * 60 * 1000; // 24 hours
function getSecret(): string {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET is not set");
return secret;
}
function getAdminPassword(): string {
const pw = process.env.ADMIN_PASSWORD;
if (!pw) throw new Error("ADMIN_PASSWORD is not set");
return pw;
}
export function verifyPassword(password: string): boolean {
return password === getAdminPassword();
}
export function signToken(): string {
const payload = {
role: "admin",
exp: Date.now() + TOKEN_TTL,
};
const data = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = crypto
.createHmac("sha256", getSecret())
.update(data)
.digest("base64url");
return `${data}.${sig}`;
}
export async function isAuthenticated(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return false;
return verifyTokenNode(token);
}
/** Node.js runtime token verification (for API routes / server components) */
function verifyTokenNode(token: string): boolean {
try {
const [data, sig] = token.split(".");
if (!data || !sig) return false;
const expectedSig = crypto
.createHmac("sha256", getSecret())
.update(data)
.digest("base64url");
if (sig !== expectedSig) return false;
const payload = JSON.parse(
Buffer.from(data, "base64url").toString()
) as { role: string; exp: number };
return payload.role === "admin" && payload.exp > Date.now();
} catch {
return false;
}
}
export { COOKIE_NAME };

13
src/lib/content.ts Normal file
View File

@@ -0,0 +1,13 @@
import { getSiteContent } from "@/lib/db";
import { siteContent as fallback } from "@/data/content";
import type { SiteContent } from "@/types/content";
export function getContent(): SiteContent {
try {
const content = getSiteContent();
if (content) return content;
return fallback;
} catch {
return fallback;
}
}

223
src/lib/db.ts Normal file
View File

@@ -0,0 +1,223 @@
import Database from "better-sqlite3";
import path from "path";
import type { SiteContent, TeamMember } from "@/types/content";
const DB_PATH =
process.env.DATABASE_PATH ||
path.join(process.cwd(), "db", "blackheart.db");
let _db: Database.Database | null = null;
function getDb(): Database.Database {
if (!_db) {
_db = new Database(DB_PATH);
_db.pragma("journal_mode = WAL");
_db.pragma("foreign_keys = ON");
initTables(_db);
}
return _db;
}
function initTables(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS sections (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
role TEXT NOT NULL,
image TEXT NOT NULL,
instagram TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
}
// --- Sections ---
export function getSection<T = unknown>(key: string): T | null {
const db = getDb();
const row = db.prepare("SELECT data FROM sections WHERE key = ?").get(key) as
| { data: string }
| undefined;
return row ? (JSON.parse(row.data) as T) : null;
}
export function setSection(key: string, data: unknown): void {
const db = getDb();
db.prepare(
`INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
).run(key, JSON.stringify(data));
}
// --- Team Members ---
interface TeamMemberRow {
id: number;
name: string;
role: string;
image: string;
instagram: string | null;
description: string | null;
sort_order: number;
}
export function getTeamMembers(): (TeamMember & { id: number })[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM team_members ORDER BY sort_order ASC, id ASC")
.all() as TeamMemberRow[];
return rows.map((r) => ({
id: r.id,
name: r.name,
role: r.role,
image: r.image,
instagram: r.instagram ?? undefined,
description: r.description ?? undefined,
}));
}
export function getTeamMember(
id: number
): (TeamMember & { id: number }) | null {
const db = getDb();
const r = db
.prepare("SELECT * FROM team_members WHERE id = ?")
.get(id) as TeamMemberRow | undefined;
if (!r) return null;
return {
id: r.id,
name: r.name,
role: r.role,
image: r.image,
instagram: r.instagram ?? undefined,
description: r.description ?? undefined,
};
}
export function createTeamMember(
data: Omit<TeamMember, "id">
): number {
const db = getDb();
const maxOrder = db
.prepare("SELECT COALESCE(MAX(sort_order), -1) as max FROM team_members")
.get() as { max: number };
const result = db
.prepare(
`INSERT INTO team_members (name, role, image, instagram, description, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
data.name,
data.role,
data.image,
data.instagram ?? null,
data.description ?? null,
maxOrder.max + 1
);
return result.lastInsertRowid as number;
}
export function updateTeamMember(
id: number,
data: Partial<Omit<TeamMember, "id">>
): void {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { fields.push("name = ?"); values.push(data.name); }
if (data.role !== undefined) { fields.push("role = ?"); values.push(data.role); }
if (data.image !== undefined) { fields.push("image = ?"); values.push(data.image); }
if (data.instagram !== undefined) { fields.push("instagram = ?"); values.push(data.instagram || null); }
if (data.description !== undefined) { fields.push("description = ?"); values.push(data.description || null); }
if (fields.length === 0) return;
fields.push("updated_at = datetime('now')");
values.push(id);
db.prepare(`UPDATE team_members SET ${fields.join(", ")} WHERE id = ?`).run(
...values
);
}
export function deleteTeamMember(id: number): void {
const db = getDb();
db.prepare("DELETE FROM team_members WHERE id = ?").run(id);
}
export function reorderTeamMembers(ids: number[]): void {
const db = getDb();
const stmt = db.prepare(
"UPDATE team_members SET sort_order = ? WHERE id = ?"
);
const tx = db.transaction(() => {
ids.forEach((id, index) => stmt.run(index, id));
});
tx();
}
// --- Full site content ---
const SECTION_KEYS = [
"meta",
"hero",
"about",
"classes",
"faq",
"pricing",
"schedule",
"contact",
] as const;
export function getSiteContent(): SiteContent | null {
const db = getDb();
const rows = db.prepare("SELECT key, data FROM sections").all() as {
key: string;
data: string;
}[];
if (rows.length === 0) return null;
const sections: Record<string, unknown> = {};
for (const row of rows) {
sections[row.key] = JSON.parse(row.data);
}
// Merge team members from dedicated table
const members = getTeamMembers();
const teamSection = (sections.team as { title?: string }) || {};
return {
meta: sections.meta,
hero: sections.hero,
about: sections.about,
classes: sections.classes,
faq: sections.faq,
pricing: sections.pricing,
schedule: sections.schedule,
contact: sections.contact,
team: {
title: teamSection.title || "",
members: members.map(({ id, ...rest }) => rest),
},
} as SiteContent;
}
export function isDatabaseSeeded(): boolean {
const db = getDb();
const row = db
.prepare("SELECT COUNT(*) as count FROM sections")
.get() as { count: number };
return row.count > 0;
}
export { SECTION_KEYS };

28
src/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow login page and login API
if (pathname === "/admin/login" || pathname === "/api/auth/login") {
return NextResponse.next();
}
// Protect /admin/* and /api/admin/*
const token = request.cookies.get(COOKIE_NAME)?.value;
const valid = token ? await verifyToken(token) : false;
if (!valid) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.redirect(new URL("/admin/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/api/admin/:path*"],
};