weifuwu 0.19.7 → 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.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 +5 -1
- package/dist/index.js +1318 -56
- package/dist/react.js +1 -1
- package/dist/ssr-entries.d.ts +4 -0
- package/package.json +3 -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({
|
|
@@ -5192,10 +5195,10 @@ async function compileHotComponent(path2) {
|
|
|
5192
5195
|
});
|
|
5193
5196
|
let code = new TextDecoder().decode(result.outputFiles[0].contents);
|
|
5194
5197
|
if (code.includes("__require") && (code.includes('"react"') || code.includes("'react'"))) {
|
|
5195
|
-
code = `import __r from '
|
|
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": {
|
|
@@ -5233,8 +5236,8 @@ function buildHeadPayload(opts) {
|
|
|
5233
5236
|
`;
|
|
5234
5237
|
}
|
|
5235
5238
|
if (compiledTailwindCss) {
|
|
5236
|
-
const cssUrl = ctx.tailwindCssUrl
|
|
5237
|
-
result += `<link rel="stylesheet" href="${cssUrl}" />
|
|
5239
|
+
const cssUrl = ctx.tailwindCssUrl;
|
|
5240
|
+
if (cssUrl) result += `<link rel="stylesheet" href="${cssUrl}" />
|
|
5238
5241
|
`;
|
|
5239
5242
|
}
|
|
5240
5243
|
const localeData = ctx.parsed?.__localeData ?? globalThis.__LOCALE_DATA__;
|
|
@@ -5337,10 +5340,13 @@ function streamResponse(reactStream, opts) {
|
|
|
5337
5340
|
});
|
|
5338
5341
|
}
|
|
5339
5342
|
|
|
5343
|
+
// ssr-entries.ts
|
|
5344
|
+
var ssrEntries = /* @__PURE__ */ new Map();
|
|
5345
|
+
|
|
5340
5346
|
// ssr.ts
|
|
5347
|
+
var isDev2 = isDev();
|
|
5341
5348
|
var als = new AsyncLocalStorage();
|
|
5342
5349
|
__registerAls(() => als.getStore());
|
|
5343
|
-
var isDev = process.env.NODE_ENV !== "production";
|
|
5344
5350
|
var bundleCache = /* @__PURE__ */ new Map();
|
|
5345
5351
|
var _bundleDirty = false;
|
|
5346
5352
|
function markClientBundleDirty() {
|
|
@@ -5375,7 +5381,7 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5375
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()})}})()`;
|
|
5376
5382
|
const code = [
|
|
5377
5383
|
layoutImports,
|
|
5378
|
-
`${
|
|
5384
|
+
`${isDev2 ? "import{createRoot}from'react-dom/client';" : "import{hydrateRoot}from'react-dom/client';"}`,
|
|
5379
5385
|
`import{createElement}from'react';`,
|
|
5380
5386
|
`import{TsxContext}from'weifuwu/react';`,
|
|
5381
5387
|
`import P from${JSON.stringify(absEntry)};`,
|
|
@@ -5383,15 +5389,15 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5383
5389
|
`const c=document.getElementById('__weifuwu_root');`,
|
|
5384
5390
|
`if(window.__WEIFUWU_PROPS)setCtx({loaderData:window.__WEIFUWU_PROPS});`,
|
|
5385
5391
|
// Dev: stable proxy chain — _P → _W (stable) → actual component
|
|
5386
|
-
|
|
5392
|
+
isDev2 ? `const _W=function(props){return(_W._fn||P)(props)};_W._fn=P;const _P=function(props){return createElement(_W,props)};` : "",
|
|
5387
5393
|
// Dev: HMR handler — updates proxy + re-renders root
|
|
5388
|
-
|
|
5394
|
+
isDev2 ? `window.__WFW_ENTRY=${JSON.stringify(id2(absEntry))};window.__WFW_REFRESH=function(n){_W._fn=n;window.__WFW_ROOT.render(createElement(App))};` : "",
|
|
5389
5395
|
`function App(){`,
|
|
5390
5396
|
`const ctx=window.__WEIFUWU_CTX||{};`,
|
|
5391
5397
|
`return createElement(TsxContext.Provider,{value:ctx},`,
|
|
5392
|
-
|
|
5398
|
+
isDev2 ? `createElement(_P,null))` : `createElement(P,null))`,
|
|
5393
5399
|
`}`,
|
|
5394
|
-
|
|
5400
|
+
isDev2 ? `window.__WFW_ROOT=createRoot(c);window.__WFW_ROOT.render(createElement(App));` : `hydrateRoot(c,createElement(App));`
|
|
5395
5401
|
].filter(Boolean).join("");
|
|
5396
5402
|
const { default: esbuild2 } = await import("esbuild");
|
|
5397
5403
|
const result = await esbuild2.build({
|
|
@@ -5402,9 +5408,9 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5402
5408
|
jsxImportSource: "react",
|
|
5403
5409
|
banner: { js: "self.process={env:{}};" },
|
|
5404
5410
|
loader: { ".node": "empty" },
|
|
5405
|
-
external:
|
|
5411
|
+
external: isDev2 ? ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "weifuwu", "weifuwu/react"] : void 0,
|
|
5406
5412
|
write: false,
|
|
5407
|
-
minify: !
|
|
5413
|
+
minify: !isDev2
|
|
5408
5414
|
});
|
|
5409
5415
|
return result.outputFiles[0].contents;
|
|
5410
5416
|
} catch (err) {
|
|
@@ -5413,7 +5419,9 @@ async function buildClientBundle(entryPath, layoutPaths) {
|
|
|
5413
5419
|
}
|
|
5414
5420
|
}
|
|
5415
5421
|
function ssr(path2) {
|
|
5416
|
-
const
|
|
5422
|
+
const absPath = resolve4(path2);
|
|
5423
|
+
const entryId = id2(absPath);
|
|
5424
|
+
ssrEntries.set(entryId, { path: absPath });
|
|
5417
5425
|
const bundleKey = `/__ssr/${entryId}.js`;
|
|
5418
5426
|
const r = new Router();
|
|
5419
5427
|
r.get("/__ssr/:path", (req, ctx) => {
|
|
@@ -5487,7 +5495,7 @@ function ssr(path2) {
|
|
|
5487
5495
|
ctx,
|
|
5488
5496
|
base,
|
|
5489
5497
|
rootBase: ctx.rootLayoutBase || "",
|
|
5490
|
-
isDev,
|
|
5498
|
+
isDev: isDev2,
|
|
5491
5499
|
bundle,
|
|
5492
5500
|
loaderData,
|
|
5493
5501
|
compiledTailwindCss: ctx.compiledTailwindCss
|
|
@@ -5562,7 +5570,7 @@ ${src}`;
|
|
|
5562
5570
|
// live.ts
|
|
5563
5571
|
import chokidar from "chokidar";
|
|
5564
5572
|
import { existsSync as existsSync4 } from "node:fs";
|
|
5565
|
-
import { join as join4, resolve as resolve6 } from "node:path";
|
|
5573
|
+
import { dirname as dirname3, join as join4, resolve as resolve6 } from "node:path";
|
|
5566
5574
|
var clients = /* @__PURE__ */ new Set();
|
|
5567
5575
|
var hotBundleCache = /* @__PURE__ */ new Map();
|
|
5568
5576
|
var hotKeys = [];
|
|
@@ -5625,6 +5633,24 @@ function liveReload(dir) {
|
|
|
5625
5633
|
ignored: /(^|[/\\])\.|node_modules|[/\\]\.weifuwu[/\\]/,
|
|
5626
5634
|
ignoreInitial: true
|
|
5627
5635
|
});
|
|
5636
|
+
function findEntries(changedPath) {
|
|
5637
|
+
const matched = [];
|
|
5638
|
+
for (const [, entry] of ssrEntries) {
|
|
5639
|
+
if (!entry.path.startsWith(resolved)) continue;
|
|
5640
|
+
if (entry.path === changedPath) {
|
|
5641
|
+
matched.push(entry.path);
|
|
5642
|
+
} else {
|
|
5643
|
+
const ed = dirname3(entry.path);
|
|
5644
|
+
if (changedPath.startsWith(ed)) matched.push(entry.path);
|
|
5645
|
+
}
|
|
5646
|
+
}
|
|
5647
|
+
if (matched.length === 0) {
|
|
5648
|
+
for (const [, entry] of ssrEntries) {
|
|
5649
|
+
if (entry.path.startsWith(resolved)) matched.push(entry.path);
|
|
5650
|
+
}
|
|
5651
|
+
}
|
|
5652
|
+
return matched;
|
|
5653
|
+
}
|
|
5628
5654
|
watcher.on("change", async (filePath) => {
|
|
5629
5655
|
if (/\.tsx?$/i.test(filePath)) {
|
|
5630
5656
|
if (filePath.endsWith("layout.tsx")) {
|
|
@@ -5632,25 +5658,28 @@ function liveReload(dir) {
|
|
|
5632
5658
|
}
|
|
5633
5659
|
clearCompileCache();
|
|
5634
5660
|
markClientBundleDirty();
|
|
5661
|
+
const targets = existsSync4(entryPath) ? [entryPath] : findEntries(resolve6(filePath));
|
|
5662
|
+
if (targets.length === 0) return broadcastReload();
|
|
5635
5663
|
try {
|
|
5636
|
-
const target = existsSync4(entryPath) ? entryPath : filePath;
|
|
5637
|
-
await compileTsxDev(target);
|
|
5638
|
-
const { hash, code } = await compileHotComponent(target);
|
|
5639
|
-
setHot(hash, code);
|
|
5640
5664
|
let css;
|
|
5641
5665
|
const cssPath = join4(resolved, "app.css");
|
|
5642
5666
|
if (existsSync4(cssPath)) {
|
|
5643
5667
|
css = await compileTailwindCss(cssPath, resolved);
|
|
5644
5668
|
}
|
|
5645
|
-
const
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5669
|
+
for (const target of targets) {
|
|
5670
|
+
await compileTsxDev(target);
|
|
5671
|
+
const { hash, code } = await compileHotComponent(target);
|
|
5672
|
+
setHot(hash, code);
|
|
5673
|
+
const entry = id(target);
|
|
5674
|
+
const msg = { type: "component", hash, entry };
|
|
5675
|
+
if (css) msg.css = css;
|
|
5676
|
+
const str = JSON.stringify(msg);
|
|
5677
|
+
for (const ws of clients) {
|
|
5678
|
+
try {
|
|
5679
|
+
ws.send(str);
|
|
5680
|
+
} catch {
|
|
5681
|
+
clients.delete(ws);
|
|
5682
|
+
}
|
|
5654
5683
|
}
|
|
5655
5684
|
}
|
|
5656
5685
|
} catch (e) {
|
|
@@ -5676,7 +5705,6 @@ function liveReload(dir) {
|
|
|
5676
5705
|
function rootLayout(dir) {
|
|
5677
5706
|
const r = new Router();
|
|
5678
5707
|
const resolved = resolve7(dir);
|
|
5679
|
-
const isDev2 = process.env.NODE_ENV !== "production";
|
|
5680
5708
|
const layoutPath = join5(resolved, "layout.tsx");
|
|
5681
5709
|
r.use(async (req, ctx, next) => {
|
|
5682
5710
|
const mod = await compile(layoutPath);
|
|
@@ -5687,7 +5715,7 @@ function rootLayout(dir) {
|
|
|
5687
5715
|
if (existsSync5(join5(resolved, "app.css"))) {
|
|
5688
5716
|
r.use(tailwind(resolved));
|
|
5689
5717
|
}
|
|
5690
|
-
if (
|
|
5718
|
+
if (isDev()) {
|
|
5691
5719
|
const lr = liveReload(resolved);
|
|
5692
5720
|
r.use(lr);
|
|
5693
5721
|
r.close = lr.close;
|
|
@@ -5993,7 +6021,7 @@ function createReadTool(ctx) {
|
|
|
5993
6021
|
import { tool as tool5 } from "ai";
|
|
5994
6022
|
import { z as z7 } from "zod";
|
|
5995
6023
|
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "node:fs";
|
|
5996
|
-
import { resolve as resolve9, dirname as
|
|
6024
|
+
import { resolve as resolve9, dirname as dirname4 } from "node:path";
|
|
5997
6025
|
function createWriteTool(ctx) {
|
|
5998
6026
|
return tool5({
|
|
5999
6027
|
description: "Create or overwrite a file. Parent directories are created automatically.",
|
|
@@ -6006,7 +6034,7 @@ function createWriteTool(ctx) {
|
|
|
6006
6034
|
if (!isPathAllowed(resolved, ctx.workspace, ctx.permissions)) {
|
|
6007
6035
|
return { error: "Path not allowed" };
|
|
6008
6036
|
}
|
|
6009
|
-
mkdirSync3(
|
|
6037
|
+
mkdirSync3(dirname4(resolved), { recursive: true });
|
|
6010
6038
|
writeFileSync2(resolved, content, "utf-8");
|
|
6011
6039
|
return { path: path2, size: content.length };
|
|
6012
6040
|
}
|
|
@@ -7064,9 +7092,9 @@ function seoMiddleware(options) {
|
|
|
7064
7092
|
const url = new URL(req.url);
|
|
7065
7093
|
const robotTag = getRobotsHeader(headers, url.pathname);
|
|
7066
7094
|
if (robotTag) {
|
|
7067
|
-
const
|
|
7068
|
-
|
|
7069
|
-
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 });
|
|
7070
7098
|
}
|
|
7071
7099
|
return res;
|
|
7072
7100
|
};
|
|
@@ -8387,7 +8415,7 @@ function notFound(path2) {
|
|
|
8387
8415
|
return streamResponse(stream, {
|
|
8388
8416
|
ctx,
|
|
8389
8417
|
base,
|
|
8390
|
-
isDev:
|
|
8418
|
+
isDev: isDev(),
|
|
8391
8419
|
compiledTailwindCss: ctx.compiledTailwindCss,
|
|
8392
8420
|
status: 404
|
|
8393
8421
|
});
|
|
@@ -8434,13 +8462,1244 @@ function errorBoundary(errorPath) {
|
|
|
8434
8462
|
return streamResponse(stream, {
|
|
8435
8463
|
ctx,
|
|
8436
8464
|
base,
|
|
8437
|
-
isDev:
|
|
8465
|
+
isDev: isDev(),
|
|
8438
8466
|
compiledTailwindCss: ctx.compiledTailwindCss,
|
|
8439
8467
|
status: 500
|
|
8440
8468
|
});
|
|
8441
8469
|
}
|
|
8442
8470
|
};
|
|
8443
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
|
+
}
|
|
8444
9703
|
export {
|
|
8445
9704
|
Router,
|
|
8446
9705
|
TsxContext,
|
|
@@ -8449,6 +9708,7 @@ export {
|
|
|
8449
9708
|
analytics,
|
|
8450
9709
|
auth,
|
|
8451
9710
|
clearCompileCache,
|
|
9711
|
+
cms,
|
|
8452
9712
|
compress,
|
|
8453
9713
|
cors,
|
|
8454
9714
|
createHub,
|
|
@@ -8472,6 +9732,7 @@ export {
|
|
|
8472
9732
|
health,
|
|
8473
9733
|
helmet,
|
|
8474
9734
|
iii,
|
|
9735
|
+
isDev,
|
|
8475
9736
|
layout,
|
|
8476
9737
|
loadEnv,
|
|
8477
9738
|
logdb,
|
|
@@ -8498,6 +9759,7 @@ export {
|
|
|
8498
9759
|
setCookie,
|
|
8499
9760
|
smoothStream,
|
|
8500
9761
|
ssr,
|
|
9762
|
+
ssrEntries,
|
|
8501
9763
|
streamObject,
|
|
8502
9764
|
streamText,
|
|
8503
9765
|
tenant,
|