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:
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user