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
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# database
|
||||||
|
/db/
|
||||||
|
|
||||||
# claude
|
# claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"seed": "tsx src/data/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"lucide-react": "^0.576.0",
|
"lucide-react": "^0.576.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"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 type { Metadata } from "next";
|
||||||
import { Inter, Oswald } from "next/font/google";
|
import { Inter, Oswald } from "next/font/google";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { getContent } from "@/lib/content";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -15,16 +13,19 @@ const oswald = Oswald({
|
|||||||
subsets: ["latin", "cyrillic"],
|
subsets: ["latin", "cyrillic"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export function generateMetadata(): Metadata {
|
||||||
title: siteContent.meta.title,
|
const { meta } = getContent();
|
||||||
description: siteContent.meta.description,
|
return {
|
||||||
openGraph: {
|
title: meta.title,
|
||||||
title: siteContent.meta.title,
|
description: meta.description,
|
||||||
description: siteContent.meta.description,
|
openGraph: {
|
||||||
locale: "ru_RU",
|
title: meta.title,
|
||||||
type: "website",
|
description: meta.description,
|
||||||
},
|
locale: "ru_RU",
|
||||||
};
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -36,9 +37,7 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
||||||
>
|
>
|
||||||
<Header />
|
{children}
|
||||||
<main>{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Header } from "@/components/layout/Header";
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
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>
|
<Header />
|
||||||
<p className="body-text mt-4 text-lg">Страница не найдена</p>
|
<main>
|
||||||
<div className="mt-8">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
|
||||||
<Button href="/">На главную</Button>
|
<h1 className="font-display text-6xl font-bold">404</h1>
|
||||||
</div>
|
<p className="body-text mt-4 text-lg">Страница не найдена</p>
|
||||||
</div>
|
<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 { FAQ } from "@/components/sections/FAQ";
|
||||||
import { Contact } from "@/components/sections/Contact";
|
import { Contact } from "@/components/sections/Contact";
|
||||||
import { BackToTop } from "@/components/ui/BackToTop";
|
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() {
|
export default function HomePage() {
|
||||||
|
const content = getContent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Header />
|
||||||
<About />
|
<main>
|
||||||
<Team />
|
<Hero data={content.hero} />
|
||||||
<Classes />
|
<About
|
||||||
<Schedule />
|
data={content.about}
|
||||||
<Pricing />
|
stats={{
|
||||||
<FAQ />
|
trainers: content.team.members.length,
|
||||||
<Contact />
|
classes: content.classes.items.length,
|
||||||
<BackToTop />
|
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 { Users, Layers, MapPin } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
const stats = [
|
interface AboutStats {
|
||||||
{ icon: <Users size={22} />, value: "16", label: "тренеров" },
|
trainers: number;
|
||||||
{ icon: <Layers size={22} />, value: "6", label: "направлений" },
|
classes: number;
|
||||||
{ icon: <MapPin size={22} />, value: "2", label: "зала в Минске" },
|
locations: number;
|
||||||
];
|
}
|
||||||
|
|
||||||
export function About() {
|
interface AboutProps {
|
||||||
const { about } = siteContent;
|
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 (
|
return (
|
||||||
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||||
@@ -33,7 +42,7 @@ export function About() {
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8">
|
<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
|
<div
|
||||||
key={i}
|
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"
|
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 Image from "next/image";
|
||||||
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
|
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||||
import type { ClassItem } from "@/types";
|
import type { ClassItem, SiteContent } from "@/types";
|
||||||
import { UI_CONFIG } from "@/lib/config";
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
@@ -19,8 +18,11 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
monitor: <Monitor size={20} />,
|
monitor: <Monitor size={20} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Classes() {
|
interface ClassesProps {
|
||||||
const { classes } = siteContent;
|
data: SiteContent["classes"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Classes({ data: classes }: ClassesProps) {
|
||||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||||
totalItems: classes.items.length,
|
totalItems: classes.items.length,
|
||||||
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { MapPin, Phone, Clock, Instagram } from "lucide-react";
|
import { MapPin, Phone, Clock, Instagram } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { BRAND } from "@/lib/constants";
|
import { BRAND } from "@/lib/constants";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { IconBadge } from "@/components/ui/IconBadge";
|
import { IconBadge } from "@/components/ui/IconBadge";
|
||||||
|
import type { ContactInfo } from "@/types/content";
|
||||||
|
|
||||||
export function Contact() {
|
interface ContactProps {
|
||||||
const { contact } = siteContent;
|
data: ContactInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Contact({ data: contact }: ContactProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
|
||||||
import { UI_CONFIG } from "@/lib/config";
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
|
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
|
||||||
|
|
||||||
export function FAQ() {
|
interface FAQProps {
|
||||||
const { faq } = siteContent;
|
data: SiteContent["faq"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQ({ data: faq }: FAQProps) {
|
||||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
export function Hero() {
|
interface HeroProps {
|
||||||
const { hero } = siteContent;
|
data: SiteContent["hero"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hero({ data: hero }: HeroProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { BookingModal } from "@/components/ui/BookingModal";
|
import { BookingModal } from "@/components/ui/BookingModal";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
type Tab = "prices" | "rental" | "rules";
|
type Tab = "prices" | "rental" | "rules";
|
||||||
|
|
||||||
export function Pricing() {
|
interface PricingProps {
|
||||||
const { pricing } = siteContent;
|
data: SiteContent["pricing"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pricing({ data: pricing }: PricingProps) {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||||
const [bookingOpen, setBookingOpen] = useState(false);
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { DayCard } from "./schedule/DayCard";
|
import { DayCard } from "./schedule/DayCard";
|
||||||
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||||||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||||
import type { StatusFilter } from "./schedule/constants";
|
import type { StatusFilter } from "./schedule/constants";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
export function Schedule() {
|
interface ScheduleProps {
|
||||||
const { schedule } = siteContent;
|
data: SiteContent["schedule"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Schedule({ data: schedule }: ScheduleProps) {
|
||||||
const [locationIndex, setLocationIndex] = useState(0);
|
const [locationIndex, setLocationIndex] = useState(0);
|
||||||
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||||
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
|
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
export function Team() {
|
interface TeamProps {
|
||||||
const { team } = siteContent;
|
data: SiteContent["team"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Team({ data: team }: TeamProps) {
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
return (
|
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