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

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

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

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

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

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

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

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

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