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/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 h = node.handlers.get("*") || node.handlers.get(method);
456
- if (h) {
457
- wildcardHandler = h;
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 h = new Headers(res.headers);
1266
+ const h2 = new Headers(res.headers);
1264
1267
  for (const [k, v] of headers) {
1265
- if (!h.has(k)) h.set(k, v);
1268
+ if (!h2.has(k)) h2.set(k, v);
1266
1269
  }
1267
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
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 h = new Headers(res.headers);
1283
- h.set(header, id3);
1284
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
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 join9(key, ws) {
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: join9, leave, broadcast, close };
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 h = candidate.getHours();
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(h) && fields[0].has(min)) {
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 stop() {
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 process.env.NODE_ENV !== "production" ? compileTsxDev(path2) : compileTsx(path2);
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 h = id(absPath);
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: h, code };
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: isDev2 } = opts;
5218
+ const { ctx, compiledTailwindCss, isDev: isDev3 } = opts;
5216
5219
  const rb = opts.rootBase || "";
5217
5220
  let result = "";
5218
- if (isDev2) {
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
- `${isDev ? "import{createRoot}from'react-dom/client';" : "import{hydrateRoot}from'react-dom/client';"}`,
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
- isDev ? `const _W=function(props){return(_W._fn||P)(props)};_W._fn=P;const _P=function(props){return createElement(_W,props)};` : "",
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
- isDev ? `window.__WFW_ENTRY=${JSON.stringify(id2(absEntry))};window.__WFW_REFRESH=function(n){_W._fn=n;window.__WFW_ROOT.render(createElement(App))};` : "",
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
- isDev ? `createElement(_P,null))` : `createElement(P,null))`,
5398
+ isDev2 ? `createElement(_P,null))` : `createElement(P,null))`,
5396
5399
  `}`,
5397
- isDev ? `window.__WFW_ROOT=createRoot(c);window.__WFW_ROOT.render(createElement(App));` : `hydrateRoot(c,createElement(App));`
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: isDev ? ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "weifuwu", "weifuwu/react"] : void 0,
5411
+ external: isDev2 ? ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "weifuwu", "weifuwu/react"] : void 0,
5409
5412
  write: false,
5410
- minify: !isDev
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 (isDev2) {
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 h = new Headers(res.headers);
7094
- h.set("X-Robots-Tag", robotTag);
7095
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
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: process.env.NODE_ENV !== "production",
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: process.env.NODE_ENV !== "production",
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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,