weifuwu 0.19.8 → 0.19.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/template/.weifuwu/ssr/96f5704e.js +481 -0
- package/cli/template/.weifuwu/ssr/fae6ecbe.js +14 -0
- package/cli/template/ui/components/Greeting.tsx +1 -1
- package/cli.ts +3 -3
- package/dist/cli.js +3 -3
- package/dist/cms/admin.d.ts +3 -0
- package/dist/cms/api.d.ts +3 -0
- package/dist/cms/client.d.ts +2 -0
- package/dist/cms/content.d.ts +36 -0
- package/dist/cms/index.d.ts +2 -0
- package/dist/cms/media.d.ts +17 -0
- package/dist/cms/types.d.ts +93 -0
- package/dist/compile.d.ts +2 -0
- package/dist/env.d.ts +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1271 -36
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// env.ts
|
|
2
2
|
import { readFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
function isDev() {
|
|
5
|
+
return process.env.NODE_ENV === "development";
|
|
6
|
+
}
|
|
4
7
|
function loadEnv(path2) {
|
|
5
8
|
const filePath = resolve(process.cwd(), path2 ?? ".env");
|
|
6
9
|
if (!existsSync(filePath)) return;
|
|
@@ -92,7 +95,7 @@ async function sendResponse(res, response) {
|
|
|
92
95
|
res.end();
|
|
93
96
|
}
|
|
94
97
|
async function createTestServer(handler) {
|
|
95
|
-
const server = serve(handler, { port: 0 });
|
|
98
|
+
const server = serve(handler, { port: 0, shutdown: false });
|
|
96
99
|
await server.ready;
|
|
97
100
|
return { server, url: `http://localhost:${server.port}` };
|
|
98
101
|
}
|
|
@@ -452,9 +455,9 @@ var Router = class _Router {
|
|
|
452
455
|
for (let i = 0; i < segments.length; i++) {
|
|
453
456
|
pathMws.push(...node.pathMws);
|
|
454
457
|
if (node.wildcard) {
|
|
455
|
-
const
|
|
456
|
-
if (
|
|
457
|
-
wildcardHandler =
|
|
458
|
+
const h2 = node.handlers.get("*") || node.handlers.get(method);
|
|
459
|
+
if (h2) {
|
|
460
|
+
wildcardHandler = h2;
|
|
458
461
|
wildcardMws = node.middlewares.get(method) || node.middlewares.get("*") || [];
|
|
459
462
|
wildcardIdx = i;
|
|
460
463
|
}
|
|
@@ -1260,11 +1263,11 @@ function helmet(options) {
|
|
|
1260
1263
|
}
|
|
1261
1264
|
return async (req, ctx, next) => {
|
|
1262
1265
|
const res = await next(req, ctx);
|
|
1263
|
-
const
|
|
1266
|
+
const h2 = new Headers(res.headers);
|
|
1264
1267
|
for (const [k, v] of headers) {
|
|
1265
|
-
if (!
|
|
1268
|
+
if (!h2.has(k)) h2.set(k, v);
|
|
1266
1269
|
}
|
|
1267
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers:
|
|
1270
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h2 });
|
|
1268
1271
|
};
|
|
1269
1272
|
}
|
|
1270
1273
|
|
|
@@ -1279,9 +1282,9 @@ function requestId(options) {
|
|
|
1279
1282
|
ctx.requestId = id3;
|
|
1280
1283
|
const res = await next(req, ctx);
|
|
1281
1284
|
if (res.headers.has(header)) return res;
|
|
1282
|
-
const
|
|
1283
|
-
|
|
1284
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers:
|
|
1285
|
+
const h2 = new Headers(res.headers);
|
|
1286
|
+
h2.set(header, id3);
|
|
1287
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h2 });
|
|
1285
1288
|
};
|
|
1286
1289
|
}
|
|
1287
1290
|
|
|
@@ -2845,7 +2848,7 @@ function createHub(opts) {
|
|
|
2845
2848
|
}
|
|
2846
2849
|
});
|
|
2847
2850
|
}
|
|
2848
|
-
function
|
|
2851
|
+
function join10(key, ws) {
|
|
2849
2852
|
if (!channels.has(key)) {
|
|
2850
2853
|
channels.set(key, /* @__PURE__ */ new Set());
|
|
2851
2854
|
redisSub?.subscribe(`${prefix}${key}`);
|
|
@@ -2886,7 +2889,7 @@ function createHub(opts) {
|
|
|
2886
2889
|
await redisSub.quit();
|
|
2887
2890
|
}
|
|
2888
2891
|
}
|
|
2889
|
-
return { join:
|
|
2892
|
+
return { join: join10, leave, broadcast, close };
|
|
2890
2893
|
}
|
|
2891
2894
|
|
|
2892
2895
|
// queue/index.ts
|
|
@@ -2905,10 +2908,10 @@ function cronNext(expr, from = /* @__PURE__ */ new Date()) {
|
|
|
2905
2908
|
for (let i = 0; i < 525600; i++) {
|
|
2906
2909
|
const m = candidate.getMonth() + 1;
|
|
2907
2910
|
const d = candidate.getDate();
|
|
2908
|
-
const
|
|
2911
|
+
const h2 = candidate.getHours();
|
|
2909
2912
|
const min = candidate.getMinutes();
|
|
2910
2913
|
const dw = candidate.getDay();
|
|
2911
|
-
if (fields[4].has(dw) && fields[3].has(m) && fields[2].has(d) && fields[1].has(
|
|
2914
|
+
if (fields[4].has(dw) && fields[3].has(m) && fields[2].has(d) && fields[1].has(h2) && fields[0].has(min)) {
|
|
2912
2915
|
return candidate.getTime();
|
|
2913
2916
|
}
|
|
2914
2917
|
candidate.setTime(candidate.getTime() + 6e4);
|
|
@@ -3033,7 +3036,7 @@ function queue(opts) {
|
|
|
3033
3036
|
running = true;
|
|
3034
3037
|
poll();
|
|
3035
3038
|
};
|
|
3036
|
-
mw.stop = function
|
|
3039
|
+
mw.stop = function stop2() {
|
|
3037
3040
|
running = false;
|
|
3038
3041
|
epoch++;
|
|
3039
3042
|
if (pollTimer) {
|
|
@@ -5139,7 +5142,7 @@ async function compileTsxDev(path2) {
|
|
|
5139
5142
|
return mod;
|
|
5140
5143
|
}
|
|
5141
5144
|
function compile(path2) {
|
|
5142
|
-
return
|
|
5145
|
+
return isDev() ? compileTsxDev(path2) : compileTsx(path2);
|
|
5143
5146
|
}
|
|
5144
5147
|
var vendorBundle = null;
|
|
5145
5148
|
async function compileVendorBundle() {
|
|
@@ -5177,7 +5180,7 @@ async function compileVendorBundle() {
|
|
|
5177
5180
|
}
|
|
5178
5181
|
async function compileHotComponent(path2) {
|
|
5179
5182
|
const absPath = resolve3(path2);
|
|
5180
|
-
const
|
|
5183
|
+
const h2 = id(absPath);
|
|
5181
5184
|
const stdin = `import C from ${JSON.stringify(absPath)};
|
|
5182
5185
|
(window.__WFW_REFRESH||function(){})(C)`;
|
|
5183
5186
|
const result = await esbuild.build({
|
|
@@ -5195,7 +5198,7 @@ async function compileHotComponent(path2) {
|
|
|
5195
5198
|
code = `import * as __r from 'react';
|
|
5196
5199
|
` + code.replace(/__require\(["']react["']\)/g, "__r");
|
|
5197
5200
|
}
|
|
5198
|
-
return { hash:
|
|
5201
|
+
return { hash: h2, code };
|
|
5199
5202
|
}
|
|
5200
5203
|
|
|
5201
5204
|
// stream.ts
|
|
@@ -5212,10 +5215,10 @@ function getPublicEnv() {
|
|
|
5212
5215
|
return _publicEnv;
|
|
5213
5216
|
}
|
|
5214
5217
|
function buildHeadPayload(opts) {
|
|
5215
|
-
const { ctx, compiledTailwindCss, isDev:
|
|
5218
|
+
const { ctx, compiledTailwindCss, isDev: isDev3 } = opts;
|
|
5216
5219
|
const rb = opts.rootBase || "";
|
|
5217
5220
|
let result = "";
|
|
5218
|
-
if (
|
|
5221
|
+
if (isDev3) {
|
|
5219
5222
|
const vUrl = `${rb}/__wfw/v/bundle`;
|
|
5220
5223
|
result += `<script type="importmap">{
|
|
5221
5224
|
"imports": {
|
|
@@ -5341,9 +5344,9 @@ function streamResponse(reactStream, opts) {
|
|
|
5341
5344
|
var ssrEntries = /* @__PURE__ */ new Map();
|
|
5342
5345
|
|
|
5343
5346
|
// ssr.ts
|
|
5347
|
+
var isDev2 = isDev();
|
|
5344
5348
|
var als = new AsyncLocalStorage();
|
|
5345
5349
|
__registerAls(() => als.getStore());
|
|
5346
|
-
var isDev = process.env.NODE_ENV !== "production";
|
|
5347
5350
|
var bundleCache = /* @__PURE__ */ new Map();
|
|
5348
5351
|
var _bundleDirty = false;
|
|
5349
5352
|
function markClientBundleDirty() {
|
|
@@ -5378,7 +5381,7 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5378
5381
|
const _sc = `(function(){var k='__WEIFUWU_CTX_STORE';var s=typeof globalThis!='undefined'&&globalThis[k];if(!s)return function(){};return function(v){s._ctx={...s._ctx,...v};s._snapshot={params:s._ctx.params,query:s._ctx.query,user:s._ctx.user,parsed:s._ctx.parsed,prefs:s._ctx.prefs,env:s._ctx.env};s._listeners.forEach(function(fn){fn()})}})()`;
|
|
5379
5382
|
const code = [
|
|
5380
5383
|
layoutImports,
|
|
5381
|
-
`${
|
|
5384
|
+
`${isDev2 ? "import{createRoot}from'react-dom/client';" : "import{hydrateRoot}from'react-dom/client';"}`,
|
|
5382
5385
|
`import{createElement}from'react';`,
|
|
5383
5386
|
`import{TsxContext}from'weifuwu/react';`,
|
|
5384
5387
|
`import P from${JSON.stringify(absEntry)};`,
|
|
@@ -5386,15 +5389,15 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5386
5389
|
`const c=document.getElementById('__weifuwu_root');`,
|
|
5387
5390
|
`if(window.__WEIFUWU_PROPS)setCtx({loaderData:window.__WEIFUWU_PROPS});`,
|
|
5388
5391
|
// Dev: stable proxy chain — _P → _W (stable) → actual component
|
|
5389
|
-
|
|
5392
|
+
isDev2 ? `const _W=function(props){return(_W._fn||P)(props)};_W._fn=P;const _P=function(props){return createElement(_W,props)};` : "",
|
|
5390
5393
|
// Dev: HMR handler — updates proxy + re-renders root
|
|
5391
|
-
|
|
5394
|
+
isDev2 ? `window.__WFW_ENTRY=${JSON.stringify(id2(absEntry))};window.__WFW_REFRESH=function(n){_W._fn=n;window.__WFW_ROOT.render(createElement(App))};` : "",
|
|
5392
5395
|
`function App(){`,
|
|
5393
5396
|
`const ctx=window.__WEIFUWU_CTX||{};`,
|
|
5394
5397
|
`return createElement(TsxContext.Provider,{value:ctx},`,
|
|
5395
|
-
|
|
5398
|
+
isDev2 ? `createElement(_P,null))` : `createElement(P,null))`,
|
|
5396
5399
|
`}`,
|
|
5397
|
-
|
|
5400
|
+
isDev2 ? `window.__WFW_ROOT=createRoot(c);window.__WFW_ROOT.render(createElement(App));` : `hydrateRoot(c,createElement(App));`
|
|
5398
5401
|
].filter(Boolean).join("");
|
|
5399
5402
|
const { default: esbuild2 } = await import("esbuild");
|
|
5400
5403
|
const result = await esbuild2.build({
|
|
@@ -5405,9 +5408,9 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5405
5408
|
jsxImportSource: "react",
|
|
5406
5409
|
banner: { js: "self.process={env:{}};" },
|
|
5407
5410
|
loader: { ".node": "empty" },
|
|
5408
|
-
external:
|
|
5411
|
+
external: isDev2 ? ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "weifuwu", "weifuwu/react"] : void 0,
|
|
5409
5412
|
write: false,
|
|
5410
|
-
minify: !
|
|
5413
|
+
minify: !isDev2
|
|
5411
5414
|
});
|
|
5412
5415
|
return result.outputFiles[0].contents;
|
|
5413
5416
|
} catch (err) {
|
|
@@ -5492,7 +5495,7 @@ function ssr(path2) {
|
|
|
5492
5495
|
ctx,
|
|
5493
5496
|
base,
|
|
5494
5497
|
rootBase: ctx.rootLayoutBase || "",
|
|
5495
|
-
isDev,
|
|
5498
|
+
isDev: isDev2,
|
|
5496
5499
|
bundle,
|
|
5497
5500
|
loaderData,
|
|
5498
5501
|
compiledTailwindCss: ctx.compiledTailwindCss
|
|
@@ -5702,7 +5705,6 @@ function liveReload(dir) {
|
|
|
5702
5705
|
function rootLayout(dir) {
|
|
5703
5706
|
const r = new Router();
|
|
5704
5707
|
const resolved = resolve7(dir);
|
|
5705
|
-
const isDev2 = process.env.NODE_ENV !== "production";
|
|
5706
5708
|
const layoutPath = join5(resolved, "layout.tsx");
|
|
5707
5709
|
r.use(async (req, ctx, next) => {
|
|
5708
5710
|
const mod = await compile(layoutPath);
|
|
@@ -5713,7 +5715,7 @@ function rootLayout(dir) {
|
|
|
5713
5715
|
if (existsSync5(join5(resolved, "app.css"))) {
|
|
5714
5716
|
r.use(tailwind(resolved));
|
|
5715
5717
|
}
|
|
5716
|
-
if (
|
|
5718
|
+
if (isDev()) {
|
|
5717
5719
|
const lr = liveReload(resolved);
|
|
5718
5720
|
r.use(lr);
|
|
5719
5721
|
r.close = lr.close;
|
|
@@ -7090,9 +7092,9 @@ function seoMiddleware(options) {
|
|
|
7090
7092
|
const url = new URL(req.url);
|
|
7091
7093
|
const robotTag = getRobotsHeader(headers, url.pathname);
|
|
7092
7094
|
if (robotTag) {
|
|
7093
|
-
const
|
|
7094
|
-
|
|
7095
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers:
|
|
7095
|
+
const h2 = new Headers(res.headers);
|
|
7096
|
+
h2.set("X-Robots-Tag", robotTag);
|
|
7097
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h2 });
|
|
7096
7098
|
}
|
|
7097
7099
|
return res;
|
|
7098
7100
|
};
|
|
@@ -8413,7 +8415,7 @@ function notFound(path2) {
|
|
|
8413
8415
|
return streamResponse(stream, {
|
|
8414
8416
|
ctx,
|
|
8415
8417
|
base,
|
|
8416
|
-
isDev:
|
|
8418
|
+
isDev: isDev(),
|
|
8417
8419
|
compiledTailwindCss: ctx.compiledTailwindCss,
|
|
8418
8420
|
status: 404
|
|
8419
8421
|
});
|
|
@@ -8460,13 +8462,1244 @@ function errorBoundary(errorPath) {
|
|
|
8460
8462
|
return streamResponse(stream, {
|
|
8461
8463
|
ctx,
|
|
8462
8464
|
base,
|
|
8463
|
-
isDev:
|
|
8465
|
+
isDev: isDev(),
|
|
8464
8466
|
compiledTailwindCss: ctx.compiledTailwindCss,
|
|
8465
8467
|
status: 500
|
|
8466
8468
|
});
|
|
8467
8469
|
}
|
|
8468
8470
|
};
|
|
8469
8471
|
}
|
|
8472
|
+
|
|
8473
|
+
// cms/content.ts
|
|
8474
|
+
async function createContentType(sql2, slug, label, fields, config) {
|
|
8475
|
+
const rows = await sql2`
|
|
8476
|
+
INSERT INTO "_cms_content_types" ("slug", "label", "fields", "config")
|
|
8477
|
+
VALUES (${slug}, ${label}, ${sql2.json(fields)}, ${sql2.json(config ?? {})})
|
|
8478
|
+
RETURNING *
|
|
8479
|
+
`;
|
|
8480
|
+
return mapContentType(rows[0]);
|
|
8481
|
+
}
|
|
8482
|
+
async function getContentType(sql2, slug) {
|
|
8483
|
+
const rows = await sql2`SELECT * FROM "_cms_content_types" WHERE "slug" = ${slug}`;
|
|
8484
|
+
return rows[0] ? mapContentType(rows[0]) : null;
|
|
8485
|
+
}
|
|
8486
|
+
async function listContentTypes(sql2) {
|
|
8487
|
+
const rows = await sql2`SELECT * FROM "_cms_content_types" ORDER BY "created_at" ASC`;
|
|
8488
|
+
return rows.map(mapContentType);
|
|
8489
|
+
}
|
|
8490
|
+
async function updateContentType(sql2, slug, data) {
|
|
8491
|
+
const sets = [];
|
|
8492
|
+
const vals = [];
|
|
8493
|
+
let idx = 1;
|
|
8494
|
+
if (data.label !== void 0) {
|
|
8495
|
+
sets.push(`"label" = $${idx++}`);
|
|
8496
|
+
vals.push(data.label);
|
|
8497
|
+
}
|
|
8498
|
+
if (data.description !== void 0) {
|
|
8499
|
+
sets.push(`"description" = $${idx++}`);
|
|
8500
|
+
vals.push(data.description);
|
|
8501
|
+
}
|
|
8502
|
+
if (data.fields !== void 0) {
|
|
8503
|
+
sets.push(`"fields" = $${idx++}`);
|
|
8504
|
+
vals.push(JSON.stringify(data.fields));
|
|
8505
|
+
}
|
|
8506
|
+
if (data.config !== void 0) {
|
|
8507
|
+
sets.push(`"config" = $${idx++}`);
|
|
8508
|
+
vals.push(JSON.stringify(data.config));
|
|
8509
|
+
}
|
|
8510
|
+
if (sets.length === 0) {
|
|
8511
|
+
const existing = await getContentType(sql2, slug);
|
|
8512
|
+
return existing;
|
|
8513
|
+
}
|
|
8514
|
+
sets.push(`"updated_at" = NOW()`);
|
|
8515
|
+
const query = `UPDATE "_cms_content_types" SET ${sets.join(", ")} WHERE "slug" = $${idx} RETURNING *`;
|
|
8516
|
+
const rows = await sql2.unsafe(query, [...vals, slug]);
|
|
8517
|
+
return mapContentType(rows[0]);
|
|
8518
|
+
}
|
|
8519
|
+
async function deleteContentType(sql2, slug) {
|
|
8520
|
+
await sql2`DELETE FROM "_cms_content_types" WHERE "slug" = ${slug}`;
|
|
8521
|
+
}
|
|
8522
|
+
async function createEntry(sql2, data) {
|
|
8523
|
+
const rows = await sql2`
|
|
8524
|
+
INSERT INTO "_cms_entries" ("content_type", "slug", "title", "data", "status", "created_by", "updated_by")
|
|
8525
|
+
VALUES (${data.contentType}, ${data.slug}, ${data.title}, ${sql2.json(data.entryData)}, ${data.status ?? "draft"}, ${data.createdBy ?? null}, ${data.createdBy ?? null})
|
|
8526
|
+
RETURNING *
|
|
8527
|
+
`;
|
|
8528
|
+
return mapEntry(rows[0]);
|
|
8529
|
+
}
|
|
8530
|
+
async function getEntry(sql2, id3) {
|
|
8531
|
+
const rows = await sql2`SELECT * FROM "_cms_entries" WHERE "id" = ${id3}`;
|
|
8532
|
+
return rows[0] ? mapEntry(rows[0]) : null;
|
|
8533
|
+
}
|
|
8534
|
+
async function getEntryBySlug(sql2, contentType, slug, status) {
|
|
8535
|
+
const rows = status ? await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} AND "slug" = ${slug} AND "status" = ${status}` : await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} AND "slug" = ${slug}`;
|
|
8536
|
+
return rows[0] ? mapEntry(rows[0]) : null;
|
|
8537
|
+
}
|
|
8538
|
+
async function listEntries(sql2, contentType, status) {
|
|
8539
|
+
const rows = status ? await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} AND "status" = ${status} ORDER BY "updated_at" DESC` : await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} ORDER BY "updated_at" DESC`;
|
|
8540
|
+
return rows.map(mapEntry);
|
|
8541
|
+
}
|
|
8542
|
+
async function updateEntry(sql2, id3, data) {
|
|
8543
|
+
const sets = [];
|
|
8544
|
+
const vals = [];
|
|
8545
|
+
let idx = 1;
|
|
8546
|
+
if (data.slug !== void 0) {
|
|
8547
|
+
sets.push(`"slug" = $${idx++}`);
|
|
8548
|
+
vals.push(data.slug);
|
|
8549
|
+
}
|
|
8550
|
+
if (data.title !== void 0) {
|
|
8551
|
+
sets.push(`"title" = $${idx++}`);
|
|
8552
|
+
vals.push(data.title);
|
|
8553
|
+
}
|
|
8554
|
+
if (data.entryData !== void 0) {
|
|
8555
|
+
sets.push(`"data" = $${idx++}::jsonb`);
|
|
8556
|
+
vals.push(JSON.stringify(data.entryData));
|
|
8557
|
+
}
|
|
8558
|
+
if (data.updatedBy !== void 0) {
|
|
8559
|
+
sets.push(`"updated_by" = $${idx++}`);
|
|
8560
|
+
vals.push(data.updatedBy);
|
|
8561
|
+
}
|
|
8562
|
+
if (sets.length === 0) {
|
|
8563
|
+
const existing = await getEntry(sql2, id3);
|
|
8564
|
+
return existing;
|
|
8565
|
+
}
|
|
8566
|
+
sets.push(`"updated_at" = NOW()`);
|
|
8567
|
+
const query = `UPDATE "_cms_entries" SET ${sets.join(", ")} WHERE "id" = $${idx} RETURNING *`;
|
|
8568
|
+
const rows = await sql2.unsafe(query, [...vals, id3]);
|
|
8569
|
+
return mapEntry(rows[0]);
|
|
8570
|
+
}
|
|
8571
|
+
async function publishEntry(sql2, id3, userId) {
|
|
8572
|
+
const rows = await sql2`
|
|
8573
|
+
UPDATE "_cms_entries"
|
|
8574
|
+
SET "status" = 'published', "published_at" = NOW(), "updated_by" = ${userId ?? null}, "updated_at" = NOW()
|
|
8575
|
+
WHERE "id" = ${id3}
|
|
8576
|
+
RETURNING *
|
|
8577
|
+
`;
|
|
8578
|
+
return mapEntry(rows[0]);
|
|
8579
|
+
}
|
|
8580
|
+
async function archiveEntry(sql2, id3) {
|
|
8581
|
+
const rows = await sql2`
|
|
8582
|
+
UPDATE "_cms_entries"
|
|
8583
|
+
SET "status" = 'archived', "updated_at" = NOW()
|
|
8584
|
+
WHERE "id" = ${id3}
|
|
8585
|
+
RETURNING *
|
|
8586
|
+
`;
|
|
8587
|
+
return mapEntry(rows[0]);
|
|
8588
|
+
}
|
|
8589
|
+
async function deleteEntry(sql2, id3) {
|
|
8590
|
+
await sql2`DELETE FROM "_cms_entries" WHERE "id" = ${id3}`;
|
|
8591
|
+
}
|
|
8592
|
+
async function searchEntries(sql2, contentType, query, status) {
|
|
8593
|
+
const q = `%${query}%`;
|
|
8594
|
+
const rows = status ? await sql2`
|
|
8595
|
+
SELECT * FROM "_cms_entries"
|
|
8596
|
+
WHERE "content_type" = ${contentType}
|
|
8597
|
+
AND "status" = ${status}
|
|
8598
|
+
AND ("title" ILIKE ${q} OR "data"::text ILIKE ${q})
|
|
8599
|
+
ORDER BY "updated_at" DESC
|
|
8600
|
+
LIMIT 50
|
|
8601
|
+
` : await sql2`
|
|
8602
|
+
SELECT * FROM "_cms_entries"
|
|
8603
|
+
WHERE "content_type" = ${contentType}
|
|
8604
|
+
AND ("title" ILIKE ${q} OR "data"::text ILIKE ${q})
|
|
8605
|
+
ORDER BY "updated_at" DESC
|
|
8606
|
+
LIMIT 50
|
|
8607
|
+
`;
|
|
8608
|
+
return rows.map(mapEntry);
|
|
8609
|
+
}
|
|
8610
|
+
async function createVersion(sql2, entryId, entryData, userId) {
|
|
8611
|
+
const maxVer = await sql2`SELECT COALESCE(MAX("version"), 0) + 1 AS next FROM "_cms_versions" WHERE "entry_id" = ${entryId}`;
|
|
8612
|
+
const nextVer = maxVer[0].next;
|
|
8613
|
+
const rows = await sql2`
|
|
8614
|
+
INSERT INTO "_cms_versions" ("entry_id", "version", "data", "created_by")
|
|
8615
|
+
VALUES (${entryId}, ${nextVer}, ${sql2.json(entryData)}, ${userId ?? null})
|
|
8616
|
+
RETURNING *
|
|
8617
|
+
`;
|
|
8618
|
+
return mapVersion(rows[0]);
|
|
8619
|
+
}
|
|
8620
|
+
async function listVersions(sql2, entryId) {
|
|
8621
|
+
const rows = await sql2`
|
|
8622
|
+
SELECT * FROM "_cms_versions" WHERE "entry_id" = ${entryId} ORDER BY "version" DESC
|
|
8623
|
+
`;
|
|
8624
|
+
return rows.map(mapVersion);
|
|
8625
|
+
}
|
|
8626
|
+
async function getVersion(sql2, entryId, version) {
|
|
8627
|
+
const rows = await sql2`
|
|
8628
|
+
SELECT * FROM "_cms_versions" WHERE "entry_id" = ${entryId} AND "version" = ${version}
|
|
8629
|
+
`;
|
|
8630
|
+
return rows[0] ? mapVersion(rows[0]) : null;
|
|
8631
|
+
}
|
|
8632
|
+
function mapContentType(row) {
|
|
8633
|
+
return {
|
|
8634
|
+
id: row.id,
|
|
8635
|
+
slug: row.slug,
|
|
8636
|
+
label: row.label,
|
|
8637
|
+
description: row.description ?? "",
|
|
8638
|
+
fields: typeof row.fields === "string" ? JSON.parse(row.fields) : row.fields,
|
|
8639
|
+
config: typeof row.config === "string" ? JSON.parse(row.config) : row.config ?? {},
|
|
8640
|
+
createdAt: row.created_at,
|
|
8641
|
+
updatedAt: row.updated_at
|
|
8642
|
+
};
|
|
8643
|
+
}
|
|
8644
|
+
function mapEntry(row) {
|
|
8645
|
+
return {
|
|
8646
|
+
id: row.id,
|
|
8647
|
+
contentType: row.content_type,
|
|
8648
|
+
slug: row.slug,
|
|
8649
|
+
title: row.title,
|
|
8650
|
+
status: row.status,
|
|
8651
|
+
data: typeof row.data === "string" ? JSON.parse(row.data) : row.data ?? {},
|
|
8652
|
+
locale: row.locale ?? null,
|
|
8653
|
+
createdBy: row.created_by ?? null,
|
|
8654
|
+
updatedBy: row.updated_by ?? null,
|
|
8655
|
+
publishedAt: row.published_at ?? null,
|
|
8656
|
+
createdAt: row.created_at,
|
|
8657
|
+
updatedAt: row.updated_at
|
|
8658
|
+
};
|
|
8659
|
+
}
|
|
8660
|
+
function mapVersion(row) {
|
|
8661
|
+
return {
|
|
8662
|
+
id: row.id,
|
|
8663
|
+
entryId: row.entry_id,
|
|
8664
|
+
version: row.version,
|
|
8665
|
+
data: typeof row.data === "string" ? JSON.parse(row.data) : row.data ?? {},
|
|
8666
|
+
createdBy: row.created_by ?? null,
|
|
8667
|
+
createdAt: row.created_at
|
|
8668
|
+
};
|
|
8669
|
+
}
|
|
8670
|
+
|
|
8671
|
+
// cms/admin.ts
|
|
8672
|
+
function esc(s) {
|
|
8673
|
+
if (s === null || s === void 0) return "";
|
|
8674
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
8675
|
+
}
|
|
8676
|
+
function h(s) {
|
|
8677
|
+
return esc(s);
|
|
8678
|
+
}
|
|
8679
|
+
function redirect(to, status = 303) {
|
|
8680
|
+
return new Response(null, { status, headers: { location: to } });
|
|
8681
|
+
}
|
|
8682
|
+
var ADMIN_CSS = `
|
|
8683
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
8684
|
+
:root{--bg:#f5f6fa;--card:#fff;--text:#1a1a2e;--text-muted:#6b7280;--primary:#4f46e5;--primary-hover:#4338ca;--danger:#ef4444;--danger-hover:#dc2626;--success:#10b981;--border:#e5e7eb;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
8685
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh;display:flex}
|
|
8686
|
+
a{color:var(--primary);text-decoration:none}
|
|
8687
|
+
a:hover{text-decoration:underline}
|
|
8688
|
+
h1{font-size:1.5rem;font-weight:700;margin-bottom:1rem}
|
|
8689
|
+
h2{font-size:1.2rem;font-weight:600;margin-bottom:.75rem}
|
|
8690
|
+
.sidebar{width:240px;background:var(--card);border-right:1px solid var(--border);padding:1.5rem;position:sticky;top:0;height:100vh;overflow-y:auto;flex-shrink:0}
|
|
8691
|
+
.sidebar h2{font-size:1rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
|
|
8692
|
+
.sidebar nav{display:flex;flex-direction:column;gap:.25rem}
|
|
8693
|
+
.sidebar a{display:block;padding:.5rem .75rem;border-radius:6px;color:var(--text);font-size:.9rem;transition:background .15s}
|
|
8694
|
+
.sidebar a:hover{background:#f0f0ff;text-decoration:none}
|
|
8695
|
+
.sidebar a.active{background:#eef2ff;color:var(--primary);font-weight:600}
|
|
8696
|
+
.sidebar .section{margin-top:1.5rem}
|
|
8697
|
+
.main{flex:1;padding:2rem;max-width:1200px}
|
|
8698
|
+
.card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.5rem;margin-bottom:1.5rem}
|
|
8699
|
+
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
|
|
8700
|
+
.stat-card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.25rem;text-align:center}
|
|
8701
|
+
.stat-card .num{font-size:2rem;font-weight:700;color:var(--primary)}
|
|
8702
|
+
.stat-card .label{font-size:.85rem;color:var(--text-muted);margin-top:.25rem}
|
|
8703
|
+
table{width:100%;border-collapse:collapse;font-size:.9rem}
|
|
8704
|
+
th,td{padding:.75rem;text-align:left;border-bottom:1px solid var(--border)}
|
|
8705
|
+
th{font-weight:600;color:var(--text-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}
|
|
8706
|
+
tr:hover td{background:#f9fafb}
|
|
8707
|
+
.badge{display:inline-block;padding:.15rem .5rem;border-radius:999px;font-size:.75rem;font-weight:500}
|
|
8708
|
+
.badge-draft{background:#fef3c7;color:#92400e}
|
|
8709
|
+
.badge-published{background:#d1fae5;color:#065f46}
|
|
8710
|
+
.badge-archived{background:#f3f4f6;color:#374151}
|
|
8711
|
+
.btn{display:inline-block;padding:.5rem 1rem;border-radius:6px;font-size:.875rem;font-weight:500;border:none;cursor:pointer;transition:all .15s;text-decoration:none;line-height:1.4}
|
|
8712
|
+
.btn-primary{background:var(--primary);color:#fff}
|
|
8713
|
+
.btn-primary:hover{background:var(--primary-hover);text-decoration:none}
|
|
8714
|
+
.btn-danger{background:var(--danger);color:#fff}
|
|
8715
|
+
.btn-danger:hover{background:var(--danger-hover);text-decoration:none}
|
|
8716
|
+
.btn-success{background:var(--success);color:#fff}
|
|
8717
|
+
.btn-success:hover{opacity:.9;text-decoration:none}
|
|
8718
|
+
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
|
|
8719
|
+
.btn-outline:hover{background:var(--bg);text-decoration:none}
|
|
8720
|
+
.btn-sm{padding:.3rem .6rem;font-size:.8rem}
|
|
8721
|
+
.btn-group{display:flex;gap:.5rem;flex-wrap:wrap}
|
|
8722
|
+
.mb-1{margin-bottom:.5rem}
|
|
8723
|
+
.mb-2{margin-bottom:1rem}
|
|
8724
|
+
.mt-2{margin-top:1rem}
|
|
8725
|
+
.flex{display:flex}
|
|
8726
|
+
.justify-between{justify-content:space-between}
|
|
8727
|
+
.items-center{align-items:center}
|
|
8728
|
+
.gap-2{gap:.5rem}
|
|
8729
|
+
.form-group{margin-bottom:1rem}
|
|
8730
|
+
.form-group label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.25rem;color:var(--text)}
|
|
8731
|
+
.form-group .help{font-size:.8rem;color:var(--text-muted);margin-top:.25rem}
|
|
8732
|
+
input[type=text],input[type=number],input[type=url],input[type=datetime-local],select,textarea{width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;font-family:inherit;transition:border-color .15s}
|
|
8733
|
+
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(79,70,229,.1)}
|
|
8734
|
+
textarea.code{font-family:'SF Mono','Fira Code',monospace;font-size:.85rem}
|
|
8735
|
+
input[type=checkbox]{margin-right:.5rem}
|
|
8736
|
+
.alert{padding:.75rem 1rem;border-radius:6px;margin-bottom:1rem;font-size:.9rem}
|
|
8737
|
+
.alert-success{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
|
|
8738
|
+
.alert-error{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
|
|
8739
|
+
.empty{text-align:center;padding:3rem 1rem;color:var(--text-muted)}
|
|
8740
|
+
.empty p{font-size:1rem;margin-bottom:1rem}
|
|
8741
|
+
.pagination{display:flex;gap:.5rem;justify-content:center;margin-top:1rem}
|
|
8742
|
+
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem}
|
|
8743
|
+
.media-item{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:box-shadow .15s}
|
|
8744
|
+
.media-item:hover{box-shadow:0 4px 12px rgba(0,0,0,.12)}
|
|
8745
|
+
.media-item img{width:100%;height:140px;object-fit:cover;display:block}
|
|
8746
|
+
.media-item .meta{padding:.5rem;font-size:.8rem}
|
|
8747
|
+
.media-item .meta .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
8748
|
+
.media-item .meta .info{color:var(--text-muted);font-size:.75rem}
|
|
8749
|
+
.field-row{display:flex;gap:.75rem;align-items:center;margin-bottom:.5rem;padding:.5rem;background:var(--bg);border-radius:6px}
|
|
8750
|
+
.field-row input[type=text]{flex:1}
|
|
8751
|
+
.field-row select{width:160px;flex:none}
|
|
8752
|
+
`;
|
|
8753
|
+
function flashMessage(ctx) {
|
|
8754
|
+
const msg = ctx.query?.message;
|
|
8755
|
+
const err = ctx.query?.error;
|
|
8756
|
+
if (msg) return `<div class="alert alert-success">${h(msg)}</div>`;
|
|
8757
|
+
if (err) return `<div class="alert alert-error">${h(err)}</div>`;
|
|
8758
|
+
return "";
|
|
8759
|
+
}
|
|
8760
|
+
function adminLayout(title, content, ctx, activeNav) {
|
|
8761
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
8762
|
+
function navItem(href, label, icon, match) {
|
|
8763
|
+
const active = activeNav && match && activeNav.startsWith(match);
|
|
8764
|
+
return `<a href="${base}${href}" class="${active ? "active" : ""}">${icon} ${label}</a>`;
|
|
8765
|
+
}
|
|
8766
|
+
return `<!DOCTYPE html>
|
|
8767
|
+
<html lang="en">
|
|
8768
|
+
<head>
|
|
8769
|
+
<meta charset="UTF-8">
|
|
8770
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8771
|
+
<title>${h(title)} \u2014 CMS</title>
|
|
8772
|
+
<style>${ADMIN_CSS}</style>
|
|
8773
|
+
</head>
|
|
8774
|
+
<body>
|
|
8775
|
+
<div class="sidebar">
|
|
8776
|
+
<h2>\u{1F4E6} CMS</h2>
|
|
8777
|
+
<nav>
|
|
8778
|
+
${navItem("/admin", "Dashboard", "", "")}
|
|
8779
|
+
</nav>
|
|
8780
|
+
<nav class="section">
|
|
8781
|
+
${navItem("/admin/content-types", "Content Types", "", "/admin/content-types")}
|
|
8782
|
+
${navItem("/admin/media", "Media Library", "", "/admin/media")}
|
|
8783
|
+
</nav>
|
|
8784
|
+
</div>
|
|
8785
|
+
<div class="main">
|
|
8786
|
+
${flashMessage(ctx)}
|
|
8787
|
+
${content}
|
|
8788
|
+
</div>
|
|
8789
|
+
</body>
|
|
8790
|
+
</html>`;
|
|
8791
|
+
}
|
|
8792
|
+
async function renderDashboard2(sql2, ctx) {
|
|
8793
|
+
const types = await listContentTypes(sql2);
|
|
8794
|
+
const totalTypes = types.length;
|
|
8795
|
+
let totalEntries = 0;
|
|
8796
|
+
let publishedEntries = 0;
|
|
8797
|
+
for (const t of types) {
|
|
8798
|
+
const all = await listEntries(sql2, t.slug);
|
|
8799
|
+
totalEntries += all.length;
|
|
8800
|
+
publishedEntries += all.filter((e) => e.status === "published").length;
|
|
8801
|
+
}
|
|
8802
|
+
const recentEntries = [];
|
|
8803
|
+
for (const t of types) {
|
|
8804
|
+
const entries = await listEntries(sql2, t.slug);
|
|
8805
|
+
recentEntries.push(...entries.slice(0, 5));
|
|
8806
|
+
}
|
|
8807
|
+
recentEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
8808
|
+
const recent = recentEntries.slice(0, 10);
|
|
8809
|
+
const stats = `
|
|
8810
|
+
<div class="stat-grid">
|
|
8811
|
+
<div class="stat-card"><div class="num">${totalTypes}</div><div class="label">Content Types</div></div>
|
|
8812
|
+
<div class="stat-card"><div class="num">${totalEntries}</div><div class="label">Total Entries</div></div>
|
|
8813
|
+
<div class="stat-card"><div class="num">${publishedEntries}</div><div class="label">Published</div></div>
|
|
8814
|
+
</div>
|
|
8815
|
+
`;
|
|
8816
|
+
let content = `<h1>Dashboard</h1>${stats}`;
|
|
8817
|
+
if (types.length === 0) {
|
|
8818
|
+
content += `
|
|
8819
|
+
<div class="card empty">
|
|
8820
|
+
<p>No content types yet.</p>
|
|
8821
|
+
<a href="${baseHref(ctx)}/admin/content-types/new" class="btn btn-primary">Create your first Content Type</a>
|
|
8822
|
+
</div>
|
|
8823
|
+
`;
|
|
8824
|
+
return adminLayout("Dashboard", content, ctx, "");
|
|
8825
|
+
}
|
|
8826
|
+
content += `<div class="card"><h2>Recent Entries</h2>`;
|
|
8827
|
+
if (recent.length === 0) {
|
|
8828
|
+
content += `<div class="empty"><p>No entries yet.</p></div>`;
|
|
8829
|
+
} else {
|
|
8830
|
+
content += `<table><thead><tr><th>Title</th><th>Type</th><th>Status</th><th>Updated</th></tr></thead><tbody>`;
|
|
8831
|
+
for (const e of recent) {
|
|
8832
|
+
const ct = types.find((t) => t.slug === e.contentType);
|
|
8833
|
+
content += `<tr><td><a href="${baseHref(ctx)}/admin/content/${e.contentType}/${e.id}/edit">${h(e.title)}</a></td><td>${h(ct?.label || e.contentType)}</td><td>${statusBadge(e.status)}</td><td>${formatDate(e.updatedAt)}</td></tr>`;
|
|
8834
|
+
}
|
|
8835
|
+
content += `</tbody></table>`;
|
|
8836
|
+
}
|
|
8837
|
+
content += `</div>`;
|
|
8838
|
+
content += `<div class="card"><h2>Quick Actions</h2>
|
|
8839
|
+
<div class="btn-group">
|
|
8840
|
+
<a href="${baseHref(ctx)}/admin/content-types/new" class="btn btn-primary">New Content Type</a>
|
|
8841
|
+
${types.map((t) => `<a href="${baseHref(ctx)}/admin/content/${t.slug}/new" class="btn btn-outline">New ${h(t.label)}</a>`).join("")}
|
|
8842
|
+
</div>
|
|
8843
|
+
</div>`;
|
|
8844
|
+
return adminLayout("Dashboard", content, ctx, "");
|
|
8845
|
+
}
|
|
8846
|
+
function baseHref(ctx) {
|
|
8847
|
+
return (ctx.mountPath || "").replace(/\/+$/, "");
|
|
8848
|
+
}
|
|
8849
|
+
function statusBadge(status) {
|
|
8850
|
+
const cls = status === "published" ? "badge-published" : status === "draft" ? "badge-draft" : "badge-archived";
|
|
8851
|
+
return `<span class="badge ${cls}">${status}</span>`;
|
|
8852
|
+
}
|
|
8853
|
+
function formatDate(d) {
|
|
8854
|
+
if (!d) return "";
|
|
8855
|
+
const date = new Date(d);
|
|
8856
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
8857
|
+
}
|
|
8858
|
+
async function renderContentTypeList(sql2, ctx) {
|
|
8859
|
+
const types = await listContentTypes(sql2);
|
|
8860
|
+
let content = `<div class="flex justify-between items-center mb-2">
|
|
8861
|
+
<h1>Content Types</h1>
|
|
8862
|
+
<a href="${baseHref(ctx)}/admin/content-types/new" class="btn btn-primary">+ New Type</a>
|
|
8863
|
+
</div>`;
|
|
8864
|
+
if (types.length === 0) {
|
|
8865
|
+
content += `<div class="card empty"><p>No content types defined.</p></div>`;
|
|
8866
|
+
} else {
|
|
8867
|
+
content += `<div class="card"><table><thead><tr><th>Label</th><th>Slug</th><th>Fields</th><th>Entries</th><th>Created</th><th></th></tr></thead><tbody>`;
|
|
8868
|
+
for (const t of types) {
|
|
8869
|
+
const entries = await listEntries(sql2, t.slug);
|
|
8870
|
+
content += `<tr>
|
|
8871
|
+
<td><a href="${baseHref(ctx)}/admin/content/${t.slug}"><strong>${h(t.label)}</strong></a></td>
|
|
8872
|
+
<td><code>${h(t.slug)}</code></td>
|
|
8873
|
+
<td>${t.fields.length}</td>
|
|
8874
|
+
<td>${entries.length}</td>
|
|
8875
|
+
<td>${formatDate(t.createdAt)}</td>
|
|
8876
|
+
<td>
|
|
8877
|
+
<div class="btn-group">
|
|
8878
|
+
<a href="${baseHref(ctx)}/admin/content-types/${t.slug}/edit" class="btn btn-outline btn-sm">Edit</a>
|
|
8879
|
+
<a href="${baseHref(ctx)}/admin/content/${t.slug}" class="btn btn-outline btn-sm">Entries</a>
|
|
8880
|
+
<form method="POST" action="${baseHref(ctx)}/admin/content-types/${t.slug}/delete" onsubmit="return confirm('Delete this content type and all its entries?')">
|
|
8881
|
+
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
|
8882
|
+
</form>
|
|
8883
|
+
</div>
|
|
8884
|
+
</td>
|
|
8885
|
+
</tr>`;
|
|
8886
|
+
}
|
|
8887
|
+
content += `</tbody></table></div>`;
|
|
8888
|
+
}
|
|
8889
|
+
return adminLayout("Content Types", content, ctx, "/admin/content-types");
|
|
8890
|
+
}
|
|
8891
|
+
function renderContentTypeForm(ctx, existing) {
|
|
8892
|
+
const base = baseHref(ctx);
|
|
8893
|
+
const isEdit = !!existing;
|
|
8894
|
+
const action = isEdit ? `${base}/admin/content-types/${existing.slug}` : `${base}/admin/content-types`;
|
|
8895
|
+
const title = isEdit ? `Edit ${existing.label}` : "New Content Type";
|
|
8896
|
+
const ct = existing || { slug: "", label: "", description: "", fields: [], config: {} };
|
|
8897
|
+
const fieldsJson = JSON.stringify(ct.fields, null, 2);
|
|
8898
|
+
const configJson = JSON.stringify(ct.config || {}, null, 2);
|
|
8899
|
+
let content = `<h1>${title}</h1>
|
|
8900
|
+
<div class="card">
|
|
8901
|
+
<form method="POST" action="${action}">
|
|
8902
|
+
<div class="form-group">
|
|
8903
|
+
<label for="slug">Slug</label>
|
|
8904
|
+
<input type="text" id="slug" name="slug" value="${h(ct.slug)}" required${isEdit ? ' readonly style="background:#f3f4f6"' : ""} placeholder="e.g., post, page, product">
|
|
8905
|
+
<div class="help">Unique identifier used in URLs and API</div>
|
|
8906
|
+
</div>
|
|
8907
|
+
<div class="form-group">
|
|
8908
|
+
<label for="label">Label</label>
|
|
8909
|
+
<input type="text" id="label" name="label" value="${h(ct.label)}" required placeholder="e.g., Post, Page, Product">
|
|
8910
|
+
</div>
|
|
8911
|
+
<div class="form-group">
|
|
8912
|
+
<label for="description">Description</label>
|
|
8913
|
+
<textarea id="description" name="description" rows="2">${h(ct.description)}</textarea>
|
|
8914
|
+
</div>
|
|
8915
|
+
<div class="form-group">
|
|
8916
|
+
<label for="fields">Fields (JSON)</label>
|
|
8917
|
+
<textarea id="fields" name="fields" rows="12" class="code" required>${h(fieldsJson)}</textarea>
|
|
8918
|
+
<div class="help">Array of field definitions. <a href="#" onclick="return false" style="cursor:help">See docs</a></div>
|
|
8919
|
+
</div>
|
|
8920
|
+
<div class="form-group">
|
|
8921
|
+
<label for="config">Config (JSON)</label>
|
|
8922
|
+
<textarea id="config" name="config" rows="4" class="code">${h(configJson)}</textarea>
|
|
8923
|
+
</div>
|
|
8924
|
+
<div class="btn-group">
|
|
8925
|
+
<button type="submit" class="btn btn-primary">${isEdit ? "Update" : "Create"}</button>
|
|
8926
|
+
<a href="${base}/admin/content-types" class="btn btn-outline">Cancel</a>
|
|
8927
|
+
</div>
|
|
8928
|
+
</form>
|
|
8929
|
+
</div>`;
|
|
8930
|
+
return adminLayout(title, content, ctx, "/admin/content-types");
|
|
8931
|
+
}
|
|
8932
|
+
async function renderEntryList(sql2, typeSlug, ctx) {
|
|
8933
|
+
const ct = await getContentType(sql2, typeSlug);
|
|
8934
|
+
if (!ct) {
|
|
8935
|
+
return adminLayout("Not Found", `<div class="card"><p>Content type "${h(typeSlug)}" not found.</p></div>`, ctx, "/admin/content-types");
|
|
8936
|
+
}
|
|
8937
|
+
const entries = await listEntries(sql2, typeSlug);
|
|
8938
|
+
const searchQ = ctx.query?.q;
|
|
8939
|
+
let content = `<div class="flex justify-between items-center mb-2">
|
|
8940
|
+
<h1>${h(ct.label)} <span style="font-weight:400;font-size:.9rem;color:var(--text-muted)">(${h(ct.slug)})</span></h1>
|
|
8941
|
+
<a href="${baseHref(ctx)}/admin/content/${typeSlug}/new" class="btn btn-primary">+ New ${h(ct.label)}</a>
|
|
8942
|
+
</div>`;
|
|
8943
|
+
if (entries.length === 0) {
|
|
8944
|
+
content += `<div class="card empty">
|
|
8945
|
+
<p>No entries yet.</p>
|
|
8946
|
+
<a href="${baseHref(ctx)}/admin/content/${typeSlug}/new" class="btn btn-primary">Create first entry</a>
|
|
8947
|
+
</div>`;
|
|
8948
|
+
} else {
|
|
8949
|
+
const titleField = ct.config?.titleField || "title";
|
|
8950
|
+
content += `<div class="card"><table><thead><tr><th>${h(titleField === "title" ? "Title" : titleField)}</th><th>Slug</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>`;
|
|
8951
|
+
for (const e of entries) {
|
|
8952
|
+
const displayTitle = e.title || e.data[titleField] || `(ID: ${e.id})`;
|
|
8953
|
+
content += `<tr>
|
|
8954
|
+
<td><a href="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/edit"><strong>${h(displayTitle)}</strong></a></td>
|
|
8955
|
+
<td><code>${h(e.slug)}</code></td>
|
|
8956
|
+
<td>${statusBadge(e.status)}</td>
|
|
8957
|
+
<td>${formatDate(e.updatedAt)}</td>
|
|
8958
|
+
<td>
|
|
8959
|
+
<div class="btn-group">
|
|
8960
|
+
<a href="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/edit" class="btn btn-outline btn-sm">Edit</a>
|
|
8961
|
+
${e.status === "draft" ? `<form method="POST" action="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/publish"><button type="submit" class="btn btn-success btn-sm">Publish</button></form>` : ""}
|
|
8962
|
+
${e.status === "published" ? `<form method="POST" action="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/archive"><button type="submit" class="btn btn-outline btn-sm">Archive</button></form>` : ""}
|
|
8963
|
+
${e.status !== "published" ? `<form method="POST" action="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/delete" onsubmit="return confirm('Delete this entry?')"><button type="submit" class="btn btn-danger btn-sm">Delete</button></form>` : ""}
|
|
8964
|
+
</div>
|
|
8965
|
+
</td>
|
|
8966
|
+
</tr>`;
|
|
8967
|
+
}
|
|
8968
|
+
content += `</tbody></table></div>`;
|
|
8969
|
+
}
|
|
8970
|
+
return adminLayout(ct.label, content, ctx, "/admin/content-types");
|
|
8971
|
+
}
|
|
8972
|
+
async function renderEntryForm(sql2, typeSlug, ctx, existing) {
|
|
8973
|
+
const ct = await getContentType(sql2, typeSlug);
|
|
8974
|
+
if (!ct) {
|
|
8975
|
+
return adminLayout("Not Found", `<div class="card"><p>Content type "${h(typeSlug)}" not found.</p></div>`, ctx, "/admin/content-types");
|
|
8976
|
+
}
|
|
8977
|
+
const base = baseHref(ctx);
|
|
8978
|
+
const isEdit = !!existing;
|
|
8979
|
+
const action = isEdit ? `${base}/admin/content/${typeSlug}/${existing.id}` : `${base}/admin/content/${typeSlug}`;
|
|
8980
|
+
const title = isEdit ? `Edit ${existing.title || existing.slug || `Entry #${existing.id}`}` : `New ${ct.label}`;
|
|
8981
|
+
const entry = existing || { slug: "", title: "", data: {}, status: "draft", id: 0, locale: null, createdBy: null, updatedBy: null, publishedAt: null, createdAt: "", updatedAt: "", contentType: typeSlug };
|
|
8982
|
+
const data = entry.data || {};
|
|
8983
|
+
let content = `<div class="flex justify-between items-center mb-2">
|
|
8984
|
+
<h1>${h(title)}</h1>
|
|
8985
|
+
${isEdit ? `<div class="btn-group">
|
|
8986
|
+
<span class="badge ${entry.status === "published" ? "badge-published" : entry.status === "draft" ? "badge-draft" : "badge-archived"}" style="font-size:.9rem;padding:.3rem .75rem">${entry.status}</span>
|
|
8987
|
+
</div>` : ""}
|
|
8988
|
+
</div>`;
|
|
8989
|
+
if (isEdit) {
|
|
8990
|
+
content += `<div class="btn-group mb-2">
|
|
8991
|
+
${entry.status === "draft" ? `<form method="POST" action="${base}/admin/content/${typeSlug}/${entry.id}/publish"><button type="submit" class="btn btn-success btn-sm">Publish</button></form>` : ""}
|
|
8992
|
+
${entry.status === "published" ? `<form method="POST" action="${base}/admin/content/${typeSlug}/${entry.id}/archive"><button type="submit" class="btn btn-outline btn-sm">Archive</button></form>` : ""}
|
|
8993
|
+
${entry.status !== "published" ? `<form method="POST" action="${base}/admin/content/${typeSlug}/${entry.id}/delete" onsubmit="return confirm('Delete this entry?')"><button type="submit" class="btn btn-danger btn-sm">Delete</button></form>` : ""}
|
|
8994
|
+
${isEdit ? `<a href="${base}/admin/content/${typeSlug}/${entry.id}/versions" class="btn btn-outline btn-sm">Versions</a>` : ""}
|
|
8995
|
+
</div>`;
|
|
8996
|
+
}
|
|
8997
|
+
content += `<div class="card">
|
|
8998
|
+
<form method="POST" action="${action}">
|
|
8999
|
+
<div class="form-group">
|
|
9000
|
+
<label for="title">${h(ct.config?.titleField || "Title")}</label>
|
|
9001
|
+
<input type="text" id="title" name="title" value="${h(entry.title)}" required placeholder="Enter title">
|
|
9002
|
+
</div>
|
|
9003
|
+
<div class="form-group">
|
|
9004
|
+
<label for="slug">Slug</label>
|
|
9005
|
+
<input type="text" id="slug" name="slug" value="${h(entry.slug)}" placeholder="Auto-generated from title if empty">
|
|
9006
|
+
</div>`;
|
|
9007
|
+
for (const field of ct.fields) {
|
|
9008
|
+
content += renderFormField(field, data[field.name], "data");
|
|
9009
|
+
}
|
|
9010
|
+
content += `
|
|
9011
|
+
<div class="btn-group mt-2">
|
|
9012
|
+
<button type="submit" class="btn btn-primary">${isEdit ? "Update" : "Create"}</button>
|
|
9013
|
+
<a href="${base}/admin/content/${typeSlug}" class="btn btn-outline">Cancel</a>
|
|
9014
|
+
</div>
|
|
9015
|
+
</form>
|
|
9016
|
+
</div>`;
|
|
9017
|
+
return adminLayout(title, content, ctx, "/admin/content-types");
|
|
9018
|
+
}
|
|
9019
|
+
function renderFormField(field, value, prefix) {
|
|
9020
|
+
const name = `${prefix}[${field.name}]`;
|
|
9021
|
+
const val = value !== void 0 && value !== null ? String(value) : "";
|
|
9022
|
+
let input = "";
|
|
9023
|
+
switch (field.type) {
|
|
9024
|
+
case "string":
|
|
9025
|
+
case "slug":
|
|
9026
|
+
input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" ${field.required ? "required" : ""} placeholder="${h(field.placeholder || "")}">`;
|
|
9027
|
+
break;
|
|
9028
|
+
case "richtext":
|
|
9029
|
+
input = `<textarea id="field-${field.name}" name="${name}" rows="10" ${field.required ? "required" : ""}>${h(val)}</textarea>`;
|
|
9030
|
+
break;
|
|
9031
|
+
case "integer":
|
|
9032
|
+
input = `<input type="number" id="field-${field.name}" name="${name}" value="${h(val)}" step="1" ${field.required ? "required" : ""}>`;
|
|
9033
|
+
break;
|
|
9034
|
+
case "float":
|
|
9035
|
+
input = `<input type="number" id="field-${field.name}" name="${name}" value="${h(val)}" step="0.01" ${field.required ? "required" : ""}>`;
|
|
9036
|
+
break;
|
|
9037
|
+
case "boolean":
|
|
9038
|
+
input = `<input type="hidden" name="${name}" value="false"><input type="checkbox" id="field-${field.name}" name="${name}" value="true" ${val === "true" ? "checked" : ""}>`;
|
|
9039
|
+
break;
|
|
9040
|
+
case "datetime":
|
|
9041
|
+
input = `<input type="datetime-local" id="field-${field.name}" name="${name}" value="${h(val)}" ${field.required ? "required" : ""}>`;
|
|
9042
|
+
break;
|
|
9043
|
+
case "json":
|
|
9044
|
+
input = `<textarea id="field-${field.name}" name="${name}" rows="8" class="code">${h(val)}</textarea>`;
|
|
9045
|
+
break;
|
|
9046
|
+
case "enum": {
|
|
9047
|
+
const opts = (field.options || []).map(
|
|
9048
|
+
(o) => `<option value="${h(o)}" ${o === val ? "selected" : ""}>${h(o)}</option>`
|
|
9049
|
+
).join("");
|
|
9050
|
+
input = `<select id="field-${field.name}" name="${name}" ${field.required ? "required" : ""}><option value="">\u2014 Select \u2014</option>${opts}</select>`;
|
|
9051
|
+
break;
|
|
9052
|
+
}
|
|
9053
|
+
case "image":
|
|
9054
|
+
input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" placeholder="Media URL or path" ${field.required ? "required" : ""}>`;
|
|
9055
|
+
break;
|
|
9056
|
+
case "gallery":
|
|
9057
|
+
input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" placeholder="Comma-separated media URLs">`;
|
|
9058
|
+
break;
|
|
9059
|
+
case "relation":
|
|
9060
|
+
input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" ${field.required ? "required" : ""} placeholder="Related ${h(field.relation?.contentType || "entry")} ID or slug">`;
|
|
9061
|
+
break;
|
|
9062
|
+
default:
|
|
9063
|
+
input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}">`;
|
|
9064
|
+
}
|
|
9065
|
+
return `
|
|
9066
|
+
<div class="form-group">
|
|
9067
|
+
<label for="field-${field.name}">${h(field.name)}${field.required ? ' <span style="color:var(--danger)">*</span>' : ""}</label>
|
|
9068
|
+
${input}
|
|
9069
|
+
${field.helpText ? `<div class="help">${h(field.helpText)}</div>` : ""}
|
|
9070
|
+
</div>`;
|
|
9071
|
+
}
|
|
9072
|
+
async function renderVersions(sql2, typeSlug, entryId, ctx) {
|
|
9073
|
+
const ct = await getContentType(sql2, typeSlug);
|
|
9074
|
+
const entry = await getEntry(sql2, entryId);
|
|
9075
|
+
if (!ct || !entry) {
|
|
9076
|
+
return adminLayout("Not Found", `<div class="card"><p>Entry not found.</p></div>`, ctx, "/admin/content-types");
|
|
9077
|
+
}
|
|
9078
|
+
const versions = await listVersions(sql2, entryId);
|
|
9079
|
+
const base = baseHref(ctx);
|
|
9080
|
+
let content = `<div class="flex justify-between items-center mb-2">
|
|
9081
|
+
<h1>Versions: ${h(entry.title)}</h1>
|
|
9082
|
+
<a href="${base}/admin/content/${typeSlug}/${entryId}/edit" class="btn btn-outline">\u2190 Back to Editor</a>
|
|
9083
|
+
</div>`;
|
|
9084
|
+
if (versions.length === 0) {
|
|
9085
|
+
content += `<div class="card empty"><p>No versions saved.</p></div>`;
|
|
9086
|
+
} else {
|
|
9087
|
+
content += `<div class="card"><table><thead><tr><th>Version</th><th>Created</th><th></th></tr></thead><tbody>`;
|
|
9088
|
+
for (const v of versions) {
|
|
9089
|
+
content += `<tr>
|
|
9090
|
+
<td><strong>#${v.version}</strong></td>
|
|
9091
|
+
<td>${formatDate(v.createdAt)}</td>
|
|
9092
|
+
<td>
|
|
9093
|
+
<div class="btn-group">
|
|
9094
|
+
<form method="POST" action="${base}/admin/content/${typeSlug}/${entryId}/restore/${v.version}" onsubmit="return confirm('Restore version #${v.version}? Current data will be replaced.')">
|
|
9095
|
+
<button type="submit" class="btn btn-outline btn-sm">Restore</button>
|
|
9096
|
+
</form>
|
|
9097
|
+
</div>
|
|
9098
|
+
</td>
|
|
9099
|
+
</tr>`;
|
|
9100
|
+
}
|
|
9101
|
+
content += `</tbody></table></div>`;
|
|
9102
|
+
}
|
|
9103
|
+
return adminLayout(`Versions: ${entry.title}`, content, ctx, "/admin/content-types");
|
|
9104
|
+
}
|
|
9105
|
+
function registerAdminRoutes(router, sql2) {
|
|
9106
|
+
const h2 = (handler) => (req, ctx) => handler(sql2, req, ctx);
|
|
9107
|
+
router.get("/admin", h2(async (_sql, req, ctx) => {
|
|
9108
|
+
const html = await renderDashboard2(sql2, ctx);
|
|
9109
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9110
|
+
}));
|
|
9111
|
+
router.get("/admin/content-types", h2(async (_sql, req, ctx) => {
|
|
9112
|
+
const html = await renderContentTypeList(sql2, ctx);
|
|
9113
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9114
|
+
}));
|
|
9115
|
+
router.get("/admin/content-types/new", h2(async (_sql, req, ctx) => {
|
|
9116
|
+
const html = renderContentTypeForm(ctx);
|
|
9117
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9118
|
+
}));
|
|
9119
|
+
router.post("/admin/content-types", h2(async (_sql, req, ctx) => {
|
|
9120
|
+
try {
|
|
9121
|
+
const fd = await req.formData();
|
|
9122
|
+
const slug = fd.get("slug")?.trim();
|
|
9123
|
+
const label = fd.get("label")?.trim();
|
|
9124
|
+
const description = fd.get("description") || "";
|
|
9125
|
+
const fieldsRaw = fd.get("fields") || "[]";
|
|
9126
|
+
const configRaw = fd.get("config") || "{}";
|
|
9127
|
+
let fields;
|
|
9128
|
+
let config;
|
|
9129
|
+
try {
|
|
9130
|
+
fields = JSON.parse(fieldsRaw);
|
|
9131
|
+
} catch {
|
|
9132
|
+
fields = [];
|
|
9133
|
+
}
|
|
9134
|
+
try {
|
|
9135
|
+
config = JSON.parse(configRaw);
|
|
9136
|
+
} catch {
|
|
9137
|
+
config = {};
|
|
9138
|
+
}
|
|
9139
|
+
if (!slug || !label) throw new Error("Slug and label are required");
|
|
9140
|
+
await createContentType(sql2, slug, label, fields, config);
|
|
9141
|
+
return redirect(`${baseHref(ctx)}/admin/content-types?message=${encodeURIComponent(`Content type "${label}" created`)}`, 303);
|
|
9142
|
+
} catch (err) {
|
|
9143
|
+
return redirect(`${baseHref(ctx)}/admin/content-types/new?error=${encodeURIComponent(err.message)}`, 303);
|
|
9144
|
+
}
|
|
9145
|
+
}));
|
|
9146
|
+
router.get("/admin/content-types/:slug/edit", h2(async (_sql, req, ctx) => {
|
|
9147
|
+
const ct = await getContentType(sql2, ctx.params.slug);
|
|
9148
|
+
if (!ct) {
|
|
9149
|
+
return redirect(`${baseHref(ctx)}/admin/content-types?error=${encodeURIComponent("Content type not found")}`, 303);
|
|
9150
|
+
}
|
|
9151
|
+
const html = renderContentTypeForm(ctx, ct);
|
|
9152
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9153
|
+
}));
|
|
9154
|
+
router.post("/admin/content-types/:slug", h2(async (_sql, req, ctx) => {
|
|
9155
|
+
try {
|
|
9156
|
+
const fd = await req.formData();
|
|
9157
|
+
const label = fd.get("label")?.trim();
|
|
9158
|
+
const description = fd.get("description") || "";
|
|
9159
|
+
const fieldsRaw = fd.get("fields") || "[]";
|
|
9160
|
+
const configRaw = fd.get("config") || "{}";
|
|
9161
|
+
let fields;
|
|
9162
|
+
let config;
|
|
9163
|
+
try {
|
|
9164
|
+
fields = JSON.parse(fieldsRaw);
|
|
9165
|
+
} catch {
|
|
9166
|
+
fields = [];
|
|
9167
|
+
}
|
|
9168
|
+
try {
|
|
9169
|
+
config = JSON.parse(configRaw);
|
|
9170
|
+
} catch {
|
|
9171
|
+
config = {};
|
|
9172
|
+
}
|
|
9173
|
+
await updateContentType(sql2, ctx.params.slug, { label, description, fields, config });
|
|
9174
|
+
return redirect(`${baseHref(ctx)}/admin/content-types?message=${encodeURIComponent(`Content type updated`)}`, 303);
|
|
9175
|
+
} catch (err) {
|
|
9176
|
+
return redirect(`${baseHref(ctx)}/admin/content-types/${ctx.params.slug}/edit?error=${encodeURIComponent(err.message)}`, 303);
|
|
9177
|
+
}
|
|
9178
|
+
}));
|
|
9179
|
+
router.post("/admin/content-types/:slug/delete", h2(async (_sql, req, ctx) => {
|
|
9180
|
+
try {
|
|
9181
|
+
await deleteContentType(sql2, ctx.params.slug);
|
|
9182
|
+
return redirect(`${baseHref(ctx)}/admin/content-types?message=Content type deleted`, 303);
|
|
9183
|
+
} catch (err) {
|
|
9184
|
+
return redirect(`${baseHref(ctx)}/admin/content-types?error=${encodeURIComponent(err.message)}`, 303);
|
|
9185
|
+
}
|
|
9186
|
+
}));
|
|
9187
|
+
router.get("/admin/content/:type", h2(async (_sql, req, ctx) => {
|
|
9188
|
+
const html = await renderEntryList(sql2, ctx.params.type, ctx);
|
|
9189
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9190
|
+
}));
|
|
9191
|
+
router.get("/admin/content/:type/new", h2(async (_sql, req, ctx) => {
|
|
9192
|
+
const html = await renderEntryForm(sql2, ctx.params.type, ctx);
|
|
9193
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9194
|
+
}));
|
|
9195
|
+
router.post("/admin/content/:type", h2(async (_sql, req, ctx) => {
|
|
9196
|
+
try {
|
|
9197
|
+
const ct = await getContentType(sql2, ctx.params.type);
|
|
9198
|
+
if (!ct) throw new Error("Content type not found");
|
|
9199
|
+
const fd = await req.formData();
|
|
9200
|
+
const title = fd.get("title")?.trim() || "Untitled";
|
|
9201
|
+
let slug = fd.get("slug")?.trim() || "";
|
|
9202
|
+
if (!slug) slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || `entry-${Date.now()}`;
|
|
9203
|
+
const entryData = parseFormDataToObject(fd, ct.fields);
|
|
9204
|
+
const entry = await createEntry(sql2, { contentType: ct.slug, slug, title, entryData });
|
|
9205
|
+
await createVersion(sql2, entry.id, entryData);
|
|
9206
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entry.id}/edit?message=Created`, 303);
|
|
9207
|
+
} catch (err) {
|
|
9208
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/new?error=${encodeURIComponent(err.message)}`, 303);
|
|
9209
|
+
}
|
|
9210
|
+
}));
|
|
9211
|
+
router.get("/admin/content/:type/:id/edit", h2(async (_sql, req, ctx) => {
|
|
9212
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9213
|
+
if (isNaN(entryId)) {
|
|
9214
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?error=Invalid ID`, 303);
|
|
9215
|
+
}
|
|
9216
|
+
const entry = await getEntry(sql2, entryId);
|
|
9217
|
+
if (!entry) {
|
|
9218
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?error=Entry not found`, 303);
|
|
9219
|
+
}
|
|
9220
|
+
const html = await renderEntryForm(sql2, ctx.params.type, ctx, entry);
|
|
9221
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9222
|
+
}));
|
|
9223
|
+
router.post("/admin/content/:type/:id", h2(async (_sql, req, ctx) => {
|
|
9224
|
+
try {
|
|
9225
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9226
|
+
const ct = await getContentType(sql2, ctx.params.type);
|
|
9227
|
+
if (!ct) throw new Error("Content type not found");
|
|
9228
|
+
const entry = await getEntry(sql2, entryId);
|
|
9229
|
+
if (!entry) throw new Error("Entry not found");
|
|
9230
|
+
const fd = await req.formData();
|
|
9231
|
+
const title = fd.get("title")?.trim() || entry.title;
|
|
9232
|
+
let slug = fd.get("slug")?.trim() || "";
|
|
9233
|
+
if (!slug) slug = entry.slug;
|
|
9234
|
+
const entryData = parseFormDataToObject(fd, ct.fields);
|
|
9235
|
+
const oldData = entry.data;
|
|
9236
|
+
await createVersion(sql2, entryId, oldData);
|
|
9237
|
+
await updateEntry(sql2, entryId, { slug, title, entryData });
|
|
9238
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Updated`, 303);
|
|
9239
|
+
} catch (err) {
|
|
9240
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
|
|
9241
|
+
}
|
|
9242
|
+
}));
|
|
9243
|
+
router.post("/admin/content/:type/:id/publish", h2(async (_sql, req, ctx) => {
|
|
9244
|
+
try {
|
|
9245
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9246
|
+
await publishEntry(sql2, entryId);
|
|
9247
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Published`, 303);
|
|
9248
|
+
} catch (err) {
|
|
9249
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
|
|
9250
|
+
}
|
|
9251
|
+
}));
|
|
9252
|
+
router.post("/admin/content/:type/:id/archive", h2(async (_sql, req, ctx) => {
|
|
9253
|
+
try {
|
|
9254
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9255
|
+
await archiveEntry(sql2, entryId);
|
|
9256
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Archived`, 303);
|
|
9257
|
+
} catch (err) {
|
|
9258
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
|
|
9259
|
+
}
|
|
9260
|
+
}));
|
|
9261
|
+
router.post("/admin/content/:type/:id/delete", h2(async (_sql, req, ctx) => {
|
|
9262
|
+
try {
|
|
9263
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9264
|
+
await deleteEntry(sql2, entryId);
|
|
9265
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?message=Entry deleted`, 303);
|
|
9266
|
+
} catch (err) {
|
|
9267
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?error=${encodeURIComponent(err.message)}`, 303);
|
|
9268
|
+
}
|
|
9269
|
+
}));
|
|
9270
|
+
router.get("/admin/content/:type/:id/versions", h2(async (_sql, req, ctx) => {
|
|
9271
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9272
|
+
const html = await renderVersions(sql2, ctx.params.type, entryId, ctx);
|
|
9273
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9274
|
+
}));
|
|
9275
|
+
router.post("/admin/content/:type/:id/restore/:version", h2(async (_sql, req, ctx) => {
|
|
9276
|
+
try {
|
|
9277
|
+
const entryId = parseInt(ctx.params.id, 10);
|
|
9278
|
+
const version = parseInt(ctx.params.version, 10);
|
|
9279
|
+
const ct = await getContentType(sql2, ctx.params.type);
|
|
9280
|
+
if (!ct) throw new Error("Content type not found");
|
|
9281
|
+
const entry = await getEntry(sql2, entryId);
|
|
9282
|
+
if (!entry) throw new Error("Entry not found");
|
|
9283
|
+
const ver = await getVersion(sql2, entryId, version);
|
|
9284
|
+
if (!ver) throw new Error("Version not found");
|
|
9285
|
+
await createVersion(sql2, entryId, entry.data);
|
|
9286
|
+
await updateEntry(sql2, entryId, { entryData: ver.data });
|
|
9287
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Version ${version} restored`, 303);
|
|
9288
|
+
} catch (err) {
|
|
9289
|
+
return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
|
|
9290
|
+
}
|
|
9291
|
+
}));
|
|
9292
|
+
}
|
|
9293
|
+
function parseFormDataToObject(fd, fields) {
|
|
9294
|
+
const data = {};
|
|
9295
|
+
for (const field of fields) {
|
|
9296
|
+
const val = fd.get(`data[${field.name}]`);
|
|
9297
|
+
if (val === null) continue;
|
|
9298
|
+
const strVal = val;
|
|
9299
|
+
switch (field.type) {
|
|
9300
|
+
case "integer":
|
|
9301
|
+
data[field.name] = strVal ? parseInt(strVal, 10) : null;
|
|
9302
|
+
break;
|
|
9303
|
+
case "float":
|
|
9304
|
+
data[field.name] = strVal ? parseFloat(strVal) : null;
|
|
9305
|
+
break;
|
|
9306
|
+
case "boolean":
|
|
9307
|
+
data[field.name] = strVal === "true";
|
|
9308
|
+
break;
|
|
9309
|
+
case "json":
|
|
9310
|
+
try {
|
|
9311
|
+
data[field.name] = JSON.parse(strVal);
|
|
9312
|
+
} catch {
|
|
9313
|
+
data[field.name] = strVal;
|
|
9314
|
+
}
|
|
9315
|
+
break;
|
|
9316
|
+
default:
|
|
9317
|
+
data[field.name] = strVal;
|
|
9318
|
+
}
|
|
9319
|
+
}
|
|
9320
|
+
return data;
|
|
9321
|
+
}
|
|
9322
|
+
|
|
9323
|
+
// cms/api.ts
|
|
9324
|
+
function registerApiRoutes(router, sql2) {
|
|
9325
|
+
router.get("/api/:type", async (req, ctx) => {
|
|
9326
|
+
try {
|
|
9327
|
+
const ct = await getContentType(sql2, ctx.params.type);
|
|
9328
|
+
if (!ct) {
|
|
9329
|
+
return Response.json({ error: "Content type not found" }, { status: 404 });
|
|
9330
|
+
}
|
|
9331
|
+
const q = ctx.query?.q;
|
|
9332
|
+
let entries;
|
|
9333
|
+
if (q) {
|
|
9334
|
+
entries = await searchEntries(sql2, ct.slug, q, "published");
|
|
9335
|
+
} else {
|
|
9336
|
+
entries = await listEntries(sql2, ct.slug, "published");
|
|
9337
|
+
}
|
|
9338
|
+
const fields = ct.fields;
|
|
9339
|
+
return Response.json({
|
|
9340
|
+
data: entries.map((e) => serializeEntry(e, fields)),
|
|
9341
|
+
meta: {
|
|
9342
|
+
total: entries.length,
|
|
9343
|
+
type: ct.slug
|
|
9344
|
+
}
|
|
9345
|
+
});
|
|
9346
|
+
} catch (err) {
|
|
9347
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
9348
|
+
}
|
|
9349
|
+
});
|
|
9350
|
+
router.get("/api/:type/:slug", async (req, ctx) => {
|
|
9351
|
+
try {
|
|
9352
|
+
const ct = await getContentType(sql2, ctx.params.type);
|
|
9353
|
+
if (!ct) {
|
|
9354
|
+
return Response.json({ error: "Content type not found" }, { status: 404 });
|
|
9355
|
+
}
|
|
9356
|
+
const entry = await getEntryBySlug(sql2, ct.slug, ctx.params.slug, "published");
|
|
9357
|
+
if (!entry) {
|
|
9358
|
+
return Response.json({ error: "Entry not found" }, { status: 404 });
|
|
9359
|
+
}
|
|
9360
|
+
return Response.json({
|
|
9361
|
+
data: serializeEntry(entry, ct.fields)
|
|
9362
|
+
});
|
|
9363
|
+
} catch (err) {
|
|
9364
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
9365
|
+
}
|
|
9366
|
+
});
|
|
9367
|
+
}
|
|
9368
|
+
function serializeEntry(entry, fields) {
|
|
9369
|
+
return {
|
|
9370
|
+
id: entry.id,
|
|
9371
|
+
slug: entry.slug,
|
|
9372
|
+
title: entry.title,
|
|
9373
|
+
...entry.data,
|
|
9374
|
+
meta: {
|
|
9375
|
+
type: entry.contentType,
|
|
9376
|
+
status: entry.status,
|
|
9377
|
+
publishedAt: entry.publishedAt,
|
|
9378
|
+
createdAt: entry.createdAt,
|
|
9379
|
+
updatedAt: entry.updatedAt
|
|
9380
|
+
}
|
|
9381
|
+
};
|
|
9382
|
+
}
|
|
9383
|
+
|
|
9384
|
+
// cms/media.ts
|
|
9385
|
+
import { writeFile as writeFile2, mkdir as mkdir3 } from "node:fs/promises";
|
|
9386
|
+
import { join as join9, extname as extname3 } from "node:path";
|
|
9387
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
9388
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
9389
|
+
function redirect2(to, status = 303) {
|
|
9390
|
+
return new Response(null, { status, headers: { location: to } });
|
|
9391
|
+
}
|
|
9392
|
+
async function createMediaTable(sql2) {
|
|
9393
|
+
await sql2.unsafe(`
|
|
9394
|
+
CREATE TABLE IF NOT EXISTS "_cms_media" (
|
|
9395
|
+
"id" SERIAL PRIMARY KEY,
|
|
9396
|
+
"filename" TEXT NOT NULL,
|
|
9397
|
+
"original_name" TEXT NOT NULL,
|
|
9398
|
+
"mimetype" TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
9399
|
+
"size" INTEGER NOT NULL DEFAULT 0,
|
|
9400
|
+
"width" INTEGER DEFAULT NULL,
|
|
9401
|
+
"height" INTEGER DEFAULT NULL,
|
|
9402
|
+
"alt" TEXT DEFAULT '',
|
|
9403
|
+
"created_by" INTEGER DEFAULT NULL,
|
|
9404
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
9405
|
+
)
|
|
9406
|
+
`);
|
|
9407
|
+
}
|
|
9408
|
+
async function saveMedia(sql2, file, mediaDir, userId) {
|
|
9409
|
+
const ext = extname3(file.name) || "";
|
|
9410
|
+
const filename = `${randomUUID3()}${ext}`;
|
|
9411
|
+
const dir = mediaDir;
|
|
9412
|
+
if (!existsSync8(dir)) {
|
|
9413
|
+
await mkdir3(dir, { recursive: true });
|
|
9414
|
+
}
|
|
9415
|
+
const filepath = join9(dir, filename);
|
|
9416
|
+
await writeFile2(filepath, file.data);
|
|
9417
|
+
const rows = await sql2`
|
|
9418
|
+
INSERT INTO "_cms_media" ("filename", "original_name", "mimetype", "size", "created_by")
|
|
9419
|
+
VALUES (${filename}, ${file.name}, ${file.mimetype}, ${file.data.length}, ${userId ?? null})
|
|
9420
|
+
RETURNING *
|
|
9421
|
+
`;
|
|
9422
|
+
return mapMedia(rows[0]);
|
|
9423
|
+
}
|
|
9424
|
+
async function listMedia(sql2) {
|
|
9425
|
+
const rows = await sql2`SELECT * FROM "_cms_media" ORDER BY "created_at" DESC`;
|
|
9426
|
+
return rows.map(mapMedia);
|
|
9427
|
+
}
|
|
9428
|
+
async function getMedia(sql2, id3) {
|
|
9429
|
+
const rows = await sql2`SELECT * FROM "_cms_media" WHERE "id" = ${id3}`;
|
|
9430
|
+
return rows[0] ? mapMedia(rows[0]) : null;
|
|
9431
|
+
}
|
|
9432
|
+
async function deleteMedia(sql2, id3, mediaDir) {
|
|
9433
|
+
const media = await getMedia(sql2, id3);
|
|
9434
|
+
if (media) {
|
|
9435
|
+
try {
|
|
9436
|
+
const { unlink } = await import("node:fs/promises");
|
|
9437
|
+
await unlink(join9(mediaDir, media.filename));
|
|
9438
|
+
} catch {
|
|
9439
|
+
}
|
|
9440
|
+
}
|
|
9441
|
+
await sql2`DELETE FROM "_cms_media" WHERE "id" = ${id3}`;
|
|
9442
|
+
}
|
|
9443
|
+
function mapMedia(row) {
|
|
9444
|
+
return {
|
|
9445
|
+
id: row.id,
|
|
9446
|
+
filename: row.filename,
|
|
9447
|
+
originalName: row.original_name,
|
|
9448
|
+
mimetype: row.mimetype,
|
|
9449
|
+
size: row.size,
|
|
9450
|
+
width: row.width ?? null,
|
|
9451
|
+
height: row.height ?? null,
|
|
9452
|
+
alt: row.alt ?? "",
|
|
9453
|
+
createdBy: row.created_by ?? null,
|
|
9454
|
+
createdAt: row.created_at
|
|
9455
|
+
};
|
|
9456
|
+
}
|
|
9457
|
+
function registerMediaRoutes(router, sql2, mediaDir) {
|
|
9458
|
+
router.get("/admin/media", async (req, ctx) => {
|
|
9459
|
+
const media = await listMedia(sql2);
|
|
9460
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
9461
|
+
const msg = ctx.query?.message;
|
|
9462
|
+
const err = ctx.query?.error;
|
|
9463
|
+
let content = `<div class="flex justify-between items-center mb-2">
|
|
9464
|
+
<h1>Media Library</h1>
|
|
9465
|
+
</div>
|
|
9466
|
+
${msg ? `<div class="alert alert-success">${esc2(msg)}</div>` : ""}
|
|
9467
|
+
${err ? `<div class="alert alert-error">${esc2(err)}</div>` : ""}`;
|
|
9468
|
+
if (media.length === 0) {
|
|
9469
|
+
content += `<div class="card empty"><p>No media uploaded yet.</p></div>`;
|
|
9470
|
+
} else {
|
|
9471
|
+
content += `<div class="media-grid">`;
|
|
9472
|
+
for (const m of media) {
|
|
9473
|
+
const isImage = m.mimetype.startsWith("image/");
|
|
9474
|
+
const fileUrl = `${base}/admin/media/${m.id}/file`;
|
|
9475
|
+
content += `<div class="media-item">
|
|
9476
|
+
${isImage ? `<img src="${esc2(fileUrl)}" alt="${esc2(m.alt || m.originalName)}" loading="lazy">` : `<div style="height:140px;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-size:2rem;color:var(--text-muted)">\u{1F4C4}</div>`}
|
|
9477
|
+
<div class="meta">
|
|
9478
|
+
<div class="name">${esc2(m.originalName)}</div>
|
|
9479
|
+
<div class="info">${formatSize(m.size)}</div>
|
|
9480
|
+
<form method="POST" action="${base}/admin/media/${m.id}/delete" onsubmit="return confirm('Delete this file?')" style="margin-top:.25rem">
|
|
9481
|
+
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
|
9482
|
+
</form>
|
|
9483
|
+
</div>
|
|
9484
|
+
</div>`;
|
|
9485
|
+
}
|
|
9486
|
+
content += `</div>`;
|
|
9487
|
+
}
|
|
9488
|
+
content += `<div class="card" style="margin-top:1rem">
|
|
9489
|
+
<h2>Upload File</h2>
|
|
9490
|
+
<form method="POST" action="${base}/admin/media/upload" enctype="multipart/form-data">
|
|
9491
|
+
<div class="form-group">
|
|
9492
|
+
<input type="file" name="file" required>
|
|
9493
|
+
</div>
|
|
9494
|
+
<button type="submit" class="btn btn-primary">Upload</button>
|
|
9495
|
+
</form>
|
|
9496
|
+
</div>`;
|
|
9497
|
+
const html = adminPageContent("Media Library", content, ctx, "/admin/media");
|
|
9498
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
9499
|
+
});
|
|
9500
|
+
router.post("/admin/media/upload", async (req, ctx) => {
|
|
9501
|
+
try {
|
|
9502
|
+
const fd = await req.formData();
|
|
9503
|
+
const file = fd.get("file");
|
|
9504
|
+
if (!file) throw new Error("No file provided");
|
|
9505
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
9506
|
+
await saveMedia(sql2, { name: file.name, data: buf, mimetype: file.type }, mediaDir);
|
|
9507
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
9508
|
+
return redirect2(`${base}/admin/media?message=File uploaded`, 303);
|
|
9509
|
+
} catch (err) {
|
|
9510
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
9511
|
+
return redirect2(`${base}/admin/media?error=${encodeURIComponent(err.message)}`, 303);
|
|
9512
|
+
}
|
|
9513
|
+
});
|
|
9514
|
+
router.post("/admin/media/:id/delete", async (req, ctx) => {
|
|
9515
|
+
try {
|
|
9516
|
+
const id3 = parseInt(ctx.params.id, 10);
|
|
9517
|
+
await deleteMedia(sql2, id3, mediaDir);
|
|
9518
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
9519
|
+
return redirect2(`${base}/admin/media?message=File deleted`, 303);
|
|
9520
|
+
} catch (err) {
|
|
9521
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
9522
|
+
return redirect2(`${base}/admin/media?error=${encodeURIComponent(err.message)}`, 303);
|
|
9523
|
+
}
|
|
9524
|
+
});
|
|
9525
|
+
router.get("/admin/media/:id/file", async (req, ctx) => {
|
|
9526
|
+
try {
|
|
9527
|
+
const id3 = parseInt(ctx.params.id, 10);
|
|
9528
|
+
const media = await getMedia(sql2, id3);
|
|
9529
|
+
if (!media) {
|
|
9530
|
+
return new Response("Not found", { status: 404 });
|
|
9531
|
+
}
|
|
9532
|
+
const { readFile: readFile3 } = await import("node:fs/promises");
|
|
9533
|
+
const filepath = join9(mediaDir, media.filename);
|
|
9534
|
+
const data = await readFile3(filepath);
|
|
9535
|
+
return new Response(data, {
|
|
9536
|
+
headers: {
|
|
9537
|
+
"content-type": media.mimetype,
|
|
9538
|
+
"content-length": String(media.size),
|
|
9539
|
+
"cache-control": "public, max-age=31536000"
|
|
9540
|
+
}
|
|
9541
|
+
});
|
|
9542
|
+
} catch (err) {
|
|
9543
|
+
return new Response("Not found", { status: 404 });
|
|
9544
|
+
}
|
|
9545
|
+
});
|
|
9546
|
+
}
|
|
9547
|
+
function formatSize(bytes) {
|
|
9548
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
9549
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
9550
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
9551
|
+
}
|
|
9552
|
+
function esc2(s) {
|
|
9553
|
+
if (s === null || s === void 0) return "";
|
|
9554
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
9555
|
+
}
|
|
9556
|
+
function adminPageContent(title, content, ctx, activeNav) {
|
|
9557
|
+
const base = (ctx.mountPath || "").replace(/\/+$/, "");
|
|
9558
|
+
const ADMIN_CSS2 = `
|
|
9559
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
9560
|
+
:root{--bg:#f5f6fa;--card:#fff;--text:#1a1a2e;--text-muted:#6b7280;--primary:#4f46e5;--primary-hover:#4338ca;--danger:#ef4444;--danger-hover:#dc2626;--success:#10b981;--border:#e5e7eb;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
9561
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh;display:flex}
|
|
9562
|
+
a{color:var(--primary);text-decoration:none}
|
|
9563
|
+
a:hover{text-decoration:underline}
|
|
9564
|
+
h1{font-size:1.5rem;font-weight:700;margin-bottom:1rem}
|
|
9565
|
+
h2{font-size:1.2rem;font-weight:600;margin-bottom:.75rem}
|
|
9566
|
+
.sidebar{width:240px;background:var(--card);border-right:1px solid var(--border);padding:1.5rem;position:sticky;top:0;height:100vh;overflow-y:auto;flex-shrink:0}
|
|
9567
|
+
.sidebar h2{font-size:1rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
|
|
9568
|
+
.sidebar nav{display:flex;flex-direction:column;gap:.25rem}
|
|
9569
|
+
.sidebar a{display:block;padding:.5rem .75rem;border-radius:6px;color:var(--text);font-size:.9rem;transition:background .15s}
|
|
9570
|
+
.sidebar a:hover{background:#f0f0ff;text-decoration:none}
|
|
9571
|
+
.sidebar a.active{background:#eef2ff;color:var(--primary);font-weight:600}
|
|
9572
|
+
.sidebar .section{margin-top:1.5rem}
|
|
9573
|
+
.main{flex:1;padding:2rem;max-width:1200px}
|
|
9574
|
+
.card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.5rem;margin-bottom:1.5rem}
|
|
9575
|
+
.btn{display:inline-block;padding:.5rem 1rem;border-radius:6px;font-size:.875rem;font-weight:500;border:none;cursor:pointer;transition:all .15s;text-decoration:none;line-height:1.4}
|
|
9576
|
+
.btn-primary{background:var(--primary);color:#fff}
|
|
9577
|
+
.btn-primary:hover{background:var(--primary-hover);text-decoration:none}
|
|
9578
|
+
.btn-danger{background:var(--danger);color:#fff}
|
|
9579
|
+
.btn-danger:hover{background:var(--danger-hover);text-decoration:none}
|
|
9580
|
+
.form-group{margin-bottom:1rem}
|
|
9581
|
+
.form-group label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.25rem}
|
|
9582
|
+
input[type=file]{padding:.5rem 0}
|
|
9583
|
+
.alert{padding:.75rem 1rem;border-radius:6px;margin-bottom:1rem;font-size:.9rem}
|
|
9584
|
+
.alert-success{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
|
|
9585
|
+
.alert-error{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
|
|
9586
|
+
.empty{text-align:center;padding:3rem 1rem;color:var(--text-muted)}
|
|
9587
|
+
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem}
|
|
9588
|
+
.media-item{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
|
|
9589
|
+
.media-item img{width:100%;height:140px;object-fit:cover;display:block}
|
|
9590
|
+
.media-item .meta{padding:.5rem;font-size:.8rem}
|
|
9591
|
+
.media-item .meta .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
9592
|
+
.media-item .meta .info{color:var(--text-muted);font-size:.75rem}
|
|
9593
|
+
`;
|
|
9594
|
+
return `<!DOCTYPE html>
|
|
9595
|
+
<html lang="en">
|
|
9596
|
+
<head>
|
|
9597
|
+
<meta charset="UTF-8">
|
|
9598
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
9599
|
+
<title>${esc2(title)} \u2014 CMS</title>
|
|
9600
|
+
<style>${ADMIN_CSS2}</style>
|
|
9601
|
+
</head>
|
|
9602
|
+
<body>
|
|
9603
|
+
<div class="sidebar">
|
|
9604
|
+
<h2>\u{1F4E6} CMS</h2>
|
|
9605
|
+
<nav>
|
|
9606
|
+
<a href="${base}/admin" class="${!activeNav || activeNav === "" ? "active" : ""}"> Dashboard</a>
|
|
9607
|
+
</nav>
|
|
9608
|
+
<nav class="section">
|
|
9609
|
+
<a href="${base}/admin/content-types" class="${activeNav?.startsWith("/admin/content-types") ? "active" : ""}"> Content Types</a>
|
|
9610
|
+
<a href="${base}/admin/media" class="active"> Media Library</a>
|
|
9611
|
+
</nav>
|
|
9612
|
+
</div>
|
|
9613
|
+
<div class="main">${content}</div>
|
|
9614
|
+
</body>
|
|
9615
|
+
</html>`;
|
|
9616
|
+
}
|
|
9617
|
+
|
|
9618
|
+
// cms/client.ts
|
|
9619
|
+
function cms(options) {
|
|
9620
|
+
const pg = options.pg;
|
|
9621
|
+
const sql2 = pg.sql;
|
|
9622
|
+
const base = new PgModule(pg);
|
|
9623
|
+
const mediaDir = options.mediaDir ?? "./cms-media";
|
|
9624
|
+
async function migrate() {
|
|
9625
|
+
await sql2.unsafe(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
|
|
9626
|
+
await sql2.unsafe(`
|
|
9627
|
+
CREATE TABLE IF NOT EXISTS "_cms_content_types" (
|
|
9628
|
+
"id" SERIAL PRIMARY KEY,
|
|
9629
|
+
"slug" TEXT NOT NULL UNIQUE,
|
|
9630
|
+
"label" TEXT NOT NULL,
|
|
9631
|
+
"description" TEXT DEFAULT '',
|
|
9632
|
+
"fields" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
9633
|
+
"config" JSONB DEFAULT '{}'::jsonb,
|
|
9634
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
9635
|
+
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
9636
|
+
)
|
|
9637
|
+
`);
|
|
9638
|
+
await sql2.unsafe(`
|
|
9639
|
+
CREATE TABLE IF NOT EXISTS "_cms_entries" (
|
|
9640
|
+
"id" SERIAL PRIMARY KEY,
|
|
9641
|
+
"content_type" TEXT NOT NULL REFERENCES "_cms_content_types"("slug") ON DELETE CASCADE,
|
|
9642
|
+
"slug" TEXT NOT NULL DEFAULT '',
|
|
9643
|
+
"title" TEXT NOT NULL DEFAULT '',
|
|
9644
|
+
"status" TEXT NOT NULL DEFAULT 'draft',
|
|
9645
|
+
"data" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
9646
|
+
"locale" TEXT DEFAULT NULL,
|
|
9647
|
+
"created_by" INTEGER DEFAULT NULL,
|
|
9648
|
+
"updated_by" INTEGER DEFAULT NULL,
|
|
9649
|
+
"published_at" TIMESTAMPTZ DEFAULT NULL,
|
|
9650
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
9651
|
+
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
9652
|
+
)
|
|
9653
|
+
`);
|
|
9654
|
+
await sql2.unsafe(`CREATE UNIQUE INDEX IF NOT EXISTS "_cms_entries_unique_idx" ON "_cms_entries" ("content_type", "slug")`);
|
|
9655
|
+
await sql2.unsafe(`CREATE INDEX IF NOT EXISTS "_cms_entries_content_type_idx" ON "_cms_entries" ("content_type")`);
|
|
9656
|
+
await sql2.unsafe(`CREATE INDEX IF NOT EXISTS "_cms_entries_status_idx" ON "_cms_entries" ("status")`);
|
|
9657
|
+
await sql2.unsafe(`CREATE INDEX IF NOT EXISTS "_cms_entries_data_gin_idx" ON "_cms_entries" USING GIN ("data" jsonb_path_ops)`);
|
|
9658
|
+
await sql2.unsafe(`
|
|
9659
|
+
CREATE TABLE IF NOT EXISTS "_cms_versions" (
|
|
9660
|
+
"id" SERIAL PRIMARY KEY,
|
|
9661
|
+
"entry_id" INTEGER NOT NULL REFERENCES "_cms_entries"("id") ON DELETE CASCADE,
|
|
9662
|
+
"version" INTEGER NOT NULL,
|
|
9663
|
+
"data" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
9664
|
+
"created_by" INTEGER DEFAULT NULL,
|
|
9665
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
9666
|
+
UNIQUE("entry_id", "version")
|
|
9667
|
+
)
|
|
9668
|
+
`);
|
|
9669
|
+
await sql2.unsafe(`
|
|
9670
|
+
CREATE INDEX IF NOT EXISTS "_cms_versions_entry_idx" ON "_cms_versions" ("entry_id")
|
|
9671
|
+
`);
|
|
9672
|
+
await createMediaTable(sql2);
|
|
9673
|
+
await sql2.unsafe(`
|
|
9674
|
+
CREATE TABLE IF NOT EXISTS "_cms_webhooks" (
|
|
9675
|
+
"id" SERIAL PRIMARY KEY,
|
|
9676
|
+
"name" TEXT NOT NULL,
|
|
9677
|
+
"url" TEXT NOT NULL,
|
|
9678
|
+
"events" TEXT[] NOT NULL DEFAULT '{}',
|
|
9679
|
+
"secret" TEXT DEFAULT '',
|
|
9680
|
+
"active" BOOLEAN NOT NULL DEFAULT true,
|
|
9681
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
9682
|
+
)
|
|
9683
|
+
`);
|
|
9684
|
+
await sql2.unsafe(`
|
|
9685
|
+
CREATE TABLE IF NOT EXISTS "_cms_redirects" (
|
|
9686
|
+
"id" SERIAL PRIMARY KEY,
|
|
9687
|
+
"from_path" TEXT NOT NULL UNIQUE,
|
|
9688
|
+
"to_path" TEXT NOT NULL,
|
|
9689
|
+
"type" INTEGER NOT NULL DEFAULT 301,
|
|
9690
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
9691
|
+
)
|
|
9692
|
+
`);
|
|
9693
|
+
}
|
|
9694
|
+
const r = new Router();
|
|
9695
|
+
registerAdminRoutes(r, sql2);
|
|
9696
|
+
registerApiRoutes(r, sql2);
|
|
9697
|
+
registerMediaRoutes(r, sql2, mediaDir);
|
|
9698
|
+
const mod = r;
|
|
9699
|
+
mod.migrate = migrate;
|
|
9700
|
+
mod.close = () => base.close();
|
|
9701
|
+
return mod;
|
|
9702
|
+
}
|
|
8470
9703
|
export {
|
|
8471
9704
|
Router,
|
|
8472
9705
|
TsxContext,
|
|
@@ -8475,6 +9708,7 @@ export {
|
|
|
8475
9708
|
analytics,
|
|
8476
9709
|
auth,
|
|
8477
9710
|
clearCompileCache,
|
|
9711
|
+
cms,
|
|
8478
9712
|
compress,
|
|
8479
9713
|
cors,
|
|
8480
9714
|
createHub,
|
|
@@ -8498,6 +9732,7 @@ export {
|
|
|
8498
9732
|
health,
|
|
8499
9733
|
helmet,
|
|
8500
9734
|
iii,
|
|
9735
|
+
isDev,
|
|
8501
9736
|
layout,
|
|
8502
9737
|
loadEnv,
|
|
8503
9738
|
logdb,
|