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:
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 };
|
||||
Reference in New Issue
Block a user