feat: initial commit — site + admin panel + Postgres content pipeline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// server/db.mjs — Postgres pool + schema bootstrap + file seed
|
||||
import pg from "pg";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const DATA_DIR = path.resolve(__dirname, "../src/data");
|
||||
|
||||
export const pool = new pg.Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 5,
|
||||
connectionTimeoutMillis: 10000,
|
||||
});
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export function isValidSlug(s) {
|
||||
return typeof s === "string" && SLUG_RE.test(s);
|
||||
}
|
||||
|
||||
// Read all src/data/<page>/<locale>.json files
|
||||
export function readDataFiles() {
|
||||
const out = [];
|
||||
for (const page of fs.readdirSync(DATA_DIR)) {
|
||||
const dir = path.join(DATA_DIR, page);
|
||||
if (!fs.statSync(dir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const locale = file.replace(/\.json$/, "");
|
||||
if (!isValidSlug(page) || !isValidSlug(locale)) continue;
|
||||
const raw = fs.readFileSync(path.join(dir, file), "utf-8");
|
||||
out.push({ page, locale, content: JSON.parse(raw) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function ensureSchema() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS page_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page TEXT NOT NULL,
|
||||
locale TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (page, locale)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Seed DB from JSON files. overwrite=false keeps existing DB rows intact.
|
||||
export async function seedFromFiles({ overwrite = false } = {}) {
|
||||
const files = readDataFiles();
|
||||
let inserted = 0;
|
||||
for (const { page, locale, content } of files) {
|
||||
const res = await pool.query(
|
||||
overwrite
|
||||
? `INSERT INTO page_data (page, locale, content, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (page, locale)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = now()`
|
||||
: `INSERT INTO page_data (page, locale, content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (page, locale) DO NOTHING`,
|
||||
[page, locale, JSON.stringify(content)],
|
||||
);
|
||||
inserted += res.rowCount;
|
||||
}
|
||||
return { total: files.length, written: inserted };
|
||||
}
|
||||
|
||||
// Write DB rows back to src/data/<page>/<locale>.json
|
||||
// Formatted with Prettier so exports match the repo's existing JSON style.
|
||||
export async function exportToFiles() {
|
||||
const prettier = await import("prettier");
|
||||
const { rows } = await pool.query(
|
||||
"SELECT page, locale, content FROM page_data ORDER BY page, locale",
|
||||
);
|
||||
const written = [];
|
||||
for (const { page, locale, content } of rows) {
|
||||
if (!isValidSlug(page) || !isValidSlug(locale)) continue;
|
||||
const dir = path.join(DATA_DIR, page);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const formatted = await prettier.format(JSON.stringify(content), {
|
||||
parser: "json",
|
||||
});
|
||||
fs.writeFileSync(path.join(dir, `${locale}.json`), formatted, "utf-8");
|
||||
written.push(`${page}/${locale}.json`);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// server/index.mjs — Admin API for src/data content stored in Postgres
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.resolve(__dirname, "../.env");
|
||||
if (fs.existsSync(envPath)) process.loadEnvFile(envPath);
|
||||
|
||||
const { default: express } = await import("express");
|
||||
const { pool, ensureSchema, seedFromFiles, exportToFiles, isValidSlug } =
|
||||
await import("./db.mjs");
|
||||
const { startPublish, getPublishState, DIST_DIR } = await import(
|
||||
"./publish.mjs"
|
||||
);
|
||||
|
||||
const PORT = Number(process.env.ADMIN_PORT || 3001);
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error("[admin] DATABASE_URL missing in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!ADMIN_PASSWORD) {
|
||||
console.error("[admin] ADMIN_PASSWORD missing in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// In-memory session tokens (dev-grade auth; fine for local admin use)
|
||||
const sessions = new Set();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const token = (req.headers.authorization || "").replace(/^Bearer\s+/i, "");
|
||||
if (!token || !sessions.has(token)) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
app.post("/api/admin/login", (req, res) => {
|
||||
const password = String(req.body?.password ?? "");
|
||||
const ok =
|
||||
password.length === ADMIN_PASSWORD.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(password), Buffer.from(ADMIN_PASSWORD));
|
||||
if (!ok) return res.status(401).json({ error: "Invalid password" });
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
sessions.add(token);
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
app.post("/api/admin/logout", requireAuth, (req, res) => {
|
||||
const token = (req.headers.authorization || "").replace(/^Bearer\s+/i, "");
|
||||
sessions.delete(token);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// List datasets
|
||||
app.get("/api/admin/datasets", requireAuth, async (_req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT page, locale, updated_at,
|
||||
pg_column_size(content) AS bytes,
|
||||
jsonb_typeof(content) AS type
|
||||
FROM page_data ORDER BY page, locale`,
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// Get one dataset
|
||||
app.get("/api/admin/datasets/:page/:locale", requireAuth, async (req, res) => {
|
||||
const { page, locale } = req.params;
|
||||
if (!isValidSlug(page) || !isValidSlug(locale)) {
|
||||
return res.status(400).json({ error: "Invalid page/locale" });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
"SELECT page, locale, content, updated_at FROM page_data WHERE page=$1 AND locale=$2",
|
||||
[page, locale],
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Not found" });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// Create/update a dataset
|
||||
app.put("/api/admin/datasets/:page/:locale", requireAuth, async (req, res) => {
|
||||
const { page, locale } = req.params;
|
||||
if (!isValidSlug(page) || !isValidSlug(locale)) {
|
||||
return res.status(400).json({ error: "Invalid page/locale" });
|
||||
}
|
||||
const content = req.body?.content;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Missing content" });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO page_data (page, locale, content, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (page, locale)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = now()
|
||||
RETURNING page, locale, updated_at`,
|
||||
[page, locale, JSON.stringify(content)],
|
||||
);
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// Delete a dataset
|
||||
app.delete(
|
||||
"/api/admin/datasets/:page/:locale",
|
||||
requireAuth,
|
||||
async (req, res) => {
|
||||
const { page, locale } = req.params;
|
||||
if (!isValidSlug(page) || !isValidSlug(locale)) {
|
||||
return res.status(400).json({ error: "Invalid page/locale" });
|
||||
}
|
||||
const r = await pool.query(
|
||||
"DELETE FROM page_data WHERE page=$1 AND locale=$2",
|
||||
[page, locale],
|
||||
);
|
||||
res.json({ deleted: r.rowCount });
|
||||
},
|
||||
);
|
||||
|
||||
// DB -> src/data JSON files (so vite dev / SSG build picks up changes)
|
||||
app.post("/api/admin/export", requireAuth, async (_req, res) => {
|
||||
const written = await exportToFiles();
|
||||
res.json({ written });
|
||||
});
|
||||
|
||||
// src/data JSON files -> DB (overwrites DB rows)
|
||||
app.post("/api/admin/import", requireAuth, async (_req, res) => {
|
||||
const result = await seedFromFiles({ overwrite: true });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Publish: DB -> JSON -> vite-ssg build -> dist swap (runs in background)
|
||||
app.post("/api/admin/publish", requireAuth, (_req, res) => {
|
||||
const started = startPublish();
|
||||
if (!started) return res.status(409).json({ error: "Publish already running" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/admin/publish/status", requireAuth, (_req, res) => {
|
||||
res.json(getPublishState());
|
||||
});
|
||||
|
||||
// Serve the built static site (production). API routes above take precedence.
|
||||
app.use(
|
||||
express.static(DIST_DIR, {
|
||||
setHeaders(res, filePath) {
|
||||
// Hashed assets can be cached hard; HTML must always revalidate
|
||||
if (/\.(js|css|woff2?|ttf|webp|jpg|jpeg|png|svg|gif|ico)$/.test(filePath)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// SPA fallback for client-only routes (/admin) and unknown paths
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== "GET" || req.path.startsWith("/api")) return next();
|
||||
const index = path.join(DIST_DIR, "index.html");
|
||||
if (!fs.existsSync(index)) return next();
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.sendFile(index);
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error("[admin]", err);
|
||||
res.status(500).json({ error: err.message || "Server error" });
|
||||
});
|
||||
|
||||
await ensureSchema();
|
||||
const seeded = await seedFromFiles({ overwrite: false });
|
||||
console.log(
|
||||
`[admin] schema ready, seed: ${seeded.written}/${seeded.total} new rows`,
|
||||
);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[admin] API listening on http://localhost:${PORT}`);
|
||||
// In production the image ships without content-fresh dist; rebuild from DB on boot
|
||||
if (process.env.PUBLISH_ON_BOOT === "true") {
|
||||
console.log("[admin] PUBLISH_ON_BOOT=true — publishing site from DB...");
|
||||
startPublish();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// server/publish.mjs — "Yayınla" pipeline: DB -> JSON files -> vite-ssg build -> dist swap
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { exportToFiles } from "./db.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
export const DIST_DIR = path.join(ROOT, "dist");
|
||||
const STAGING_DIR = path.join(ROOT, ".dist-staging");
|
||||
const LOG_LIMIT = 60_000;
|
||||
|
||||
const job = {
|
||||
running: false,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
ok: null,
|
||||
log: "",
|
||||
};
|
||||
|
||||
function append(text) {
|
||||
job.log = (job.log + text).slice(-LOG_LIMIT);
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, ...opts.env },
|
||||
shell: false,
|
||||
});
|
||||
child.stdout.on("data", (d) => append(d.toString()));
|
||||
child.stderr.on("data", (d) => append(d.toString()));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error(`${cmd} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublishState() {
|
||||
return { ...job };
|
||||
}
|
||||
|
||||
// Kick off the publish pipeline in the background. Returns false if already running.
|
||||
export function startPublish() {
|
||||
if (job.running) return false;
|
||||
job.running = true;
|
||||
job.ok = null;
|
||||
job.log = "";
|
||||
job.startedAt = new Date().toISOString();
|
||||
job.finishedAt = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
append("[1/3] DB → JSON dosyaları aktarılıyor...\n");
|
||||
const written = await exportToFiles();
|
||||
append(` ${written.length} dosya yazıldı\n`);
|
||||
|
||||
append("[2/3] Site build ediliyor (vite-ssg)...\n");
|
||||
fs.rmSync(STAGING_DIR, { recursive: true, force: true });
|
||||
await run("npx", ["vite-ssg", "build"], {
|
||||
env: { BUILD_OUT_DIR: STAGING_DIR },
|
||||
});
|
||||
|
||||
append("[3/3] dist güncelleniyor...\n");
|
||||
fs.rmSync(DIST_DIR, { recursive: true, force: true });
|
||||
fs.renameSync(STAGING_DIR, DIST_DIR);
|
||||
append("Tamamlandı ✓\n");
|
||||
job.ok = true;
|
||||
} catch (e) {
|
||||
append(`\nHATA: ${e.message}\n`);
|
||||
job.ok = false;
|
||||
} finally {
|
||||
job.running = false;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
})();
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user