106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
import 'server-only';
|
|
import Database from 'better-sqlite3';
|
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
|
|
/**
|
|
* Persistent content store for the CMS.
|
|
*
|
|
* A single `sections` table holds JSON overrides keyed by section name
|
|
* (e.g. "hero", "services", "portfolio"). The stored JSON is a bilingual
|
|
* payload — `{ fa: <sectionObject>, en: <sectionObject> }` — that mirrors the
|
|
* shape of the matching key inside `dict`. At request time the content loader
|
|
* merges these overrides on top of the in-code `dict` defaults, so editing a
|
|
* section in the admin panel transparently rewrites what every public section
|
|
* renders without touching any component.
|
|
*
|
|
* The database file lives under DATA_DIR (default ./data) which on the
|
|
* self-hosted deployment is a mounted Docker volume, so content survives
|
|
* container rebuilds.
|
|
*/
|
|
|
|
export const DATA_DIR = resolve(process.env.DATA_DIR ?? join(process.cwd(), 'data'));
|
|
export const UPLOADS_DIR = join(DATA_DIR, 'uploads');
|
|
const DB_PATH = join(DATA_DIR, 'cms.db');
|
|
|
|
let _db: Database.Database | null = null;
|
|
|
|
function db(): Database.Database {
|
|
if (_db) return _db;
|
|
|
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
|
|
const handle = new Database(DB_PATH);
|
|
handle.pragma('journal_mode = WAL');
|
|
handle.exec(`
|
|
CREATE TABLE IF NOT EXISTS sections (
|
|
key TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
`);
|
|
|
|
_db = handle;
|
|
return handle;
|
|
}
|
|
|
|
export type SectionRow = {
|
|
key: string;
|
|
/** JSON-encoded `{ fa, en }` payload. */
|
|
data: string;
|
|
updated_at: number;
|
|
};
|
|
|
|
export type SectionOverride = {
|
|
key: string;
|
|
data: unknown;
|
|
updatedAt: number;
|
|
};
|
|
|
|
/** Every stored override, used by the content loader to merge onto defaults. */
|
|
export function getAllSections(): SectionRow[] {
|
|
try {
|
|
return db()
|
|
.prepare('SELECT key, data, updated_at FROM sections')
|
|
.all() as SectionRow[];
|
|
} catch {
|
|
// A missing/locked DB must never crash a public render — fall back to dict.
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** A single override, or null when the section has never been edited. */
|
|
export function getSection(key: string): SectionOverride | null {
|
|
const row = db()
|
|
.prepare('SELECT key, data, updated_at FROM sections WHERE key = ?')
|
|
.get(key) as SectionRow | undefined;
|
|
if (!row) return null;
|
|
return { key: row.key, data: JSON.parse(row.data), updatedAt: row.updated_at };
|
|
}
|
|
|
|
/** Insert or replace a section override (admin only). */
|
|
export function setSection(key: string, data: unknown): void {
|
|
db()
|
|
.prepare(
|
|
`INSERT INTO sections (key, data, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
|
)
|
|
.run(key, JSON.stringify(data), Date.now());
|
|
}
|
|
|
|
/** Drop an override so the section reverts to its in-code default. */
|
|
export function resetSection(key: string): void {
|
|
db().prepare('DELETE FROM sections WHERE key = ?').run(key);
|
|
}
|
|
|
|
/** Map of key → updatedAt for showing edit status in the dashboard. */
|
|
export function sectionStatus(): Record<string, number> {
|
|
const out: Record<string, number> = {};
|
|
for (const row of getAllSections()) out[row.key] = row.updated_at;
|
|
return out;
|
|
}
|
|
|
|
export { dirname };
|