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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,6 +36,9 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# database
|
||||
/db/
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -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
892
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
94
src/app/admin/_components/ArrayEditor.tsx
Normal file
94
src/app/admin/_components/ArrayEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/app/admin/_components/FormField.tsx
Normal file
171
src/app/admin/_components/FormField.tsx
Normal 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:MM–HH: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>
|
||||
);
|
||||
}
|
||||
94
src/app/admin/_components/SectionEditor.tsx
Normal file
94
src/app/admin/_components/SectionEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/app/admin/about/page.tsx
Normal file
41
src/app/admin/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/app/admin/classes/page.tsx
Normal file
94
src/app/admin/classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/app/admin/contact/page.tsx
Normal file
55
src/app/admin/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/admin/faq/page.tsx
Normal file
48
src/app/admin/faq/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/app/admin/hero/page.tsx
Normal file
43
src/app/admin/hero/page.tsx
Normal 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
146
src/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/app/admin/login/page.tsx
Normal file
76
src/app/admin/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/app/admin/meta/page.tsx
Normal file
31
src/app/admin/meta/page.tsx
Normal 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
58
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/app/admin/pricing/page.tsx
Normal file
106
src/app/admin/pricing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
618
src/app/admin/schedule/page.tsx
Normal file
618
src/app/admin/schedule/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
src/app/admin/team/[id]/page.tsx
Normal file
199
src/app/admin/team/[id]/page.tsx
Normal 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
151
src/app/admin/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/api/admin/sections/[key]/route.ts
Normal file
32
src/app/api/admin/sections/[key]/route.ts
Normal 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 });
|
||||
}
|
||||
29
src/app/api/admin/team/[id]/route.ts
Normal file
29
src/app/api/admin/team/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
16
src/app/api/admin/team/reorder/route.ts
Normal file
16
src/app/api/admin/team/reorder/route.ts
Normal 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 });
|
||||
}
|
||||
30
src/app/api/admin/team/route.ts
Normal file
30
src/app/api/admin/team/route.ts
Normal 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 });
|
||||
}
|
||||
50
src/app/api/admin/upload/route.ts
Normal file
50
src/app/api/admin/upload/route.ts
Normal 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 });
|
||||
}
|
||||
23
src/app/api/auth/login/route.ts
Normal file
23
src/app/api/auth/login/route.ts
Normal 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;
|
||||
}
|
||||
12
src/app/api/logout/route.ts
Normal file
12
src/app/api/logout/route.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
96
src/data/seed.ts
Normal 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
53
src/lib/auth-edge.ts
Normal 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
66
src/lib/auth.ts
Normal 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
13
src/lib/content.ts
Normal 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
223
src/lib/db.ts
Normal 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
28
src/middleware.ts
Normal 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*"],
|
||||
};
|
||||
Reference in New Issue
Block a user