first commit
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user