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

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 };