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

96
src/data/seed.ts Normal file
View 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();