feat: admin panel with SQLite, auth, and calendar-style schedule editor

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:59:12 +03:00
parent d5afaf92ba
commit 27c1348f89
44 changed files with 3709 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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