@sonicjs-cms/core 2.4.0 → 2.6.0

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.
Files changed (98) hide show
  1. package/dist/{app-Db0AfT5F.d.cts → app-DV27cjPy.d.cts} +1 -1
  2. package/dist/{app-Db0AfT5F.d.ts → app-DV27cjPy.d.ts} +1 -1
  3. package/dist/{chunk-YIXSSJWD.cjs → chunk-63K7XXRX.cjs} +5 -5
  4. package/dist/{chunk-YIXSSJWD.cjs.map → chunk-63K7XXRX.cjs.map} +1 -1
  5. package/dist/{chunk-VNCYCH3H.js → chunk-7DL5SPPX.js} +59 -5
  6. package/dist/chunk-7DL5SPPX.js.map +1 -0
  7. package/dist/{chunk-AZLU3ROK.cjs → chunk-BZC4FYW7.cjs} +4 -4
  8. package/dist/chunk-BZC4FYW7.cjs.map +1 -0
  9. package/dist/chunk-CLIH2T74.js +403 -0
  10. package/dist/chunk-CLIH2T74.js.map +1 -0
  11. package/dist/{chunk-D2NLCPO2.js → chunk-EVZOVYLO.js} +53 -2
  12. package/dist/chunk-EVZOVYLO.js.map +1 -0
  13. package/dist/{chunk-DXM575E2.js → chunk-EYWR6UA2.js} +6 -6
  14. package/dist/chunk-EYWR6UA2.js.map +1 -0
  15. package/dist/{chunk-CPXAVWCU.js → chunk-F332TENF.js} +278 -3
  16. package/dist/chunk-F332TENF.js.map +1 -0
  17. package/dist/{chunk-FT6NBHNX.js → chunk-F6GZURXJ.js} +2536 -600
  18. package/dist/chunk-F6GZURXJ.js.map +1 -0
  19. package/dist/{chunk-2MI3LZFH.cjs → chunk-IIRVZSP2.cjs} +53 -2
  20. package/dist/chunk-IIRVZSP2.cjs.map +1 -0
  21. package/dist/{chunk-V5LBQN3I.js → chunk-KA2PDJNB.js} +4 -4
  22. package/dist/chunk-KA2PDJNB.js.map +1 -0
  23. package/dist/{chunk-AVPUX57O.js → chunk-KAOWRIFD.js} +3 -3
  24. package/dist/{chunk-AVPUX57O.js.map → chunk-KAOWRIFD.js.map} +1 -1
  25. package/dist/{chunk-ILZ3DP4I.cjs → chunk-MPT5PA6U.cjs} +24 -2
  26. package/dist/chunk-MPT5PA6U.cjs.map +1 -0
  27. package/dist/{chunk-A4SVOGG6.cjs → chunk-N7TDLOUE.cjs} +2696 -762
  28. package/dist/chunk-N7TDLOUE.cjs.map +1 -0
  29. package/dist/{chunk-7I5INVNR.cjs → chunk-T3YIKW2A.cjs} +9 -9
  30. package/dist/chunk-T3YIKW2A.cjs.map +1 -0
  31. package/dist/{chunk-DTLB6UIH.cjs → chunk-Y72M3MVX.cjs} +280 -2
  32. package/dist/chunk-Y72M3MVX.cjs.map +1 -0
  33. package/dist/{chunk-SGAG6FD3.js → chunk-YFJJU26H.js} +24 -2
  34. package/dist/chunk-YFJJU26H.js.map +1 -0
  35. package/dist/chunk-YHW27CBV.cjs +406 -0
  36. package/dist/chunk-YHW27CBV.cjs.map +1 -0
  37. package/dist/{chunk-FYEDK7K7.cjs → chunk-YMTTGHEK.cjs} +61 -4
  38. package/dist/chunk-YMTTGHEK.cjs.map +1 -0
  39. package/dist/{collection-config-FLlGtsh9.d.cts → collection-config-BF95LgQb.d.cts} +10 -2
  40. package/dist/{collection-config-FLlGtsh9.d.ts → collection-config-BF95LgQb.d.ts} +10 -2
  41. package/dist/index.cjs +2001 -142
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.cts +504 -9
  44. package/dist/index.d.ts +504 -9
  45. package/dist/index.js +1893 -41
  46. package/dist/index.js.map +1 -1
  47. package/dist/middleware.cjs +24 -24
  48. package/dist/middleware.d.cts +1 -1
  49. package/dist/middleware.d.ts +1 -1
  50. package/dist/middleware.js +3 -3
  51. package/dist/migrations-QNYAWQLB.cjs +13 -0
  52. package/dist/{migrations-32QAYLWJ.cjs.map → migrations-QNYAWQLB.cjs.map} +1 -1
  53. package/dist/migrations-R6NQBKQV.js +4 -0
  54. package/dist/{migrations-57ZHBQ4X.js.map → migrations-R6NQBKQV.js.map} +1 -1
  55. package/dist/{plugin-bootstrap-CDh0JHtW.d.ts → plugin-bootstrap-CB-xaBfK.d.ts} +2 -2
  56. package/dist/{plugin-bootstrap-C0E3jdz-.d.cts → plugin-bootstrap-U-cw9jn3.d.cts} +2 -2
  57. package/dist/plugin-manager-Baa6xXqB.d.ts +328 -0
  58. package/dist/plugin-manager-vBal9Zip.d.cts +328 -0
  59. package/dist/plugins.cjs +20 -7
  60. package/dist/plugins.d.cts +53 -310
  61. package/dist/plugins.d.ts +53 -310
  62. package/dist/plugins.js +2 -1
  63. package/dist/routes.cjs +27 -26
  64. package/dist/routes.d.cts +1 -1
  65. package/dist/routes.d.ts +1 -1
  66. package/dist/routes.js +7 -6
  67. package/dist/services.cjs +16 -16
  68. package/dist/services.d.cts +2 -2
  69. package/dist/services.d.ts +2 -2
  70. package/dist/services.js +2 -2
  71. package/dist/templates.cjs +17 -17
  72. package/dist/templates.js +2 -2
  73. package/dist/types.d.cts +1 -1
  74. package/dist/types.d.ts +1 -1
  75. package/dist/utils.cjs +23 -11
  76. package/dist/utils.d.cts +38 -1
  77. package/dist/utils.d.ts +38 -1
  78. package/dist/utils.js +1 -1
  79. package/migrations/027_fix_slug_field_type.sql +18 -0
  80. package/migrations/028_fix_slug_field_type_in_schemas.sql +30 -0
  81. package/migrations/029_ai_search_plugin.sql +45 -0
  82. package/package.json +5 -2
  83. package/dist/chunk-2MI3LZFH.cjs.map +0 -1
  84. package/dist/chunk-7I5INVNR.cjs.map +0 -1
  85. package/dist/chunk-A4SVOGG6.cjs.map +0 -1
  86. package/dist/chunk-AZLU3ROK.cjs.map +0 -1
  87. package/dist/chunk-CPXAVWCU.js.map +0 -1
  88. package/dist/chunk-D2NLCPO2.js.map +0 -1
  89. package/dist/chunk-DTLB6UIH.cjs.map +0 -1
  90. package/dist/chunk-DXM575E2.js.map +0 -1
  91. package/dist/chunk-FT6NBHNX.js.map +0 -1
  92. package/dist/chunk-FYEDK7K7.cjs.map +0 -1
  93. package/dist/chunk-ILZ3DP4I.cjs.map +0 -1
  94. package/dist/chunk-SGAG6FD3.js.map +0 -1
  95. package/dist/chunk-V5LBQN3I.js.map +0 -1
  96. package/dist/chunk-VNCYCH3H.js.map +0 -1
  97. package/dist/migrations-32QAYLWJ.cjs +0 -13
  98. package/dist/migrations-57ZHBQ4X.js +0 -4
@@ -1,9 +1,10 @@
1
1
  import { getCacheService, CACHE_CONFIGS, getLogger, SettingsService } from './chunk-3YNNVSMC.js';
2
- import { requireAuth, isPluginActive, requireRole, AuthManager, logActivity } from './chunk-DXM575E2.js';
3
- import { PluginService } from './chunk-SGAG6FD3.js';
4
- import { MigrationService } from './chunk-D2NLCPO2.js';
5
- import { init_admin_layout_catalyst_template, renderDesignPage, renderCheckboxPage, renderTestimonialsList, renderCodeExamplesList, renderAlert, renderTable, renderPagination, renderConfirmationDialog, getConfirmationDialogScript, renderAdminLayoutCatalyst, renderAdminLayout, adminLayoutV2, renderForm } from './chunk-V5LBQN3I.js';
6
- import { QueryFilterBuilder, sanitizeInput, getCoreVersion, escapeHtml } from './chunk-VNCYCH3H.js';
2
+ import { requireAuth, isPluginActive, requireRole, AuthManager, logActivity } from './chunk-EYWR6UA2.js';
3
+ import { PluginService } from './chunk-YFJJU26H.js';
4
+ import { MigrationService } from './chunk-EVZOVYLO.js';
5
+ import { init_admin_layout_catalyst_template, renderDesignPage, renderCheckboxPage, renderTestimonialsList, renderCodeExamplesList, renderAlert, renderTable, renderPagination, renderConfirmationDialog, getConfirmationDialogScript, renderAdminLayoutCatalyst, renderAdminLayout, adminLayoutV2, renderForm } from './chunk-KA2PDJNB.js';
6
+ import { PluginBuilder } from './chunk-CLIH2T74.js';
7
+ import { QueryFilterBuilder, sanitizeInput, getCoreVersion, escapeHtml, getBlocksFieldConfig, parseBlocksValue } from './chunk-7DL5SPPX.js';
7
8
  import { metricsTracker } from './chunk-FICTAGD4.js';
8
9
  import { Hono } from 'hono';
9
10
  import { cors } from 'hono/cors';
@@ -14,6 +15,37 @@ import { html, raw } from 'hono/html';
14
15
  // src/schemas/index.ts
15
16
  var schemaDefinitions = [];
16
17
  var apiContentCrudRoutes = new Hono();
18
+ apiContentCrudRoutes.get("/check-slug", async (c) => {
19
+ try {
20
+ const db = c.env.DB;
21
+ const collectionId = c.req.query("collectionId");
22
+ const slug = c.req.query("slug");
23
+ const excludeId = c.req.query("excludeId");
24
+ if (!collectionId || !slug) {
25
+ return c.json({ error: "collectionId and slug are required" }, 400);
26
+ }
27
+ let query = "SELECT id FROM content WHERE collection_id = ? AND slug = ?";
28
+ const params = [collectionId, slug];
29
+ if (excludeId) {
30
+ query += " AND id != ?";
31
+ params.push(excludeId);
32
+ }
33
+ const existing = await db.prepare(query).bind(...params).first();
34
+ if (existing) {
35
+ return c.json({
36
+ available: false,
37
+ message: "This URL slug is already in use in this collection"
38
+ });
39
+ }
40
+ return c.json({ available: true });
41
+ } catch (error) {
42
+ console.error("Error checking slug:", error);
43
+ return c.json({
44
+ error: "Failed to check slug availability",
45
+ details: error instanceof Error ? error.message : String(error)
46
+ }, 500);
47
+ }
48
+ });
17
49
  apiContentCrudRoutes.get("/:id", async (c) => {
18
50
  try {
19
51
  const id = c.req.param("id");
@@ -1551,6 +1583,107 @@ adminApiRoutes.get("/collections/:id", async (c) => {
1551
1583
  return c.json({ error: "Failed to fetch collection" }, 500);
1552
1584
  }
1553
1585
  });
1586
+ adminApiRoutes.get("/references", async (c) => {
1587
+ try {
1588
+ const db = c.env.DB;
1589
+ const url = new URL(c.req.url);
1590
+ const collectionParams = url.searchParams.getAll("collection").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
1591
+ const search = c.req.query("search") || "";
1592
+ const id = c.req.query("id") || "";
1593
+ const limit = Math.min(Number.parseInt(c.req.query("limit") || "20", 10) || 20, 100);
1594
+ if (collectionParams.length === 0) {
1595
+ return c.json({ error: "Collection is required" }, 400);
1596
+ }
1597
+ const placeholders = collectionParams.map(() => "?").join(", ");
1598
+ const collectionStmt = db.prepare(`
1599
+ SELECT id, name, display_name
1600
+ FROM collections
1601
+ WHERE id IN (${placeholders}) OR name IN (${placeholders})
1602
+ `);
1603
+ const collectionResults = await collectionStmt.bind(...collectionParams, ...collectionParams).all();
1604
+ const collections = collectionResults.results || [];
1605
+ if (collections.length === 0) {
1606
+ return c.json({ error: "Collection not found" }, 404);
1607
+ }
1608
+ const collectionById = Object.fromEntries(
1609
+ collections.map((entry) => [
1610
+ entry.id,
1611
+ {
1612
+ id: entry.id,
1613
+ name: entry.name,
1614
+ display_name: entry.display_name
1615
+ }
1616
+ ])
1617
+ );
1618
+ const collectionIds = collections.map((entry) => entry.id);
1619
+ if (id) {
1620
+ const idPlaceholders = collectionIds.map(() => "?").join(", ");
1621
+ const itemStmt = db.prepare(`
1622
+ SELECT id, title, slug, collection_id
1623
+ FROM content
1624
+ WHERE id = ? AND collection_id IN (${idPlaceholders})
1625
+ LIMIT 1
1626
+ `);
1627
+ const item = await itemStmt.bind(id, ...collectionIds).first();
1628
+ if (!item) {
1629
+ return c.json({ error: "Reference not found" }, 404);
1630
+ }
1631
+ return c.json({
1632
+ data: {
1633
+ id: item.id,
1634
+ title: item.title,
1635
+ slug: item.slug,
1636
+ collection: collectionById[item.collection_id]
1637
+ }
1638
+ });
1639
+ }
1640
+ let stmt;
1641
+ let results;
1642
+ const listPlaceholders = collectionIds.map(() => "?").join(", ");
1643
+ const statusFilterValues = ["published"];
1644
+ const statusClause = ` AND status IN (${statusFilterValues.map(() => "?").join(", ")})`;
1645
+ if (search) {
1646
+ const searchParam = `%${search}%`;
1647
+ stmt = db.prepare(`
1648
+ SELECT id, title, slug, status, updated_at, collection_id
1649
+ FROM content
1650
+ WHERE collection_id IN (${listPlaceholders})
1651
+ AND (title LIKE ? OR slug LIKE ?)
1652
+ ${statusClause}
1653
+ ORDER BY updated_at DESC
1654
+ LIMIT ?
1655
+ `);
1656
+ const queryResults = await stmt.bind(...collectionIds, searchParam, searchParam, ...statusFilterValues, limit).all();
1657
+ results = queryResults.results;
1658
+ } else {
1659
+ stmt = db.prepare(`
1660
+ SELECT id, title, slug, status, updated_at, collection_id
1661
+ FROM content
1662
+ WHERE collection_id IN (${listPlaceholders})
1663
+ ${statusClause}
1664
+ ORDER BY updated_at DESC
1665
+ LIMIT ?
1666
+ `);
1667
+ const queryResults = await stmt.bind(...collectionIds, ...statusFilterValues, limit).all();
1668
+ results = queryResults.results;
1669
+ }
1670
+ const items = (results || []).map((row) => ({
1671
+ id: row.id,
1672
+ title: row.title,
1673
+ slug: row.slug,
1674
+ status: row.status,
1675
+ updated_at: row.updated_at ? Number(row.updated_at) : null,
1676
+ collection: collectionById[row.collection_id]
1677
+ }));
1678
+ return c.json({
1679
+ data: items,
1680
+ count: items.length
1681
+ });
1682
+ } catch (error) {
1683
+ console.error("Error fetching reference options:", error);
1684
+ return c.json({ error: "Failed to fetch references" }, 500);
1685
+ }
1686
+ });
1554
1687
  adminApiRoutes.post("/collections", async (c) => {
1555
1688
  try {
1556
1689
  const contentType = c.req.header("Content-Type");
@@ -1720,7 +1853,7 @@ adminApiRoutes.delete("/collections/:id", async (c) => {
1720
1853
  });
1721
1854
  adminApiRoutes.get("/migrations/status", async (c) => {
1722
1855
  try {
1723
- const { MigrationService: MigrationService2 } = await import('./migrations-57ZHBQ4X.js');
1856
+ const { MigrationService: MigrationService2 } = await import('./migrations-R6NQBKQV.js');
1724
1857
  const db = c.env.DB;
1725
1858
  const migrationService = new MigrationService2(db);
1726
1859
  const status = await migrationService.getMigrationStatus();
@@ -1745,7 +1878,7 @@ adminApiRoutes.post("/migrations/run", async (c) => {
1745
1878
  error: "Unauthorized. Admin access required."
1746
1879
  }, 403);
1747
1880
  }
1748
- const { MigrationService: MigrationService2 } = await import('./migrations-57ZHBQ4X.js');
1881
+ const { MigrationService: MigrationService2 } = await import('./migrations-R6NQBKQV.js');
1749
1882
  const db = c.env.DB;
1750
1883
  const migrationService = new MigrationService2(db);
1751
1884
  const result = await migrationService.runPendingMigrations();
@@ -1764,7 +1897,7 @@ adminApiRoutes.post("/migrations/run", async (c) => {
1764
1897
  });
1765
1898
  adminApiRoutes.get("/migrations/validate", async (c) => {
1766
1899
  try {
1767
- const { MigrationService: MigrationService2 } = await import('./migrations-57ZHBQ4X.js');
1900
+ const { MigrationService: MigrationService2 } = await import('./migrations-R6NQBKQV.js');
1768
1901
  const db = c.env.DB;
1769
1902
  const migrationService = new MigrationService2(db);
1770
1903
  const validation = await migrationService.validateSchema();
@@ -1791,7 +1924,7 @@ function renderLoginPage(data, demoLoginActive = false) {
1791
1924
  <meta charset="UTF-8">
1792
1925
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1793
1926
  <title>Login - SonicJS AI</title>
1794
- <link rel="icon" type="image/x-icon" href="https://demo.sonicjs.com/images/favicon.ico">
1927
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
1795
1928
  <script src="https://unpkg.com/htmx.org@2.0.3"></script>
1796
1929
  <script src="https://cdn.tailwindcss.com"></script>
1797
1930
  <script>
@@ -1968,7 +2101,7 @@ function renderRegisterPage(data) {
1968
2101
  <meta charset="UTF-8">
1969
2102
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1970
2103
  <title>Register - SonicJS AI</title>
1971
- <link rel="icon" type="image/x-icon" href="https://demo.sonicjs.com/images/favicon.ico">
2104
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
1972
2105
  <script src="https://unpkg.com/htmx.org@2.0.3"></script>
1973
2106
  <script src="https://cdn.tailwindcss.com"></script>
1974
2107
  <script>
@@ -1991,40 +2124,18 @@ function renderRegisterPage(data) {
1991
2124
  <div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
1992
2125
  <!-- Logo Section -->
1993
2126
  <div class="sm:mx-auto sm:w-full sm:max-w-md text-center">
1994
- <div class="mx-auto w-64 mb-8">
1995
- <svg class="w-full h-auto" viewBox="380 1300 2250 400" aria-hidden="true">
1996
- <path fill="#F1F2F2" d="M476.851,1404.673h168.536c4.714,0,8.695-1.618,11.944-4.866c3.241-3.241,4.866-7.222,4.866-11.943 c0-2.357-0.443-4.569-1.327-6.636c-0.885-2.06-2.067-3.829-3.539-5.308c-1.479-1.472-3.249-2.654-5.308-3.538 c-2.067-0.885-4.279-1.327-6.635-1.327H476.851c-20.057,0-37.158,7.154-51.313,21.454c-14.155,14.308-21.233,31.483-21.233,51.534 c0,20.058,7.078,37.234,21.233,51.534c14.155,14.308,31.255,21.454,51.313,21.454h112.357c10.907,0,20.196,3.837,27.868,11.502 c7.666,7.672,11.502,16.885,11.502,27.646c0,10.769-3.836,19.982-11.502,27.647c-7.672,7.673-16.961,11.502-27.868,11.502H421.115 c-4.721,0-8.702,1.624-11.944,4.865c-3.248,3.249-4.866,7.23-4.866,11.944c0,3.248,0.733,6.123,2.212,8.626 c1.472,2.509,3.462,4.499,5.971,5.972c2.502,1.472,5.378,2.212,8.626,2.212h168.094c20.052,0,37.227-7.078,51.534-21.234 c14.3-14.155,21.454-31.331,21.454-51.534c0-20.196-7.154-37.379-21.454-51.534c-14.308-14.156-31.483-21.234-51.534-21.234 H476.851c-10.616,0-19.76-3.905-27.426-11.721c-7.672-7.811-11.501-17.101-11.501-27.87c0-10.761,3.829-19.975,11.501-27.647 C457.091,1408.508,466.235,1404.673,476.851,1404.673z"></path>
1997
- <path fill="#F1F2F2" d="M974.78,1398.211c-5.016,6.574-10.034,13.146-15.048,19.721c-1.828,2.398-3.657,4.796-5.487,7.194 c1.994,1.719,3.958,3.51,5.873,5.424c18.724,18.731,28.089,41.216,28.089,67.459c0,26.251-9.366,48.658-28.089,67.237 c-18.731,18.579-41.215,27.868-67.459,27.868c-9.848,0-19.156-1.308-27.923-3.923l-4.185,3.354 c-8.587,6.885-17.154,13.796-25.725,20.702c17.52,8.967,36.86,13.487,58.054,13.487c35.533,0,65.91-12.608,91.124-37.821 c25.214-25.215,37.821-55.584,37.821-91.125c0-35.534-12.607-65.911-37.821-91.126 C981.004,1403.663,977.926,1400.854,974.78,1398.211z"></path>
1998
- <path fill="#F1F2F2" d="M1364.644,1439.619c-4.72,0-8.702,1.624-11.943,4.865c-3.249,3.249-4.866,7.23-4.866,11.944v138.014 l-167.651-211.003c-0.297-0.586-0.74-1.03-1.327-1.326c-4.721-4.714-10.249-7.742-16.588-9.069 c-6.346-1.326-12.608-0.732-18.801,1.77c-6.192,2.509-11.059,6.49-14.598,11.944c-3.539,5.46-5.308,11.577-5.308,18.357v208.348 c0,4.721,1.618,8.703,4.866,11.944c3.241,3.241,7.222,4.865,11.943,4.865c2.945,0,5.751-0.738,8.405-2.211 c2.654-1.472,4.713-3.463,6.193-5.971c1.473-2.503,2.212-5.378,2.212-8.627v-205.251l166.325,209.675 c2.06,2.654,4.423,4.865,7.078,6.635c5.308,3.829,11.349,5.75,18.137,5.75c5.308,0,10.464-1.182,15.482-3.538 c3.539-1.769,6.56-4.127,9.069-7.078c2.502-2.945,4.491-6.338,5.971-10.175c1.473-3.829,2.212-7.664,2.212-11.501v-141.552 c0-4.714-1.624-8.695-4.865-11.944C1373.339,1441.243,1369.359,1439.619,1364.644,1439.619z"></path>
1999
- <path fill="#F1F2F2" d="M1508.406,1432.983c-2.654-1.472-5.46-2.212-8.404-2.212c-4.721,0-8.703,1.7-11.944,5.087 c-3.249,3.395-4.865,7.3-4.865,11.723v163.228c0,4.721,1.616,8.702,4.865,11.943c3.241,3.249,7.223,4.866,11.944,4.866 c2.944,0,5.751-0.732,8.404-2.212c2.655-1.472,4.714-3.539,6.193-6.194c1.473-2.654,2.213-5.453,2.213-8.404V1447.58 c0-2.945-0.74-5.75-2.213-8.405C1513.12,1436.522,1511.06,1434.462,1508.406,1432.983z"></path>
2000
- <path fill="#F1F2F2" d="M1499.78,1367.957c-4.575,0-8.481,1.625-11.722,4.866c-3.249,3.249-4.865,7.23-4.865,11.943 c0,2.951,0.732,5.75,2.212,8.405c1.472,2.654,3.463,4.721,5.971,6.193c2.503,1.479,5.378,2.212,8.627,2.212 c4.423,0,8.328-1.618,11.721-4.865c3.387-3.243,5.088-7.224,5.088-11.944c0-4.713-1.701-8.694-5.088-11.943 C1508.33,1369.582,1504.349,1367.957,1499.78,1367.957z"></path>
2001
- <path fill="#F1F2F2" d="M1859.627,1369.727H1747.27c-35.388,0-65.69,12.607-90.904,37.821 c-25.213,25.215-37.82,55.591-37.82,91.125c0,35.54,12.607,65.911,37.82,91.125c25.215,25.215,55.516,37.821,90.904,37.821h56.178 c4.714,0,8.695-1.618,11.944-4.866c3.241-3.241,4.865-7.222,4.865-11.943c0-4.714-1.624-8.695-4.865-11.943 c-3.249-3.243-7.23-4.866-11.944-4.866h-56.178c-26.251,0-48.659-9.359-67.237-28.09c-18.579-18.723-27.868-41.207-27.868-67.459 c0-26.243,9.29-48.659,27.868-67.237c18.579-18.579,40.987-27.868,67.237-27.868h112.357c4.714,0,8.696-1.693,11.944-5.087 c3.241-3.387,4.865-7.368,4.865-11.943c0-4.569-1.624-8.475-4.865-11.723C1868.322,1371.351,1864.341,1369.727,1859.627,1369.727z "></path>
2002
- <path fill="#06b6d4" d="M2219.256,1371.054h-112.357c-4.423,0-8.336,1.624-11.723,4.865c-3.393,3.249-5.087,7.23-5.087,11.944 c0,4.721,1.694,8.702,5.087,11.943c3.387,3.249,7.3,4.866,11.723,4.866h95.547v95.105c0,26.251-9.365,48.659-28.088,67.237 c-18.731,18.579-41.215,27.868-67.459,27.868c-26.251,0-48.659-9.289-67.237-27.868c-18.579-18.579-27.868-40.987-27.868-67.237 c0-4.713-1.701-8.771-5.088-12.165c-3.393-3.387-7.374-5.087-11.943-5.087c-4.575,0-8.481,1.7-11.722,5.087 c-3.249,3.393-4.865,7.451-4.865,12.165c0,35.388,12.607,65.69,37.82,90.904c25.215,25.213,55.584,37.82,91.126,37.82 c35.532,0,65.91-12.607,91.125-37.82c25.214-25.215,37.82-55.516,37.82-90.904v-111.915c0-4.714-1.624-8.695-4.865-11.944 C2227.951,1372.678,2223.971,1371.054,2219.256,1371.054z"></path>
2003
- <path fill="#06b6d4" d="M2574.24,1502.875c-14.306-14.156-31.483-21.234-51.533-21.234H2410.35 c-10.617,0-19.762-3.829-27.426-11.501c-7.672-7.664-11.501-16.954-11.501-27.868c0-10.907,3.829-20.196,11.501-27.868 c7.664-7.664,16.809-11.501,27.426-11.501h112.357c4.714,0,8.695-1.617,11.944-4.866c3.241-3.241,4.865-7.222,4.865-11.943 c0-4.714-1.624-8.695-4.865-11.944c-3.249-3.241-7.23-4.865-11.944-4.865H2410.35c-20.058,0-37.158,7.154-51.313,21.454 c-14.156,14.308-21.232,31.483-21.232,51.534c0,20.058,7.077,37.234,21.232,51.534c14.156,14.308,31.255,21.454,51.313,21.454 h112.357c7.078,0,13.637,1.77,19.684,5.308c6.042,3.539,10.838,8.336,14.377,14.377c3.538,6.047,5.307,12.607,5.307,19.685 c0,10.616-3.835,19.76-11.501,27.425c-7.672,7.673-16.961,11.502-27.868,11.502h-168.094c-4.721,0-8.703,1.7-11.944,5.087 c-3.249,3.393-4.865,7.374-4.865,11.943c0,4.576,1.616,8.481,4.865,11.723c3.241,3.249,7.223,4.866,11.944,4.866h168.094 c20.051,0,37.227-7.078,51.533-21.234c14.302-14.155,21.454-31.331,21.454-51.534 C2595.695,1534.213,2588.542,1517.03,2574.24,1502.875z"></path>
2004
- <path fill="#06b6d4" d="M854.024,1585.195l20.001-16.028c16.616-13.507,33.04-27.265,50.086-40.251 c1.13-0.861,2.9-1.686,2.003-3.516c-0.843-1.716-2.481-2.302-4.484-2.123c-8.514,0.765-17.016-0.538-25.537-0.353 c-1.124,0.024-2.768,0.221-3.163-1.25c-0.371-1.369,1.088-2.063,1.919-2.894c6.26-6.242,12.574-12.43,18.816-18.691 c9.303-9.327,18.565-18.714,27.851-28.066c1.848-1.859,3.701-3.713,5.549-5.572c2.655-2.661,5.309-5.315,7.958-7.982 c0.574-0.579,1.259-1.141,1.246-1.94c-0.004-0.257-0.078-0.538-0.254-0.853c-0.556-0.981-1.441-1.1-2.469-0.957 c-0.658,0.096-1.315,0.185-1.973,0.275c-3.844,0.538-7.689,1.076-11.533,1.608c-3.641,0.505-7.281,1.02-10.922,1.529 c-4.162,0.582-8.324,1.158-12.486,1.748c-1.142,0.161-2.409,1.662-3.354,0.508c-0.419-0.508-0.431-1.028-0.251-1.531 c0.269-0.741,0.957-1.441,1.387-2.021c3.414-4.58,6.882-9.124,10.356-13.662c1.74-2.272,3.48-4.544,5.214-6.822 c4.682-6.141,9.369-12.281,14.051-18.422c0.09-0.119,0.181-0.237,0.271-0.355c6.848-8.98,13.7-17.958,20.553-26.936 c0.488-0.64,0.977-1.28,1.465-1.92c2.159-2.828,4.315-5.658,6.476-8.486c4.197-5.501,8.454-10.954,12.67-16.442 c0.263-0.347,0.538-0.718,0.717-1.106c0.269-0.586,0.299-1.196-0.335-1.776c-0.825-0.753-1.8-0.15-2.595,0.419 c-0.67,0.472-1.333,0.957-1.955,1.489c-2.206,1.889-4.401,3.797-6.595,5.698c-3.958,3.438-7.922,6.876-11.976,10.194 c-2.443,2.003-4.865,4.028-7.301,6.038c-18.689-10.581-39.53-15.906-62.549-15.906c-35.54,0-65.911,12.607-91.125,37.82 c-25.214,25.215-37.821,55.592-37.821,91.126c0,35.54,12.607,65.91,37.821,91.125c4.146,4.146,8.445,7.916,12.87,11.381 c-9.015,11.14-18.036,22.277-27.034,33.429c-1.208,1.489-3.755,3.151-2.745,4.891c0.078,0.144,0.173,0.281,0.305,0.425 c1.321,1.429,3.492-1.303,4.933-2.457c6.673-5.333,13.333-10.685,19.982-16.042c3.707-2.984,7.417-5.965,11.124-8.952 c1.474-1.188,2.951-2.373,4.425-3.561c6.41-5.164,12.816-10.333,19.238-15.481L854.024,1585.195z M797.552,1498.009 c0-26.243,9.29-48.728,27.868-67.459c18.579-18.723,40.987-28.089,67.238-28.089c12.273,0,23.712,2.075,34.34,6.171 c-3.37,2.905-6.734,5.816-10.069,8.762c-6.075,5.351-12.365,10.469-18.667,15.564c-4.179,3.378-8.371,6.744-12.514,10.164 c-7.54,6.23-15.037,12.52-22.529,18.804c-7.091,5.955-14.182,11.904-21.19,17.949c-1.136,0.974-3.055,1.907-2.135,3.94 c0.831,1.836,2.774,1.417,4.341,1.578l12.145-0.599l14.151-0.698c1.031-0.102,2.192-0.257,2.89,0.632 c0.034,0.044,0.073,0.078,0.106,0.127c1.017,1.561-0.67,2.105-1.387,2.942c-6.308,7.318-12.616,14.637-18.978,21.907 c-8.161,9.339-16.353,18.649-24.544,27.958c-2.146,2.433-4.275,4.879-6.422,7.312c-1.034,1.172-2.129,2.272-1.238,3.922 c0.933,1.728,2.685,1.752,4.323,1.602c4.134-0.367,8.263-0.489,12.396-0.492c0.242,0,0.485-0.005,0.728-0.004 c2.711,0.009,5.422,0.068,8.134,0.145c2.582,0.074,5.166,0.165,7.752,0.249c0.275,1.62-0.879,2.356-1.62,3.259 c-1.333,1.626-2.667,3.247-4,4.867c-4.315,5.252-8.62,10.514-12.928,15.772c-3.562-2.725-7.007-5.733-10.324-9.051 C806.842,1546.667,797.552,1524.26,797.552,1498.009z"></path>
2127
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-lg bg-white">
2128
+ <svg class="h-7 w-7 text-zinc-950" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2129
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
2005
2130
  </svg>
2006
2131
  </div>
2007
- <h2 class="mt-6 text-xl font-medium text-white">${data.isSetup ? "Welcome to SonicJS" : "Create Account"}</h2>
2008
- ${data.isSetup ? `<p class="mt-2 text-sm text-zinc-400">Create your admin account to get started.</p>` : `<p class="mt-2 text-sm text-zinc-400">Create your account and get started</p>`}
2132
+ <h1 class="mt-6 text-3xl font-semibold tracking-tight text-white">SonicJS AI</h1>
2133
+ <p class="mt-2 text-sm text-zinc-400">Create your account and get started</p>
2009
2134
  </div>
2010
2135
 
2011
2136
  <!-- Form Container -->
2012
2137
  <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
2013
2138
  <div class="bg-zinc-900 shadow-sm ring-1 ring-white/10 rounded-xl px-6 py-8 sm:px-10">
2014
- <!-- Setup Banner -->
2015
- ${data.isSetup ? `
2016
- <div class="mb-6 rounded-lg bg-blue-500/10 p-4 ring-1 ring-blue-500/20">
2017
- <div class="flex items-start gap-x-3">
2018
- <svg class="h-5 w-5 text-blue-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2019
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
2020
- </svg>
2021
- <div class="flex-1">
2022
- <p class="text-sm font-medium text-blue-300">First-time Setup</p>
2023
- <p class="text-sm text-blue-400/80 mt-1">This account will be the administrator with full access to manage your SonicJS installation.</p>
2024
- </div>
2025
- </div>
2026
- </div>
2027
- ` : ""}
2028
2139
  <!-- Alerts -->
2029
2140
  ${data.error ? `<div class="mb-6">${renderAlert({ type: "error", message: data.error })}</div>` : ""}
2030
2141
 
@@ -2139,7 +2250,6 @@ function renderRegisterPage(data) {
2139
2250
  </html>
2140
2251
  `;
2141
2252
  }
2142
- var adminExistsCache = null;
2143
2253
  async function isRegistrationEnabled(db) {
2144
2254
  try {
2145
2255
  const plugin = await db.prepare("SELECT settings FROM plugins WHERE id = ?").bind("core-auth").first();
@@ -2161,21 +2271,6 @@ async function isFirstUserRegistration(db) {
2161
2271
  return false;
2162
2272
  }
2163
2273
  }
2164
- async function checkAdminUserExists(db) {
2165
- if (adminExistsCache !== null) {
2166
- return adminExistsCache;
2167
- }
2168
- try {
2169
- const result = await db.prepare("SELECT id FROM users WHERE role = ?").bind("admin").first();
2170
- adminExistsCache = !!result;
2171
- return adminExistsCache;
2172
- } catch {
2173
- return false;
2174
- }
2175
- }
2176
- function setAdminExists() {
2177
- adminExistsCache = true;
2178
- }
2179
2274
  var baseRegistrationSchema = z.object({
2180
2275
  email: z.string().email("Valid email is required"),
2181
2276
  password: z.string().min(8, "Password must be at least 8 characters"),
@@ -2237,11 +2332,8 @@ authRoutes.get("/register", async (c) => {
2237
2332
  }
2238
2333
  }
2239
2334
  const error = c.req.query("error");
2240
- const isSetup = c.req.query("setup") === "true";
2241
2335
  const pageData = {
2242
- error: error || void 0,
2243
- isSetup: isSetup && isFirstUser
2244
- // Only show setup message if truly first user
2336
+ error: error || void 0
2245
2337
  };
2246
2338
  return c.html(renderRegisterPage(pageData));
2247
2339
  });
@@ -2516,9 +2608,6 @@ authRoutes.post("/register/form", async (c) => {
2516
2608
  now.getTime(),
2517
2609
  now.getTime()
2518
2610
  ).run();
2519
- if (isFirstUser) {
2520
- setAdminExists();
2521
- }
2522
2611
  const token = await AuthManager.generateToken(userId, normalizedEmail, role);
2523
2612
  setCookie(c, "auth_token", token, {
2524
2613
  httpOnly: true,
@@ -2640,7 +2729,6 @@ authRoutes.post("/seed-admin", async (c) => {
2640
2729
  if (existingAdmin) {
2641
2730
  const passwordHash2 = await AuthManager.hashPassword("sonicjs!");
2642
2731
  await db.prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?").bind(passwordHash2, Date.now(), existingAdmin.id).run();
2643
- setAdminExists();
2644
2732
  return c.json({
2645
2733
  message: "Admin user already exists (password updated)",
2646
2734
  user: {
@@ -2671,7 +2759,6 @@ authRoutes.post("/seed-admin", async (c) => {
2671
2759
  now,
2672
2760
  now
2673
2761
  ).run();
2674
- setAdminExists();
2675
2762
  return c.json({
2676
2763
  message: "Admin user created successfully",
2677
2764
  user: {
@@ -3381,9 +3468,166 @@ var test_cleanup_default = app;
3381
3468
  // src/templates/pages/admin-content-form.template.ts
3382
3469
  init_admin_layout_catalyst_template();
3383
3470
 
3471
+ // src/templates/components/drag-sortable.template.ts
3472
+ function getDragSortableScript() {
3473
+ return `
3474
+ <script>
3475
+ if (!window.__sonicDragSortableInit) {
3476
+ window.__sonicDragSortableInit = true;
3477
+
3478
+ window.initializeDragSortable = function(container, options) {
3479
+ if (!container || container.dataset.dragSortableInit === 'true') {
3480
+ return;
3481
+ }
3482
+
3483
+ container.dataset.dragSortableInit = 'true';
3484
+ const itemSelector = options && options.itemSelector ? options.itemSelector : '.sortable-item';
3485
+ const handleSelector = options && options.handleSelector ? options.handleSelector : '[data-action="drag-handle"]';
3486
+ const onUpdate = options && typeof options.onUpdate === 'function' ? options.onUpdate : function() {};
3487
+ let activeDragItem = null;
3488
+
3489
+ const getDragAfterElement = function(list, y) {
3490
+ const items = Array.from(list.querySelectorAll(itemSelector + ':not(.is-dragging)'));
3491
+ let closest = { offset: Number.NEGATIVE_INFINITY, element: null };
3492
+ items.forEach(function(item) {
3493
+ const box = item.getBoundingClientRect();
3494
+ const offset = y - box.top - box.height / 2;
3495
+ if (offset < 0 && offset > closest.offset) {
3496
+ closest = { offset: offset, element: item };
3497
+ }
3498
+ });
3499
+ return closest.element;
3500
+ };
3501
+
3502
+ const activateDragItem = function(event) {
3503
+ const target = event.target;
3504
+ if (!(target instanceof Element)) return;
3505
+ const handle = target.closest(handleSelector);
3506
+ if (!handle) return;
3507
+ const item = handle.closest(itemSelector);
3508
+ if (!item) return;
3509
+ activeDragItem = item;
3510
+ };
3511
+
3512
+ const clearActiveDragItem = function() {
3513
+ activeDragItem = null;
3514
+ };
3515
+
3516
+ container.addEventListener('pointerdown', activateDragItem);
3517
+ container.addEventListener('mousedown', activateDragItem);
3518
+ container.addEventListener('pointerup', clearActiveDragItem);
3519
+ container.addEventListener('mouseup', clearActiveDragItem);
3520
+
3521
+ container.addEventListener('dragstart', function(event) {
3522
+ const target = event.target;
3523
+ if (!(target instanceof Element)) return;
3524
+ const item = target.closest(itemSelector);
3525
+ if (!item || item !== activeDragItem) {
3526
+ event.preventDefault();
3527
+ return;
3528
+ }
3529
+ item.classList.add('is-dragging');
3530
+ if (event.dataTransfer) {
3531
+ event.dataTransfer.setData('text/plain', '');
3532
+ }
3533
+ });
3534
+
3535
+ container.addEventListener('dragend', function(event) {
3536
+ const target = event.target;
3537
+ if (target instanceof Element) {
3538
+ const item = target.closest(itemSelector);
3539
+ if (item) {
3540
+ item.classList.remove('is-dragging');
3541
+ }
3542
+ }
3543
+ activeDragItem = null;
3544
+ onUpdate();
3545
+ });
3546
+
3547
+ container.addEventListener('dragover', function(event) {
3548
+ event.preventDefault();
3549
+ const dragging = container.querySelector(itemSelector + '.is-dragging');
3550
+ if (!dragging) return;
3551
+ const afterElement = getDragAfterElement(container, event.clientY);
3552
+ if (afterElement === null) {
3553
+ container.appendChild(dragging);
3554
+ } else {
3555
+ container.insertBefore(dragging, afterElement);
3556
+ }
3557
+ });
3558
+
3559
+ container.addEventListener('drop', function() {
3560
+ onUpdate();
3561
+ });
3562
+ };
3563
+ }
3564
+ </script>
3565
+ `;
3566
+ }
3567
+
3384
3568
  // src/templates/components/dynamic-field.template.ts
3569
+ function getReadFieldValueScript() {
3570
+ return `
3571
+ <script>
3572
+ if (!window.__sonicReadFieldValueInit) {
3573
+ window.__sonicReadFieldValueInit = true;
3574
+
3575
+ window.sonicReadFieldValue = function(fieldWrapper) {
3576
+ const fieldType = fieldWrapper.dataset.fieldType;
3577
+ const select = fieldWrapper.querySelector('select');
3578
+ const textarea = fieldWrapper.querySelector('textarea');
3579
+ const inputs = Array.from(fieldWrapper.querySelectorAll('input'));
3580
+ const checkbox = inputs.find((input) => input.type === 'checkbox');
3581
+ const nonHiddenInput = inputs.find((input) => input.type !== 'hidden' && input.type !== 'checkbox');
3582
+ const hiddenInput = inputs.find((input) => input.type === 'hidden');
3583
+
3584
+ if (fieldType === 'object' || fieldType === 'array') {
3585
+ if (!hiddenInput) {
3586
+ return fieldType === 'array' ? [] : {};
3587
+ }
3588
+ const rawValue = hiddenInput.value || '';
3589
+ if (!rawValue.trim()) {
3590
+ return fieldType === 'array' ? [] : {};
3591
+ }
3592
+ try {
3593
+ return JSON.parse(rawValue);
3594
+ } catch {
3595
+ return fieldType === 'array' ? [] : {};
3596
+ }
3597
+ }
3598
+
3599
+ if (fieldType === 'boolean' && checkbox) {
3600
+ return checkbox.checked;
3601
+ }
3602
+
3603
+ if (select) {
3604
+ if (select.multiple) {
3605
+ return Array.from(select.selectedOptions).map((option) => option.value);
3606
+ }
3607
+ return select.value;
3608
+ }
3609
+
3610
+ if (fieldType === 'quill' || fieldType === 'media') {
3611
+ return hiddenInput ? hiddenInput.value : '';
3612
+ }
3613
+
3614
+ const textSource = textarea || nonHiddenInput || hiddenInput;
3615
+ if (!textSource) {
3616
+ return '';
3617
+ }
3618
+
3619
+ if (fieldType === 'number') {
3620
+ return textSource.value === '' ? null : Number(textSource.value);
3621
+ }
3622
+
3623
+ return textSource.value;
3624
+ };
3625
+ }
3626
+ </script>
3627
+ `;
3628
+ }
3385
3629
  function renderDynamicField(field, options = {}) {
3386
- const { value = "", errors = [], disabled = false, className = "", pluginStatuses = {} } = options;
3630
+ const { value = "", errors = [], disabled = false, className = "", pluginStatuses = {}, collectionId = "", contentId = "" } = options;
3387
3631
  const opts = field.field_options || {};
3388
3632
  const required = field.is_required ? "required" : "";
3389
3633
  const baseClasses = `w-full rounded-lg px-3 py-2 text-sm text-zinc-950 dark:text-white bg-white dark:bg-zinc-800 shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow ${className}`;
@@ -3636,67 +3880,171 @@ function renderDynamicField(field, options = {}) {
3636
3880
  `;
3637
3881
  break;
3638
3882
  case "slug":
3639
- let slugPattern = opts.pattern || "^[a-z0-9-]+$";
3640
- let slugHelp = '<p class="mt-2 text-xs text-zinc-500 dark:text-zinc-400">Use lowercase letters, numbers, and hyphens only</p>';
3641
- slugHelp += `<button type="button" class="mt-1 text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300" onclick="generateSlugFromTitle('\${fieldId}')">Generate from title</button>`;
3883
+ const slugPattern = opts.pattern || "^[a-z0-9-]+$";
3884
+ const collectionIdValue = collectionId || opts.collectionId || "";
3885
+ const contentIdValue = contentId || opts.contentId || "";
3886
+ const isEditMode = !!value;
3642
3887
  fieldHTML = `
3643
- <input
3644
- type="text"
3645
- id="${fieldId}"
3646
- name="${fieldName}"
3647
- value="${escapeHtml2(value)}"
3648
- placeholder="${opts.placeholder || "url-friendly-slug"}"
3649
- maxlength="${opts.maxLength || ""}"
3650
- data-pattern="${slugPattern}"
3651
- class="${baseClasses} ${errorClasses}"
3652
- ${required}
3653
- ${disabled ? "disabled" : ""}
3654
- >
3655
- ${slugHelp}
3888
+ <div class="slug-field-container">
3889
+ <input
3890
+ type="text"
3891
+ id="${fieldId}"
3892
+ name="${fieldName}"
3893
+ value="${escapeHtml2(value)}"
3894
+ placeholder="${opts.placeholder || "url-friendly-slug"}"
3895
+ maxlength="${opts.maxLength || 100}"
3896
+ data-pattern="${slugPattern}"
3897
+ data-collection-id="${collectionIdValue}"
3898
+ data-content-id="${contentIdValue}"
3899
+ data-is-edit-mode="${isEditMode}"
3900
+ class="${baseClasses} ${errorClasses}"
3901
+ ${required}
3902
+ ${disabled ? "disabled" : ""}
3903
+ >
3904
+ <div id="${fieldId}-status" class="slug-status mt-1 text-sm min-h-[20px]"></div>
3905
+ <button
3906
+ type="button"
3907
+ class="regenerate-slug-btn mt-2 text-sm text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 flex items-center gap-1 transition-colors"
3908
+ onclick="window.regenerateSlugFromTitle_${fieldId.replace(/-/g, "_")}()"
3909
+ >
3910
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
3911
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
3912
+ </svg>
3913
+ Regenerate from title
3914
+ </button>
3915
+ <p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">Use lowercase letters, numbers, and hyphens only</p>
3916
+ </div>
3917
+
3656
3918
  <script>
3657
3919
  (function() {
3658
- const field = document.getElementById('${fieldId}');
3920
+ const slugField = document.getElementById('${fieldId}');
3921
+ const statusDiv = document.getElementById('${fieldId}-status');
3922
+ const isEditMode = slugField.dataset.isEditMode === 'true';
3659
3923
  const pattern = new RegExp('${slugPattern}');
3660
-
3661
- field.addEventListener('input', function() {
3662
- if (this.value && !pattern.test(this.value)) {
3663
- this.setCustomValidity('Please use only lowercase letters, numbers, and hyphens.');
3664
- } else {
3665
- this.setCustomValidity('');
3666
- }
3667
- });
3668
-
3669
- field.addEventListener('blur', function() {
3670
- this.reportValidity();
3671
- });
3672
- })();
3673
-
3674
- function generateSlugFromTitle(slugFieldId) {
3675
- const titleField = document.querySelector('input[name="title"]');
3676
- const slugField = document.getElementById(slugFieldId);
3677
- if (titleField && slugField) {
3678
- const slug = titleField.value
3924
+ const collectionId = slugField.dataset.collectionId;
3925
+ const contentId = slugField.dataset.contentId;
3926
+
3927
+ let checkTimeout;
3928
+ let lastCheckedSlug = '';
3929
+ let manuallyEdited = false;
3930
+
3931
+ // Shared slug generation function
3932
+ function generateSlug(text) {
3933
+ if (!text) return '';
3934
+
3935
+ return text
3679
3936
  .toLowerCase()
3937
+ .normalize('NFD')
3938
+ .replace(/[\\u0300-\\u036f]/g, '')
3680
3939
  .replace(/[^a-z0-9\\s_-]/g, '')
3681
3940
  .replace(/\\s+/g, '-')
3682
3941
  .replace(/[-_]+/g, '-')
3683
- .replace(/^[-_]|[-_]$/g, '');
3684
- slugField.value = slug;
3942
+ .replace(/^[-_]+|[-_]+$/g, '')
3943
+ .substring(0, 100);
3685
3944
  }
3686
- }
3687
-
3688
- // Auto-generate slug when title changes
3689
- document.addEventListener('DOMContentLoaded', function() {
3690
- const titleField = document.querySelector('input[name="title"]');
3691
- const slugField = document.getElementById('${fieldId}');
3692
- if (titleField && slugField && !slugField.value) {
3693
- titleField.addEventListener('input', function() {
3694
- if (!slugField.value) {
3695
- generateSlugFromTitle('${fieldId}');
3945
+
3946
+ // Check if slug is available
3947
+ async function checkSlugAvailability(slug) {
3948
+ if (!slug || !collectionId) return;
3949
+
3950
+ // Don't check if it's the same as last time
3951
+ if (slug === lastCheckedSlug) return;
3952
+ lastCheckedSlug = slug;
3953
+
3954
+ try {
3955
+ // Show checking status
3956
+ statusDiv.innerHTML = '<span class="text-gray-400">\u23F3 Checking availability...</span>';
3957
+
3958
+ // Build URL
3959
+ let url = \`/api/content/check-slug?collectionId=\${encodeURIComponent(collectionId)}&slug=\${encodeURIComponent(slug)}\`;
3960
+ if (contentId) {
3961
+ url += \`&excludeId=\${encodeURIComponent(contentId)}\`;
3696
3962
  }
3697
- });
3963
+
3964
+ const response = await fetch(url);
3965
+ const data = await response.json();
3966
+
3967
+ if (data.available) {
3968
+ statusDiv.innerHTML = '<span class="text-green-500 dark:text-green-400">\u2713 Available</span>';
3969
+ slugField.setCustomValidity('');
3970
+ } else {
3971
+ statusDiv.innerHTML = \`<span class="text-red-500 dark:text-red-400">\u2717 \${data.message || 'Already in use'}</span>\`;
3972
+ slugField.setCustomValidity(data.message || 'This slug is already in use');
3973
+ }
3974
+ } catch (error) {
3975
+ console.error('Error checking slug:', error);
3976
+ statusDiv.innerHTML = '<span class="text-yellow-500 dark:text-yellow-400">\u26A0 Could not verify</span>';
3977
+ }
3698
3978
  }
3699
- });
3979
+
3980
+ // Format validation and duplicate checking
3981
+ slugField.addEventListener('input', function() {
3982
+ const value = this.value;
3983
+
3984
+ // Mark as manually edited if user types directly
3985
+ if (document.activeElement === this) {
3986
+ manuallyEdited = true;
3987
+ }
3988
+
3989
+ // Clear status if empty
3990
+ if (!value) {
3991
+ statusDiv.innerHTML = '';
3992
+ this.setCustomValidity('');
3993
+ return;
3994
+ }
3995
+
3996
+ // Pattern validation
3997
+ if (!pattern.test(value)) {
3998
+ this.setCustomValidity('Please use only lowercase letters, numbers, and hyphens.');
3999
+ statusDiv.innerHTML = '<span class="text-red-500 dark:text-red-400">\u2717 Invalid format</span>';
4000
+ return;
4001
+ }
4002
+
4003
+ // Debounce the availability check
4004
+ clearTimeout(checkTimeout);
4005
+ checkTimeout = setTimeout(() => {
4006
+ checkSlugAvailability(value);
4007
+ }, 500); // Wait 500ms after user stops typing
4008
+ });
4009
+
4010
+ // Initial check if field has value
4011
+ if (slugField.value) {
4012
+ checkSlugAvailability(slugField.value);
4013
+ }
4014
+
4015
+ // Auto-generate only in create mode
4016
+ // Wait for all fields to be rendered before attaching listeners
4017
+ if (!isEditMode) {
4018
+ // Use setTimeout to ensure all fields in the form are rendered
4019
+ setTimeout(() => {
4020
+ const titleField = document.querySelector('input[name="title"]');
4021
+ if (titleField) {
4022
+ titleField.addEventListener('input', function() {
4023
+ if (!manuallyEdited) {
4024
+ const slug = generateSlug(this.value);
4025
+ slugField.value = slug;
4026
+
4027
+ // Trigger validation and duplicate check
4028
+ slugField.dispatchEvent(new Event('input', { bubbles: true }));
4029
+ }
4030
+ });
4031
+ }
4032
+ }, 0);
4033
+ }
4034
+
4035
+ // Global function for regenerate button
4036
+ window.regenerateSlugFromTitle_${fieldId.replace(/-/g, "_")} = function() {
4037
+ const titleField = document.querySelector('input[name="title"]');
4038
+ if (titleField && slugField) {
4039
+ const slug = generateSlug(titleField.value);
4040
+ slugField.value = slug;
4041
+ manuallyEdited = false;
4042
+
4043
+ // Trigger validation and duplicate check
4044
+ slugField.dispatchEvent(new Event('input', { bubbles: true }));
4045
+ }
4046
+ };
4047
+ })();
3700
4048
  </script>
3701
4049
  `;
3702
4050
  break;
@@ -3733,43 +4081,124 @@ function renderDynamicField(field, options = {}) {
3733
4081
  ` : ""}
3734
4082
  `;
3735
4083
  break;
4084
+ case "reference":
4085
+ let referenceCollections = [];
4086
+ if (Array.isArray(opts.collection)) {
4087
+ referenceCollections = opts.collection.filter(Boolean);
4088
+ } else if (typeof opts.collection === "string" && opts.collection) {
4089
+ referenceCollections = [opts.collection];
4090
+ }
4091
+ const referenceCollectionsAttr = referenceCollections.join(",");
4092
+ const hasReferenceCollection = referenceCollections.length > 0;
4093
+ const hasReferenceValue = Boolean(value);
4094
+ fieldHTML = `
4095
+ <div class="reference-field-container space-y-3" data-reference-field data-field-name="${escapeHtml2(fieldName)}" data-reference-collection="${escapeHtml2(referenceCollections[0] || "")}" data-reference-collections="${escapeHtml2(referenceCollectionsAttr)}">
4096
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml2(value)}">
4097
+ <div class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 text-sm text-zinc-600 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300" data-reference-display>
4098
+ ${hasReferenceCollection ? hasReferenceValue ? "Loading selection..." : "No reference selected." : "Reference collection not configured."}
4099
+ </div>
4100
+ <div class="flex flex-wrap gap-2">
4101
+ <button
4102
+ type="button"
4103
+ onclick="openReferenceSelector('${fieldId}')"
4104
+ class="inline-flex items-center justify-center rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 dark:bg-white/10 dark:hover:bg-white/20"
4105
+ ${hasReferenceCollection ? "" : "disabled"}
4106
+ >
4107
+ Select reference
4108
+ </button>
4109
+ <button
4110
+ type="button"
4111
+ onclick="clearReferenceField('${fieldId}')"
4112
+ class="inline-flex items-center justify-center rounded-lg border border-zinc-200 px-3 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-100 dark:border-white/10 dark:text-zinc-200 dark:hover:bg-white/10"
4113
+ data-reference-clear
4114
+ ${hasReferenceValue ? "" : "disabled"}
4115
+ >
4116
+ Remove
4117
+ </button>
4118
+ </div>
4119
+ </div>
4120
+ `;
4121
+ break;
3736
4122
  case "media":
4123
+ const isMultiple = opts.multiple === true;
4124
+ const mediaValues = isMultiple && value ? Array.isArray(value) ? value : String(value).split(",").filter(Boolean) : [];
4125
+ const singleValue = !isMultiple ? value : "";
4126
+ const isVideoUrl = (url) => {
4127
+ const videoExtensions = [".mp4", ".webm", ".ogg", ".mov", ".avi"];
4128
+ return videoExtensions.some((ext) => url.toLowerCase().endsWith(ext));
4129
+ };
4130
+ const renderMediaPreview = (url, alt, classes) => {
4131
+ if (isVideoUrl(url)) {
4132
+ return `<video src="${url}" class="${classes}" muted></video>`;
4133
+ }
4134
+ return `<img src="${url}" alt="${alt}" class="${classes}">`;
4135
+ };
3737
4136
  fieldHTML = `
3738
4137
  <div class="media-field-container">
3739
- <input type="hidden" id="${fieldId}" name="${fieldName}" value="${value}">
3740
- <div class="media-preview ${value ? "" : "hidden"}" id="${fieldId}-preview">
3741
- ${value ? `<img src="${value}" alt="Selected media" class="w-32 h-32 object-cover rounded-lg border border-white/20">` : ""}
3742
- </div>
4138
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${isMultiple ? mediaValues.join(",") : singleValue}" data-multiple="${isMultiple}">
4139
+
4140
+ ${isMultiple ? `
4141
+ <div class="media-preview-grid grid grid-cols-4 gap-2 mb-2 ${mediaValues.length === 0 ? "hidden" : ""}" id="${fieldId}-preview">
4142
+ ${mediaValues.map((url, idx) => `
4143
+ <div class="relative media-preview-item" data-url="${url}">
4144
+ ${renderMediaPreview(url, `Media ${idx + 1}`, "w-full h-24 object-cover rounded-lg border border-white/20")}
4145
+ <button
4146
+ type="button"
4147
+ onclick="removeMediaFromMultiple('${fieldId}', '${url}')"
4148
+ class="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 hover:bg-red-700"
4149
+ ${disabled ? "disabled" : ""}
4150
+ >
4151
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
4152
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
4153
+ </svg>
4154
+ </button>
4155
+ </div>
4156
+ `).join("")}
4157
+ </div>
4158
+ ` : `
4159
+ <div class="media-preview ${singleValue ? "" : "hidden"}" id="${fieldId}-preview">
4160
+ ${singleValue ? renderMediaPreview(singleValue, "Selected media", "w-32 h-32 object-cover rounded-lg border border-white/20") : ""}
4161
+ </div>
4162
+ `}
4163
+
3743
4164
  <div class="media-actions mt-2 space-x-2">
3744
4165
  <button
3745
4166
  type="button"
3746
- onclick="openMediaSelector('${fieldId}')"
4167
+ onclick="openMediaSelector('${fieldId}', ${isMultiple})"
3747
4168
  class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all"
3748
4169
  ${disabled ? "disabled" : ""}
3749
4170
  >
3750
4171
  <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
3751
4172
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
3752
4173
  </svg>
3753
- Select Media
4174
+ ${isMultiple ? "Select Media (Multiple)" : "Select Media"}
3754
4175
  </button>
3755
- ${value ? `
4176
+ ${(isMultiple ? mediaValues.length > 0 : singleValue) ? `
3756
4177
  <button
3757
4178
  type="button"
3758
4179
  onclick="clearMediaField('${fieldId}')"
3759
4180
  class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all"
3760
4181
  ${disabled ? "disabled" : ""}
3761
4182
  >
3762
- Remove
4183
+ ${isMultiple ? "Clear All" : "Remove"}
3763
4184
  </button>
3764
4185
  ` : ""}
3765
4186
  </div>
3766
4187
  </div>
3767
4188
  `;
3768
4189
  break;
4190
+ case "object":
4191
+ return renderStructuredObjectField(field, options2);
4192
+ case "array":
4193
+ const itemsConfig = opts.items && typeof opts.items === "object" ? opts.items : {};
4194
+ if (itemsConfig.blocks && typeof itemsConfig.blocks === "object") {
4195
+ return renderBlocksField(field, options2, baseClasses, errorClasses);
4196
+ }
4197
+ return renderStructuredArrayField(field, options2);
3769
4198
  default:
3770
4199
  fieldHTML = `
3771
- <input
3772
- type="text"
4200
+ <input
4201
+ type="text"
3773
4202
  id="${fieldId}"
3774
4203
  name="${fieldName}"
3775
4204
  value="${escapeHtml2(value)}"
@@ -3819,217 +4248,764 @@ function renderFieldGroup(title, fields, collapsible = false) {
3819
4248
  </div>
3820
4249
  `;
3821
4250
  }
3822
- function escapeHtml2(text) {
3823
- if (typeof text !== "string") return String(text || "");
3824
- return text.replace(/[&<>"']/g, (char) => ({
3825
- "&": "&amp;",
3826
- "<": "&lt;",
3827
- ">": "&gt;",
3828
- '"': "&quot;",
3829
- "'": "&#39;"
3830
- })[char] || char);
3831
- }
3832
- var PluginBuilder = class _PluginBuilder {
3833
- plugin;
3834
- constructor(options) {
3835
- this.plugin = {
3836
- name: options.name,
3837
- version: options.version,
3838
- description: options.description,
3839
- author: options.author,
3840
- dependencies: options.dependencies,
3841
- routes: [],
3842
- middleware: [],
3843
- models: [],
3844
- services: [],
3845
- adminPages: [],
3846
- adminComponents: [],
3847
- menuItems: [],
3848
- hooks: []
3849
- };
3850
- }
3851
- /**
3852
- * Create a new plugin builder
3853
- */
3854
- static create(options) {
3855
- return new _PluginBuilder(options);
3856
- }
3857
- /**
3858
- * Add metadata to the plugin
3859
- */
3860
- metadata(metadata) {
3861
- Object.assign(this.plugin, metadata);
3862
- return this;
3863
- }
3864
- /**
3865
- * Add routes to plugin
3866
- */
3867
- addRoutes(routes) {
3868
- this.plugin.routes = [...this.plugin.routes || [], ...routes];
3869
- return this;
3870
- }
3871
- /**
3872
- * Add a single route to plugin
3873
- */
3874
- addRoute(path, handler, options) {
3875
- const route = {
3876
- path,
3877
- handler,
3878
- ...options
3879
- };
3880
- this.plugin.routes = [...this.plugin.routes || [], route];
3881
- return this;
3882
- }
3883
- /**
3884
- * Add middleware to plugin
3885
- */
3886
- addMiddleware(middleware) {
3887
- this.plugin.middleware = [...this.plugin.middleware || [], ...middleware];
3888
- return this;
3889
- }
3890
- /**
3891
- * Add a single middleware to plugin
3892
- */
3893
- addSingleMiddleware(name, handler, options) {
3894
- const middleware = {
3895
- name,
3896
- handler,
3897
- ...options
3898
- };
3899
- this.plugin.middleware = [...this.plugin.middleware || [], middleware];
3900
- return this;
3901
- }
3902
- /**
3903
- * Add models to plugin
3904
- */
3905
- addModels(models) {
3906
- this.plugin.models = [...this.plugin.models || [], ...models];
3907
- return this;
3908
- }
3909
- /**
3910
- * Add a single model to plugin
3911
- */
3912
- addModel(name, options) {
3913
- const model = {
3914
- name,
3915
- ...options
3916
- };
3917
- this.plugin.models = [...this.plugin.models || [], model];
3918
- return this;
3919
- }
3920
- /**
3921
- * Add services to plugin
3922
- */
3923
- addServices(services) {
3924
- this.plugin.services = [...this.plugin.services || [], ...services];
3925
- return this;
3926
- }
3927
- /**
3928
- * Add a single service to plugin
3929
- */
3930
- addService(name, implementation, options) {
3931
- const service = {
3932
- name,
3933
- implementation,
3934
- ...options
3935
- };
3936
- this.plugin.services = [...this.plugin.services || [], service];
3937
- return this;
3938
- }
3939
- /**
3940
- * Add admin pages to plugin
3941
- */
3942
- addAdminPages(pages) {
3943
- this.plugin.adminPages = [...this.plugin.adminPages || [], ...pages];
3944
- return this;
3945
- }
3946
- /**
3947
- * Add a single admin page to plugin
3948
- */
3949
- addAdminPage(path, title, component, options) {
3950
- const page = {
3951
- path,
3952
- title,
3953
- component,
3954
- ...options
3955
- };
3956
- this.plugin.adminPages = [...this.plugin.adminPages || [], page];
3957
- return this;
3958
- }
3959
- /**
3960
- * Add admin components to plugin
3961
- */
3962
- addComponents(components) {
3963
- this.plugin.adminComponents = [...this.plugin.adminComponents || [], ...components];
3964
- return this;
3965
- }
3966
- /**
3967
- * Add a single admin component to plugin
3968
- */
3969
- addComponent(name, template, options) {
3970
- const component = {
3971
- name,
3972
- template,
3973
- ...options
3974
- };
3975
- this.plugin.adminComponents = [...this.plugin.adminComponents || [], component];
3976
- return this;
3977
- }
3978
- /**
3979
- * Add menu items to plugin
3980
- */
3981
- addMenuItems(items) {
3982
- this.plugin.menuItems = [...this.plugin.menuItems || [], ...items];
3983
- return this;
4251
+ function renderBlocksField(field, options, baseClasses, errorClasses) {
4252
+ const { value = [], pluginStatuses = {} } = options;
4253
+ const opts = field.field_options || {};
4254
+ const itemsConfig = opts.items && typeof opts.items === "object" ? opts.items : {};
4255
+ const blocks = normalizeBlockDefinitions(itemsConfig.blocks);
4256
+ const discriminator = typeof itemsConfig.discriminator === "string" && itemsConfig.discriminator ? itemsConfig.discriminator : "blockType";
4257
+ const blockValues = normalizeBlocksValue(value, discriminator);
4258
+ const fieldId = `field-${field.field_name}`;
4259
+ const fieldName = field.field_name;
4260
+ const emptyState = blockValues.length === 0 ? `
4261
+ <div class="rounded-lg border border-dashed border-zinc-200 dark:border-white/10 px-4 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400" data-blocks-empty>
4262
+ No blocks yet. Add your first block to get started.
4263
+ </div>
4264
+ ` : "";
4265
+ const blockOptions = blocks.map((block) => `<option value="${escapeHtml2(block.name)}">${escapeHtml2(block.label)}</option>`).join("");
4266
+ const blockItems = blockValues.map(
4267
+ (blockValue, index) => renderBlockItem(field, blockValue, blocks, discriminator, index, pluginStatuses)
4268
+ ).join("");
4269
+ const templates = blocks.map((block) => renderBlockTemplate(field, block, discriminator, pluginStatuses)).join("");
4270
+ return `
4271
+ <div
4272
+ class="blocks-field space-y-4"
4273
+ data-blocks='${escapeHtml2(JSON.stringify(blocks))}'
4274
+ data-blocks-discriminator="${escapeHtml2(discriminator)}"
4275
+ data-field-name="${escapeHtml2(fieldName)}"
4276
+ >
4277
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml2(JSON.stringify(blockValues))}">
4278
+
4279
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
4280
+ <div class="flex-1">
4281
+ <select
4282
+ class="${baseClasses} ${errorClasses}"
4283
+ data-role="block-type-select"
4284
+ >
4285
+ <option value="">Choose a block...</option>
4286
+ ${blockOptions}
4287
+ </select>
4288
+ </div>
4289
+ <button
4290
+ type="button"
4291
+ data-action="add-block"
4292
+ class="inline-flex items-center justify-center rounded-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white hover:bg-zinc-800 dark:bg-white/10 dark:hover:bg-white/20"
4293
+ >
4294
+ Add Block
4295
+ </button>
4296
+ </div>
4297
+
4298
+ <div class="space-y-4" data-blocks-list>
4299
+ ${blockItems || emptyState}
4300
+ </div>
4301
+
4302
+ ${templates}
4303
+ </div>
4304
+ ${getDragSortableScript()}
4305
+ ${getBlocksFieldScript()}
4306
+ `;
4307
+ }
4308
+ function renderStructuredObjectField(field, options, baseClasses, errorClasses) {
4309
+ const { value = {}, pluginStatuses = {} } = options;
4310
+ const opts = field.field_options || {};
4311
+ const properties = opts.properties && typeof opts.properties === "object" ? opts.properties : {};
4312
+ const fieldId = `field-${field.field_name}`;
4313
+ const fieldName = field.field_name;
4314
+ const objectValue = normalizeStructuredObjectValue(value);
4315
+ const subfields = Object.entries(properties).map(
4316
+ ([propertyName, propertyConfig]) => renderStructuredSubfield(
4317
+ field,
4318
+ propertyName,
4319
+ propertyConfig,
4320
+ objectValue,
4321
+ pluginStatuses,
4322
+ field.field_name
4323
+ )
4324
+ ).join("");
4325
+ return `
4326
+ <div class="space-y-4" data-structured-object data-field-name="${escapeHtml2(fieldName)}">
4327
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml2(JSON.stringify(objectValue))}">
4328
+ <div class="space-y-4" data-structured-object-fields>
4329
+ ${subfields}
4330
+ </div>
4331
+ </div>
4332
+ ${getStructuredFieldScript()}
4333
+ `;
4334
+ }
4335
+ function renderStructuredArrayField(field, options, baseClasses, errorClasses) {
4336
+ const { value = [], pluginStatuses = {} } = options;
4337
+ const opts = field.field_options || {};
4338
+ const itemsConfig = opts.items && typeof opts.items === "object" ? opts.items : {};
4339
+ const fieldId = `field-${field.field_name}`;
4340
+ const fieldName = field.field_name;
4341
+ const arrayValue = normalizeStructuredArrayValue(value);
4342
+ const items = arrayValue.map(
4343
+ (itemValue, index) => renderStructuredArrayItem(field, itemsConfig, String(index), itemValue, pluginStatuses)
4344
+ ).join("");
4345
+ const emptyState = arrayValue.length === 0 ? `
4346
+ <div class="rounded-lg border border-dashed border-zinc-200 dark:border-white/10 px-4 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400" data-structured-empty>
4347
+ No items yet. Add the first item to get started.
4348
+ </div>
4349
+ ` : "";
4350
+ return `
4351
+ <div class="space-y-4" data-structured-array data-field-name="${escapeHtml2(fieldName)}">
4352
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml2(JSON.stringify(arrayValue))}">
4353
+
4354
+ <div class="flex items-center justify-between gap-3">
4355
+ <div class="text-sm text-zinc-500 dark:text-zinc-400">
4356
+ ${escapeHtml2(opts.itemLabel || "Items")}
4357
+ </div>
4358
+ <button
4359
+ type="button"
4360
+ data-action="add-item"
4361
+ class="inline-flex items-center justify-center rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 dark:bg-white/10 dark:hover:bg-white/20"
4362
+ >
4363
+ Add item
4364
+ </button>
4365
+ </div>
4366
+
4367
+ <div class="space-y-4" data-structured-array-list>
4368
+ ${items || emptyState}
4369
+ </div>
4370
+
4371
+ <template data-structured-array-template>
4372
+ ${renderStructuredArrayItem(field, itemsConfig, "__INDEX__", {}, pluginStatuses)}
4373
+ </template>
4374
+ </div>
4375
+ ${getDragSortableScript()}
4376
+ ${getStructuredFieldScript()}
4377
+ `;
4378
+ }
4379
+ function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginStatuses) {
4380
+ const itemFields = renderStructuredItemFields(field, itemConfig, index, itemValue, pluginStatuses);
4381
+ return `
4382
+ <div class="structured-array-item rounded-lg border border-zinc-200 dark:border-white/10 bg-white/60 dark:bg-white/5 p-4 shadow-sm" data-array-index="${escapeHtml2(index)}" draggable="true">
4383
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
4384
+ <div class="flex items-center gap-3">
4385
+ <div class="drag-handle cursor-move text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-400" data-action="drag-handle" title="Drag to reorder">
4386
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
4387
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 8h16M4 16h16"/>
4388
+ </svg>
4389
+ </div>
4390
+ <div class="text-sm font-semibold text-zinc-900 dark:text-white">
4391
+ Item <span class="ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400" data-array-order-label></span>
4392
+ </div>
4393
+ </div>
4394
+ <div class="flex flex-wrap gap-2 text-xs">
4395
+ <button type="button" data-action="move-up" class="inline-flex items-center justify-center rounded-md border border-zinc-200 px-2 py-1 text-zinc-600 hover:bg-zinc-100 dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent" aria-label="Move item up" title="Move up">
4396
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
4397
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6l-4 4m4-4l4 4m-4-4v12"/>
4398
+ </svg>
4399
+ </button>
4400
+ <button type="button" data-action="move-down" class="inline-flex items-center justify-center rounded-md border border-zinc-200 px-2 py-1 text-zinc-600 hover:bg-zinc-100 dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent" aria-label="Move item down" title="Move down">
4401
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
4402
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 18l4-4m-4 4l-4-4m4 4V6"/>
4403
+ </svg>
4404
+ </button>
4405
+ <button type="button" data-action="remove-item" class="inline-flex items-center gap-x-1 px-2.5 py-1.5 text-xs font-medium text-pink-700 dark:text-pink-300 hover:bg-pink-50 dark:hover:bg-pink-900/20 rounded-lg transition-colors">
4406
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
4407
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 0 00-7.5 0"/>
4408
+ </svg>
4409
+ Delete item
4410
+ </button>
4411
+ </div>
4412
+ </div>
4413
+ <div class="mt-4 space-y-4" data-array-item-fields>
4414
+ ${itemFields}
4415
+ </div>
4416
+ </div>
4417
+ `;
4418
+ }
4419
+ function renderStructuredItemFields(field, itemConfig, index, itemValue, pluginStatuses) {
4420
+ const itemType = itemConfig?.type || "string";
4421
+ if (itemType === "object" && itemConfig?.properties && typeof itemConfig.properties === "object") {
4422
+ const fieldPrefix = `array-${field.field_name}-${index}`;
4423
+ return Object.entries(itemConfig.properties).map(
4424
+ ([propertyName, propertyConfig]) => renderStructuredSubfield(
4425
+ field,
4426
+ propertyName,
4427
+ propertyConfig,
4428
+ itemValue || {},
4429
+ pluginStatuses,
4430
+ fieldPrefix
4431
+ )
4432
+ ).join("");
3984
4433
  }
3985
- /**
3986
- * Add a single menu item to plugin
3987
- */
3988
- addMenuItem(label, path, options) {
3989
- const menuItem = {
3990
- label,
3991
- path,
3992
- ...options
3993
- };
3994
- this.plugin.menuItems = [...this.plugin.menuItems || [], menuItem];
3995
- return this;
4434
+ const normalizedField = normalizeBlockField(itemConfig, "Item");
4435
+ const fieldValue = itemValue ?? normalizedField.defaultValue ?? "";
4436
+ const fieldDefinition = {
4437
+ id: `array-${field.field_name}-${index}-value`,
4438
+ field_name: `array-${field.field_name}-${index}-value`,
4439
+ field_type: normalizedField.type,
4440
+ field_label: normalizedField.label,
4441
+ field_options: normalizedField.options,
4442
+ is_required: normalizedField.required};
4443
+ return `
4444
+ <div class="structured-subfield" data-structured-field="__value" data-field-type="${escapeHtml2(normalizedField.type)}">
4445
+ ${renderDynamicField(fieldDefinition, { value: fieldValue, pluginStatuses })}
4446
+ </div>
4447
+ `;
4448
+ }
4449
+ function renderStructuredSubfield(field, propertyName, propertyConfig, objectValue, pluginStatuses, fieldPrefix) {
4450
+ const normalizedField = normalizeBlockField(propertyConfig, propertyName);
4451
+ const fieldValue = objectValue?.[propertyName] ?? normalizedField.defaultValue ?? "";
4452
+ const fieldDefinition = {
4453
+ field_name: `${fieldPrefix}__${propertyName}`,
4454
+ field_type: normalizedField.type,
4455
+ field_label: normalizedField.label,
4456
+ field_options: normalizedField.options,
4457
+ is_required: normalizedField.required};
4458
+ return `
4459
+ <div class="structured-subfield" data-structured-field="${escapeHtml2(propertyName)}" data-field-type="${escapeHtml2(normalizedField.type)}">
4460
+ ${renderDynamicField(fieldDefinition, { value: fieldValue, pluginStatuses })}
4461
+ </div>
4462
+ `;
4463
+ }
4464
+ function normalizeStructuredObjectValue(value) {
4465
+ if (!value) return {};
4466
+ if (typeof value === "string") {
4467
+ try {
4468
+ const parsed = JSON.parse(value);
4469
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4470
+ } catch {
4471
+ return {};
4472
+ }
3996
4473
  }
3997
- /**
3998
- * Add hooks to plugin
3999
- */
4000
- addHooks(hooks) {
4001
- this.plugin.hooks = [...this.plugin.hooks || [], ...hooks];
4002
- return this;
4474
+ if (typeof value === "object" && !Array.isArray(value)) return value;
4475
+ return {};
4476
+ }
4477
+ function normalizeStructuredArrayValue(value) {
4478
+ if (!value) return [];
4479
+ if (Array.isArray(value)) return value;
4480
+ if (typeof value === "string") {
4481
+ try {
4482
+ const parsed = JSON.parse(value);
4483
+ return Array.isArray(parsed) ? parsed : [];
4484
+ } catch {
4485
+ return [];
4486
+ }
4003
4487
  }
4004
- /**
4005
- * Add a single hook to plugin
4006
- */
4007
- addHook(name, handler, options) {
4008
- const hook = {
4009
- name,
4010
- handler,
4011
- ...options
4012
- };
4013
- this.plugin.hooks = [...this.plugin.hooks || [], hook];
4014
- return this;
4488
+ return [];
4489
+ }
4490
+ function normalizeBlockDefinitions(rawBlocks) {
4491
+ if (!rawBlocks || typeof rawBlocks !== "object") return [];
4492
+ return Object.entries(rawBlocks).filter(([name, block]) => typeof name === "string" && block && typeof block === "object").map(([name, block]) => ({
4493
+ name,
4494
+ label: block.label || name,
4495
+ description: block.description,
4496
+ properties: block.properties && typeof block.properties === "object" ? block.properties : {}
4497
+ }));
4498
+ }
4499
+ function normalizeBlocksValue(value, discriminator) {
4500
+ const normalizeItem = (item) => {
4501
+ if (!item || typeof item !== "object") return null;
4502
+ if (item[discriminator]) return item;
4503
+ if (item.blockType && item.data && typeof item.data === "object") {
4504
+ return { [discriminator]: item.blockType, ...item.data };
4505
+ }
4506
+ return item;
4507
+ };
4508
+ const fromArray = (items) => items.map(normalizeItem).filter((item) => item && typeof item === "object");
4509
+ if (Array.isArray(value)) return fromArray(value);
4510
+ if (typeof value === "string" && value.trim()) {
4511
+ try {
4512
+ const parsed = JSON.parse(value);
4513
+ return Array.isArray(parsed) ? fromArray(parsed) : [];
4514
+ } catch {
4515
+ return [];
4516
+ }
4015
4517
  }
4016
- /**
4017
- * Add lifecycle hooks
4018
- */
4019
- lifecycle(hooks) {
4020
- Object.assign(this.plugin, hooks);
4021
- return this;
4518
+ return [];
4519
+ }
4520
+ function renderBlockTemplate(field, block, discriminator, pluginStatuses) {
4521
+ return `
4522
+ <template data-block-template="${escapeHtml2(block.name)}">
4523
+ ${renderBlockCard(field, block, discriminator, "__INDEX__", {}, pluginStatuses)}
4524
+ </template>
4525
+ `;
4526
+ }
4527
+ function renderBlockItem(field, blockValue, blocks, discriminator, index, pluginStatuses) {
4528
+ const blockType = blockValue?.[discriminator] || blockValue?.blockType;
4529
+ const blockDefinition = blocks.find((block) => block.name === blockType);
4530
+ if (!blockDefinition) {
4531
+ return `
4532
+ <div class="rounded-lg border border-amber-200 bg-amber-50/50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200" data-block-raw="${escapeHtml2(JSON.stringify(blockValue || {}))}">
4533
+ Unknown block type: <strong>${escapeHtml2(String(blockType || "unknown"))}</strong>. This block will be preserved as-is.
4534
+ </div>
4535
+ `;
4022
4536
  }
4023
- /**
4024
- * Build the plugin
4025
- */
4026
- build() {
4027
- if (!this.plugin.name || !this.plugin.version) {
4028
- throw new Error("Plugin name and version are required");
4537
+ const data = blockValue && typeof blockValue === "object" ? Object.fromEntries(Object.entries(blockValue).filter(([key]) => key !== discriminator)) : {};
4538
+ return renderBlockCard(field, blockDefinition, discriminator, String(index), data, pluginStatuses);
4539
+ }
4540
+ function renderBlockCard(field, block, discriminator, index, data, pluginStatuses) {
4541
+ const blockFields = Object.entries(block.properties).map(([fieldName, fieldConfig]) => {
4542
+ if (fieldConfig?.type === "array" && fieldConfig?.items?.blocks) {
4543
+ return `
4544
+ <div class="rounded-lg border border-dashed border-amber-200 bg-amber-50/50 px-4 py-3 text-xs text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
4545
+ Nested blocks are not supported yet for "${escapeHtml2(fieldName)}".
4546
+ </div>
4547
+ `;
4029
4548
  }
4030
- return this.plugin;
4549
+ const normalizedField = normalizeBlockField(fieldConfig, fieldName);
4550
+ const fieldValue = data?.[fieldName] ?? normalizedField.defaultValue ?? "";
4551
+ const fieldDefinition = {
4552
+ id: `block-${field.field_name}-${index}-${fieldName}`,
4553
+ field_name: `block-${field.field_name}-${index}-${fieldName}`,
4554
+ field_type: normalizedField.type,
4555
+ field_label: normalizedField.label,
4556
+ field_options: normalizedField.options,
4557
+ is_required: normalizedField.required};
4558
+ return `
4559
+ <div class="blocks-subfield" data-block-field="${escapeHtml2(fieldName)}" data-field-type="${escapeHtml2(normalizedField.type)}">
4560
+ ${renderDynamicField(fieldDefinition, { value: fieldValue, pluginStatuses })}
4561
+ </div>
4562
+ `;
4563
+ }).join("");
4564
+ return `
4565
+ <div class="blocks-item rounded-lg border border-zinc-200 dark:border-white/10 bg-white/60 dark:bg-white/5 p-4 shadow-sm" data-block-type="${escapeHtml2(block.name)}" data-block-discriminator="${escapeHtml2(discriminator)}" draggable="true">
4566
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
4567
+ <div class="flex items-start gap-3">
4568
+ <div class="drag-handle cursor-move text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-400" data-action="drag-handle" title="Drag to reorder">
4569
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
4570
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 8h16M4 16h16"/>
4571
+ </svg>
4572
+ </div>
4573
+ <div>
4574
+ <div class="text-sm font-semibold text-zinc-900 dark:text-white">
4575
+ ${escapeHtml2(block.label)}
4576
+ <span class="ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400" data-block-order-label></span>
4577
+ </div>
4578
+ ${block.description ? `<p class="text-xs text-zinc-500 dark:text-zinc-400">${escapeHtml2(block.description)}</p>` : ""}
4579
+ </div>
4580
+ </div>
4581
+ <div class="flex flex-wrap gap-2 text-xs">
4582
+ <button type="button" data-action="move-up" class="inline-flex items-center justify-center rounded-md border border-zinc-200 px-2 py-1 text-zinc-600 hover:bg-zinc-100 dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent" aria-label="Move block up" title="Move up">
4583
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
4584
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6l-4 4m4-4l4 4m-4-4v12"/>
4585
+ </svg>
4586
+ </button>
4587
+ <button type="button" data-action="move-down" class="inline-flex items-center justify-center rounded-md border border-zinc-200 px-2 py-1 text-zinc-600 hover:bg-zinc-100 dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent" aria-label="Move block down" title="Move down">
4588
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
4589
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 18l4-4m-4 4l-4-4m4 4V6"/>
4590
+ </svg>
4591
+ </button>
4592
+ <button type="button" data-action="remove-block" class="inline-flex items-center gap-x-1 px-2.5 py-1.5 text-xs font-medium text-pink-700 dark:text-pink-300 hover:bg-pink-50 dark:hover:bg-pink-900/20 rounded-lg transition-colors">
4593
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
4594
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
4595
+ </svg>
4596
+ Delete block
4597
+ </button>
4598
+ </div>
4599
+ </div>
4600
+ <div class="mt-4 space-y-4">
4601
+ ${blockFields}
4602
+ </div>
4603
+ </div>
4604
+ `;
4605
+ }
4606
+ function normalizeBlockField(fieldConfig, fieldName) {
4607
+ const type = fieldConfig?.type || "text";
4608
+ const label = fieldConfig?.title || fieldName;
4609
+ const required = fieldConfig?.required === true;
4610
+ const options = { ...fieldConfig };
4611
+ if (type === "select" && Array.isArray(fieldConfig?.enum)) {
4612
+ options.options = fieldConfig.enum.map((value, index) => ({
4613
+ value,
4614
+ label: fieldConfig.enumLabels?.[index] || value
4615
+ }));
4031
4616
  }
4032
- };
4617
+ return {
4618
+ type,
4619
+ label,
4620
+ required,
4621
+ defaultValue: fieldConfig?.default,
4622
+ options
4623
+ };
4624
+ }
4625
+ function getStructuredFieldScript() {
4626
+ return `
4627
+ ${getReadFieldValueScript()}
4628
+ <script>
4629
+ if (!window.__sonicStructuredFieldInit) {
4630
+ window.__sonicStructuredFieldInit = true;
4631
+
4632
+ function initializeStructuredFields() {
4633
+ const readFieldValue = window.sonicReadFieldValue;
4634
+
4635
+ const readStructuredValue = (container) => {
4636
+ const fields = Array.from(container.querySelectorAll('.structured-subfield'));
4637
+ if (fields.length === 1 && fields[0].dataset.structuredField === '__value') {
4638
+ return readFieldValue(fields[0]);
4639
+ }
4640
+
4641
+ return fields.reduce((acc, fieldWrapper) => {
4642
+ const fieldName = fieldWrapper.dataset.structuredField;
4643
+ if (!fieldName || fieldName === '__value') return acc;
4644
+ acc[fieldName] = readFieldValue(fieldWrapper);
4645
+ return acc;
4646
+ }, {});
4647
+ };
4648
+
4649
+ document.querySelectorAll('[data-structured-object]').forEach((container) => {
4650
+ if (container.dataset.structuredInitialized === 'true') {
4651
+ return;
4652
+ }
4653
+ container.dataset.structuredInitialized = 'true';
4654
+ const hiddenInput = container.querySelector('input[type="hidden"]');
4655
+
4656
+ const updateHiddenInput = () => {
4657
+ if (!hiddenInput) return;
4658
+ const value = readStructuredValue(container);
4659
+ hiddenInput.value = JSON.stringify(value);
4660
+ };
4661
+
4662
+ container.addEventListener('input', updateHiddenInput);
4663
+ container.addEventListener('change', updateHiddenInput);
4664
+ updateHiddenInput();
4665
+ });
4666
+
4667
+ document.querySelectorAll('[data-structured-array]').forEach((container) => {
4668
+ if (container.dataset.structuredInitialized === 'true') {
4669
+ return;
4670
+ }
4671
+ container.dataset.structuredInitialized = 'true';
4672
+ const list = container.querySelector('[data-structured-array-list]');
4673
+ const hiddenInput = container.querySelector('input[type="hidden"]');
4674
+ const template = container.querySelector('template[data-structured-array-template]');
4675
+
4676
+ const updateOrderLabels = () => {
4677
+ const items = Array.from(container.querySelectorAll('.structured-array-item'));
4678
+ items.forEach((item, index) => {
4679
+ const label = item.querySelector('[data-array-order-label]');
4680
+ if (label) {
4681
+ label.textContent = '#'+ (index + 1);
4682
+ }
4683
+
4684
+ const moveUpButton = item.querySelector('[data-action="move-up"]');
4685
+ if (moveUpButton instanceof HTMLButtonElement) {
4686
+ moveUpButton.disabled = index === 0;
4687
+ }
4688
+
4689
+ const moveDownButton = item.querySelector('[data-action="move-down"]');
4690
+ if (moveDownButton instanceof HTMLButtonElement) {
4691
+ moveDownButton.disabled = index === items.length - 1;
4692
+ }
4693
+ });
4694
+ };
4695
+
4696
+ const updateHiddenInput = () => {
4697
+ if (!hiddenInput || !list) return;
4698
+ const items = Array.from(list.querySelectorAll('.structured-array-item'));
4699
+ const values = items.map((item) => readStructuredValue(item));
4700
+ hiddenInput.value = JSON.stringify(values);
4701
+
4702
+ const emptyState = list.querySelector('[data-structured-empty]');
4703
+ if (emptyState) {
4704
+ emptyState.style.display = values.length === 0 ? 'block' : 'none';
4705
+ }
4706
+ updateOrderLabels();
4707
+ };
4708
+
4709
+ if (typeof window.initializeDragSortable === 'function' && list) {
4710
+ window.initializeDragSortable(list, {
4711
+ itemSelector: '.structured-array-item',
4712
+ handleSelector: '[data-action="drag-handle"]',
4713
+ onUpdate: updateHiddenInput
4714
+ });
4715
+ }
4716
+
4717
+ container.addEventListener('click', (event) => {
4718
+ const target = event.target;
4719
+ if (!(target instanceof Element)) return;
4720
+ const actionButton = target.closest('[data-action]');
4721
+ if (!actionButton || actionButton.hasAttribute('disabled')) return;
4722
+
4723
+ const action = actionButton.getAttribute('data-action');
4724
+
4725
+ if (action === 'add-item') {
4726
+ if (!list || !template) return;
4727
+ const nextIndex = list.querySelectorAll('.structured-array-item').length;
4728
+ const html = template.innerHTML.replace(/__INDEX__/g, String(nextIndex));
4729
+ list.insertAdjacentHTML('beforeend', html);
4730
+ if (typeof initializeTinyMCE === 'function') {
4731
+ initializeTinyMCE();
4732
+ }
4733
+ if (typeof window.initializeQuillEditors === 'function') {
4734
+ window.initializeQuillEditors();
4735
+ }
4736
+ if (typeof initializeMDXEditor === 'function') {
4737
+ initializeMDXEditor();
4738
+ }
4739
+ updateHiddenInput();
4740
+ return;
4741
+ }
4742
+
4743
+ const item = actionButton.closest('.structured-array-item');
4744
+ if (!item || !list) return;
4745
+
4746
+ if (action === 'remove-item') {
4747
+ item.remove();
4748
+ updateHiddenInput();
4749
+ return;
4750
+ }
4751
+
4752
+ if (action === 'move-up') {
4753
+ const previous = item.previousElementSibling;
4754
+ if (previous) {
4755
+ list.insertBefore(item, previous);
4756
+ updateHiddenInput();
4757
+ }
4758
+ return;
4759
+ }
4760
+
4761
+ if (action === 'move-down') {
4762
+ const next = item.nextElementSibling;
4763
+ if (next) {
4764
+ list.insertBefore(next, item);
4765
+ updateHiddenInput();
4766
+ }
4767
+ }
4768
+ });
4769
+
4770
+ container.addEventListener('input', (event) => {
4771
+ const target = event.target;
4772
+ if (!(target instanceof Element)) return;
4773
+ if (target.closest('[data-structured-array-list]')) {
4774
+ updateHiddenInput();
4775
+ }
4776
+ });
4777
+
4778
+ container.addEventListener('change', (event) => {
4779
+ const target = event.target;
4780
+ if (!(target instanceof Element)) return;
4781
+ if (target.closest('[data-structured-array-list]')) {
4782
+ updateHiddenInput();
4783
+ }
4784
+ });
4785
+
4786
+ updateHiddenInput();
4787
+ });
4788
+ }
4789
+
4790
+ window.initializeStructuredFields = initializeStructuredFields;
4791
+
4792
+ if (document.readyState === 'loading') {
4793
+ document.addEventListener('DOMContentLoaded', initializeStructuredFields);
4794
+ } else {
4795
+ initializeStructuredFields();
4796
+ }
4797
+
4798
+ document.addEventListener('htmx:afterSwap', function() {
4799
+ setTimeout(initializeStructuredFields, 50);
4800
+ });
4801
+ } else if (typeof window.initializeStructuredFields === 'function') {
4802
+ window.initializeStructuredFields();
4803
+ }
4804
+ </script>
4805
+ `;
4806
+ }
4807
+ function getBlocksFieldScript() {
4808
+ return `
4809
+ ${getReadFieldValueScript()}
4810
+ <script>
4811
+ if (!window.__sonicBlocksFieldInit) {
4812
+ window.__sonicBlocksFieldInit = true;
4813
+
4814
+ function initializeBlocksFields() {
4815
+ document.querySelectorAll('.blocks-field').forEach((container) => {
4816
+ if (container.dataset.blocksInitialized === 'true') {
4817
+ return;
4818
+ }
4819
+
4820
+ container.dataset.blocksInitialized = 'true';
4821
+ const list = container.querySelector('[data-blocks-list]');
4822
+ const hiddenInput = container.querySelector('input[type="hidden"]');
4823
+ const typeSelect = container.querySelector('[data-role="block-type-select"]');
4824
+ const discriminator = container.dataset.blocksDiscriminator || 'blockType';
4825
+
4826
+ const updateOrderLabels = () => {
4827
+ const items = Array.from(container.querySelectorAll('.blocks-item'));
4828
+ items.forEach((item, index) => {
4829
+ const label = item.querySelector('[data-block-order-label]');
4830
+ if (label) {
4831
+ label.textContent = '#'+ (index + 1);
4832
+ }
4833
+
4834
+ const moveUpButton = item.querySelector('[data-action="move-up"]');
4835
+ if (moveUpButton instanceof HTMLButtonElement) {
4836
+ moveUpButton.disabled = index === 0;
4837
+ }
4838
+
4839
+ const moveDownButton = item.querySelector('[data-action="move-down"]');
4840
+ if (moveDownButton instanceof HTMLButtonElement) {
4841
+ moveDownButton.disabled = index === items.length - 1;
4842
+ }
4843
+ });
4844
+ };
4845
+
4846
+ const readFieldValue = window.sonicReadFieldValue;
4847
+
4848
+ const readBlockItem = (item) => {
4849
+ if (item.dataset.blockRaw) {
4850
+ try {
4851
+ return JSON.parse(item.dataset.blockRaw);
4852
+ } catch (error) {
4853
+ return {};
4854
+ }
4855
+ }
4856
+
4857
+ const blockType = item.dataset.blockType;
4858
+ const data = {};
4859
+
4860
+ item.querySelectorAll('.blocks-subfield').forEach((fieldWrapper) => {
4861
+ const fieldName = fieldWrapper.dataset.blockField;
4862
+ if (!fieldName) {
4863
+ return;
4864
+ }
4865
+ data[fieldName] = readFieldValue(fieldWrapper);
4866
+ });
4867
+
4868
+ return { [discriminator]: blockType, ...data };
4869
+ };
4870
+
4871
+ const updateHiddenInput = () => {
4872
+ if (!hiddenInput || !list) return;
4873
+ const items = Array.from(list.querySelectorAll('.blocks-item, [data-block-raw]'));
4874
+ const blocksData = items.map((item) => readBlockItem(item));
4875
+ hiddenInput.value = JSON.stringify(blocksData);
4876
+
4877
+ const emptyState = list.querySelector('[data-blocks-empty]');
4878
+ if (emptyState) {
4879
+ emptyState.style.display = blocksData.length === 0 ? 'block' : 'none';
4880
+ }
4881
+ updateOrderLabels();
4882
+ };
4883
+
4884
+ const initializeEditors = () => {
4885
+ if (typeof initializeTinyMCE === 'function') {
4886
+ initializeTinyMCE();
4887
+ }
4888
+ if (typeof window.initializeQuillEditors === 'function') {
4889
+ window.initializeQuillEditors();
4890
+ }
4891
+ if (typeof initializeMDXEditor === 'function') {
4892
+ initializeMDXEditor();
4893
+ }
4894
+ };
4895
+
4896
+ if (typeof window.initializeDragSortable === 'function' && list) {
4897
+ window.initializeDragSortable(list, {
4898
+ itemSelector: '.blocks-item',
4899
+ handleSelector: '[data-action="drag-handle"]',
4900
+ onUpdate: updateHiddenInput
4901
+ });
4902
+ }
4903
+
4904
+ container.addEventListener('click', (event) => {
4905
+ const target = event.target;
4906
+ if (!(target instanceof Element)) return;
4907
+ const actionButton = target.closest('[data-action]');
4908
+ if (!actionButton) return;
4909
+
4910
+ if (actionButton.hasAttribute('disabled')) {
4911
+ return;
4912
+ }
4913
+
4914
+ const action = actionButton.getAttribute('data-action');
4915
+ if (action === 'add-block') {
4916
+ const blockType = typeSelect ? typeSelect.value : '';
4917
+ if (!blockType || !list) return;
4918
+ const template = container.querySelector('template[data-block-template="' + blockType + '"]');
4919
+ if (!template) return;
4920
+
4921
+ const nextIndex = list.querySelectorAll('.blocks-item').length;
4922
+ const html = template.innerHTML.replace(/__INDEX__/g, String(nextIndex));
4923
+ list.insertAdjacentHTML('beforeend', html);
4924
+ if (typeSelect) {
4925
+ typeSelect.value = '';
4926
+ }
4927
+ initializeEditors();
4928
+ if (typeof window.initializeStructuredFields === 'function') {
4929
+ window.initializeStructuredFields();
4930
+ }
4931
+ updateHiddenInput();
4932
+ return;
4933
+ }
4934
+
4935
+ const item = actionButton.closest('.blocks-item');
4936
+ if (!item || !list) return;
4937
+
4938
+ if (action === 'remove-block') {
4939
+ item.remove();
4940
+ updateHiddenInput();
4941
+ return;
4942
+ }
4943
+
4944
+ if (action === 'move-up') {
4945
+ const previous = item.previousElementSibling;
4946
+ if (previous) {
4947
+ list.insertBefore(item, previous);
4948
+ updateHiddenInput();
4949
+ }
4950
+ return;
4951
+ }
4952
+
4953
+ if (action === 'move-down') {
4954
+ const next = item.nextElementSibling;
4955
+ if (next) {
4956
+ list.insertBefore(next, item);
4957
+ updateHiddenInput();
4958
+ }
4959
+ }
4960
+ });
4961
+
4962
+ container.addEventListener('input', (event) => {
4963
+ const target = event.target;
4964
+ if (!(target instanceof Element)) return;
4965
+ if (target.closest('[data-blocks-list]')) {
4966
+ updateHiddenInput();
4967
+ }
4968
+ });
4969
+
4970
+ container.addEventListener('change', (event) => {
4971
+ const target = event.target;
4972
+ if (!(target instanceof Element)) return;
4973
+ if (target.closest('[data-blocks-list]')) {
4974
+ updateHiddenInput();
4975
+ }
4976
+ });
4977
+
4978
+ updateHiddenInput();
4979
+ });
4980
+ }
4981
+
4982
+ window.initializeBlocksFields = initializeBlocksFields;
4983
+
4984
+ if (document.readyState === 'loading') {
4985
+ document.addEventListener('DOMContentLoaded', initializeBlocksFields);
4986
+ } else {
4987
+ initializeBlocksFields();
4988
+ }
4989
+
4990
+ document.addEventListener('htmx:afterSwap', function() {
4991
+ setTimeout(initializeBlocksFields, 50);
4992
+ });
4993
+ } else if (typeof window.initializeBlocksFields === 'function') {
4994
+ window.initializeBlocksFields();
4995
+ }
4996
+ </script>
4997
+ `;
4998
+ }
4999
+ function escapeHtml2(text) {
5000
+ if (typeof text !== "string") return String(text || "");
5001
+ return text.replace(/[&<>"']/g, (char) => ({
5002
+ "&": "&amp;",
5003
+ "<": "&lt;",
5004
+ ">": "&gt;",
5005
+ '"': "&quot;",
5006
+ "'": "&#39;"
5007
+ })[char] || char);
5008
+ }
4033
5009
 
4034
5010
  // src/plugins/available/tinymce-plugin/index.ts
4035
5011
  var builder = PluginBuilder.create({
@@ -4573,17 +5549,24 @@ function renderContentFormPage(data) {
4573
5549
  const coreFieldsHTML = coreFields.sort((a, b) => a.field_order - b.field_order).map((field) => renderDynamicField(field, {
4574
5550
  value: getFieldValue(field.field_name),
4575
5551
  errors: data.validationErrors?.[field.field_name] || [],
4576
- pluginStatuses
5552
+ pluginStatuses,
5553
+ collectionId: data.collection.id,
5554
+ contentId: data.id
5555
+ // Pass content ID when editing
4577
5556
  }));
4578
5557
  const contentFieldsHTML = contentFields.sort((a, b) => a.field_order - b.field_order).map((field) => renderDynamicField(field, {
4579
5558
  value: getFieldValue(field.field_name),
4580
5559
  errors: data.validationErrors?.[field.field_name] || [],
4581
- pluginStatuses
5560
+ pluginStatuses,
5561
+ collectionId: data.collection.id,
5562
+ contentId: data.id
4582
5563
  }));
4583
5564
  const metaFieldsHTML = metaFields.sort((a, b) => a.field_order - b.field_order).map((field) => renderDynamicField(field, {
4584
5565
  value: getFieldValue(field.field_name),
4585
5566
  errors: data.validationErrors?.[field.field_name] || [],
4586
- pluginStatuses
5567
+ pluginStatuses,
5568
+ collectionId: data.collection.id,
5569
+ contentId: data.id
4587
5570
  }));
4588
5571
  const pageContent = `
4589
5572
  <div class="space-y-6">
@@ -4969,71 +5952,371 @@ function renderContentFormPage(data) {
4969
5952
  }
4970
5953
  }
4971
5954
 
4972
- // Close modal
4973
- closeMediaSelector();
4974
- }
5955
+ // Close modal
5956
+ closeMediaSelector();
5957
+ }
5958
+
5959
+ function clearMediaField(fieldId) {
5960
+ const hiddenInput = document.getElementById(fieldId);
5961
+ const preview = document.getElementById(fieldId + '-preview');
5962
+
5963
+ if (hiddenInput) {
5964
+ hiddenInput.value = '';
5965
+ }
5966
+
5967
+ if (preview) {
5968
+ // Clear all children if it's a grid, or hide it
5969
+ if (preview.classList.contains('media-preview-grid')) {
5970
+ preview.innerHTML = '';
5971
+ }
5972
+ preview.classList.add('hidden');
5973
+ }
5974
+ }
5975
+
5976
+ // Global function to remove a single media from multiple selection
5977
+ window.removeMediaFromMultiple = function(fieldId, urlToRemove) {
5978
+ const hiddenInput = document.getElementById(fieldId);
5979
+ if (!hiddenInput) return;
5980
+
5981
+ const values = hiddenInput.value.split(',').filter(url => url !== urlToRemove);
5982
+ hiddenInput.value = values.join(',');
5983
+
5984
+ // Remove preview item
5985
+ const previewItem = document.querySelector(\`[data-url="\${urlToRemove}"]\`);
5986
+ if (previewItem) {
5987
+ previewItem.remove();
5988
+ }
5989
+
5990
+ // Hide preview grid if empty
5991
+ if (values.length === 0) {
5992
+ const preview = document.getElementById(fieldId + '-preview');
5993
+ if (preview) {
5994
+ preview.classList.add('hidden');
5995
+ }
5996
+ }
5997
+ };
5998
+
5999
+ // Global function called by media selector buttons
6000
+ window.selectMediaFile = function(mediaId, mediaUrl, filename) {
6001
+ if (!currentMediaFieldId) {
6002
+ console.error('No field ID set for media selection');
6003
+ return;
6004
+ }
6005
+
6006
+ const fieldId = currentMediaFieldId;
6007
+
6008
+ // Set the hidden input value to the media URL (not ID)
6009
+ const hiddenInput = document.getElementById(fieldId);
6010
+ if (hiddenInput) {
6011
+ hiddenInput.value = mediaUrl;
6012
+ }
6013
+
6014
+ // Update the preview
6015
+ const preview = document.getElementById(fieldId + '-preview');
6016
+ if (preview) {
6017
+ preview.innerHTML = \`<img src="\${mediaUrl}" alt="\${filename}" class="w-32 h-32 object-cover rounded-lg border border-white/20">\`;
6018
+ preview.classList.remove('hidden');
6019
+ }
6020
+
6021
+ // Show the remove button by finding the media actions container and updating it
6022
+ const mediaField = hiddenInput?.closest('.media-field-container');
6023
+ if (mediaField) {
6024
+ const actionsDiv = mediaField.querySelector('.media-actions');
6025
+ if (actionsDiv && !actionsDiv.querySelector('button:has-text("Remove")')) {
6026
+ const removeBtn = document.createElement('button');
6027
+ removeBtn.type = 'button';
6028
+ removeBtn.onclick = () => clearMediaField(fieldId);
6029
+ removeBtn.className = 'inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all';
6030
+ removeBtn.textContent = 'Remove';
6031
+ actionsDiv.appendChild(removeBtn);
6032
+ }
6033
+ }
6034
+
6035
+ // DON'T close the modal - let user click OK button
6036
+ // Visual feedback: highlight the selected item
6037
+ document.querySelectorAll('#media-selector-grid [data-media-id]').forEach(el => {
6038
+ el.classList.remove('ring-2', 'ring-lime-500', 'dark:ring-lime-400');
6039
+ });
6040
+ const selectedItem = document.querySelector(\`#media-selector-grid [data-media-id="\${mediaId}"]\`);
6041
+ if (selectedItem) {
6042
+ selectedItem.classList.add('ring-2', 'ring-lime-500', 'dark:ring-lime-400');
6043
+ }
6044
+ };
6045
+
6046
+ function setMediaField(fieldId, mediaUrl) {
6047
+ document.getElementById(fieldId).value = mediaUrl;
6048
+ const preview = document.getElementById(fieldId + '-preview');
6049
+ preview.innerHTML = \`<img src="\${mediaUrl}" alt="Selected media" class="w-32 h-32 object-cover rounded-lg ring-1 ring-zinc-950/10 dark:ring-white/10">\`;
6050
+ preview.classList.remove('hidden');
6051
+
6052
+ // Close modal
6053
+ document.querySelector('.fixed.inset-0')?.remove();
6054
+ }
6055
+
6056
+ // Reference field functions
6057
+ let currentReferenceFieldId = null;
6058
+ let referenceSearchTimeout = null;
6059
+
6060
+ function getReferenceContainer(fieldId) {
6061
+ const input = document.getElementById(fieldId);
6062
+ return input ? input.closest('[data-reference-field]') : null;
6063
+ }
6064
+
6065
+ function getReferenceCollections(container) {
6066
+ if (!container) return [];
6067
+ const rawCollections = container.dataset.referenceCollections || '';
6068
+ const collections = rawCollections
6069
+ .split(',')
6070
+ .map((value) => value.trim())
6071
+ .filter(Boolean);
6072
+ if (collections.length > 0) {
6073
+ return collections;
6074
+ }
6075
+ const singleCollection = container.dataset.referenceCollection;
6076
+ return singleCollection ? [singleCollection] : [];
6077
+ }
6078
+
6079
+ async function fetchReferenceItems(collections, search = '', limit = 20) {
6080
+ const params = new URLSearchParams({ limit: String(limit) });
6081
+ collections.forEach((collection) => params.append('collection', collection));
6082
+ if (search) {
6083
+ params.set('search', search);
6084
+ }
6085
+ const response = await fetch('/admin/api/references?' + params.toString());
6086
+ if (!response.ok) {
6087
+ throw new Error('Failed to load references');
6088
+ }
6089
+ const data = await response.json();
6090
+ return data?.data || [];
6091
+ }
6092
+
6093
+ async function fetchReferenceById(collections, id) {
6094
+ if (!id) return null;
6095
+ const params = new URLSearchParams({ id });
6096
+ collections.forEach((collection) => params.append('collection', collection));
6097
+ const response = await fetch('/admin/api/references?' + params.toString());
6098
+ if (!response.ok) {
6099
+ return null;
6100
+ }
6101
+ const data = await response.json();
6102
+ return data?.data || null;
6103
+ }
6104
+
6105
+ function renderReferenceDisplay(container, item, fallbackMessage = 'No reference selected.') {
6106
+ const display = container.querySelector('[data-reference-display]');
6107
+ const removeButton = container.querySelector('[data-reference-clear]');
6108
+ if (!display) return;
6109
+
6110
+ display.innerHTML = '';
6111
+
6112
+ if (!item) {
6113
+ display.textContent = fallbackMessage;
6114
+ if (removeButton) {
6115
+ removeButton.disabled = true;
6116
+ }
6117
+ return;
6118
+ }
6119
+
6120
+ const title = item.title || item.slug || item.id || 'Untitled';
6121
+ const titleEl = document.createElement('div');
6122
+ titleEl.className = 'font-medium text-zinc-900 dark:text-white';
6123
+ titleEl.textContent = title;
6124
+
6125
+ display.appendChild(titleEl);
6126
+
6127
+ const metaRow = document.createElement('div');
6128
+ metaRow.className = 'mt-1 flex flex-wrap items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400';
6129
+
6130
+ if (item.collection?.display_name || item.collection?.name) {
6131
+ const collectionLabel = document.createElement('span');
6132
+ collectionLabel.className = 'inline-flex items-center rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-600 dark:bg-white/10 dark:text-zinc-200';
6133
+ collectionLabel.textContent = item.collection.display_name || item.collection.name;
6134
+ metaRow.appendChild(collectionLabel);
6135
+ }
6136
+
6137
+ if (item.slug) {
6138
+ const slugEl = document.createElement('span');
6139
+ slugEl.textContent = item.slug;
6140
+ metaRow.appendChild(slugEl);
6141
+ }
6142
+
6143
+ if (metaRow.childElementCount > 0) {
6144
+ display.appendChild(metaRow);
6145
+ }
6146
+
6147
+ if (removeButton) {
6148
+ removeButton.disabled = false;
6149
+ }
6150
+ }
6151
+
6152
+ function updateReferenceField(fieldId, item) {
6153
+ const input = document.getElementById(fieldId);
6154
+ const container = getReferenceContainer(fieldId);
6155
+ if (!input || !container) return;
6156
+
6157
+ input.value = item?.id || '';
6158
+ renderReferenceDisplay(container, item, 'No reference selected.');
6159
+ input.dispatchEvent(new Event('input', { bubbles: true }));
6160
+ input.dispatchEvent(new Event('change', { bubbles: true }));
6161
+ }
6162
+
6163
+ function clearReferenceField(fieldId) {
6164
+ updateReferenceField(fieldId, null);
6165
+ }
6166
+
6167
+ function closeReferenceSelector() {
6168
+ const modal = document.getElementById('reference-selector-modal');
6169
+ if (modal) {
6170
+ modal.remove();
6171
+ }
6172
+ currentReferenceFieldId = null;
6173
+ }
6174
+
6175
+ function openReferenceSelector(fieldId) {
6176
+ const container = getReferenceContainer(fieldId);
6177
+ const collections = getReferenceCollections(container);
6178
+ if (!container || collections.length === 0) {
6179
+ console.error('Reference collection is missing for field', fieldId);
6180
+ return;
6181
+ }
6182
+
6183
+ currentReferenceFieldId = fieldId;
6184
+
6185
+ const modal = document.createElement('div');
6186
+ modal.className = 'fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50';
6187
+ modal.id = 'reference-selector-modal';
6188
+ modal.innerHTML = \`
6189
+ <div class="rounded-xl bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 p-6 w-full max-w-3xl max-h-[90vh] overflow-y-auto">
6190
+ <div class="flex items-center justify-between gap-3">
6191
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Select Reference</h3>
6192
+ <button
6193
+ type="button"
6194
+ onclick="closeReferenceSelector()"
6195
+ class="rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
6196
+ aria-label="Close"
6197
+ >
6198
+ \u2715
6199
+ </button>
6200
+ </div>
6201
+ <div class="mt-4">
6202
+ <input
6203
+ type="search"
6204
+ id="reference-search-input"
6205
+ placeholder="Search by title or slug..."
6206
+ class="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
6207
+ >
6208
+ </div>
6209
+ <div id="reference-results" class="mt-4 space-y-2"></div>
6210
+ <div class="mt-4 flex justify-end">
6211
+ <button
6212
+ type="button"
6213
+ onclick="closeReferenceSelector()"
6214
+ class="rounded-lg bg-zinc-950 dark:bg-white px-4 py-2 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
6215
+ >
6216
+ Close
6217
+ </button>
6218
+ </div>
6219
+ </div>
6220
+ \`;
6221
+
6222
+ document.body.appendChild(modal);
6223
+
6224
+ const resultsContainer = modal.querySelector('#reference-results');
6225
+ const searchInput = modal.querySelector('#reference-search-input');
6226
+
6227
+ const renderResults = (items) => {
6228
+ resultsContainer.innerHTML = '';
6229
+ if (!items || items.length === 0) {
6230
+ resultsContainer.innerHTML = '<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-white/10 dark:text-zinc-400">No items found.</div>';
6231
+ return;
6232
+ }
6233
+
6234
+ const selectedId = document.getElementById(fieldId)?.value;
6235
+
6236
+ items.forEach((item) => {
6237
+ const button = document.createElement('button');
6238
+ button.type = 'button';
6239
+ button.className = 'w-full text-left rounded-lg border border-zinc-200 px-4 py-3 text-sm text-zinc-700 hover:bg-zinc-50 dark:border-white/10 dark:text-zinc-200 dark:hover:bg-white/5';
6240
+ if (item.id === selectedId) {
6241
+ button.classList.add('ring-2', 'ring-cyan-500', 'dark:ring-cyan-400');
6242
+ }
6243
+
6244
+ const title = item.title || item.slug || item.id || 'Untitled';
6245
+ const titleEl = document.createElement('div');
6246
+ titleEl.className = 'font-medium text-zinc-900 dark:text-white';
6247
+ titleEl.textContent = title;
4975
6248
 
4976
- function clearMediaField(fieldId) {
4977
- document.getElementById(fieldId).value = '';
4978
- document.getElementById(fieldId + '-preview').classList.add('hidden');
4979
- }
6249
+ button.appendChild(titleEl);
4980
6250
 
4981
- // Global function called by media selector buttons
4982
- window.selectMediaFile = function(mediaId, mediaUrl, filename) {
4983
- if (!currentMediaFieldId) {
4984
- console.error('No field ID set for media selection');
4985
- return;
4986
- }
6251
+ const metaRow = document.createElement('div');
6252
+ metaRow.className = 'mt-1 flex flex-wrap items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400';
4987
6253
 
4988
- const fieldId = currentMediaFieldId;
6254
+ if (item.collection?.display_name || item.collection?.name) {
6255
+ const collectionLabel = document.createElement('span');
6256
+ collectionLabel.className = 'inline-flex items-center rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-600 dark:bg-white/10 dark:text-zinc-200';
6257
+ collectionLabel.textContent = item.collection.display_name || item.collection.name;
6258
+ metaRow.appendChild(collectionLabel);
6259
+ }
4989
6260
 
4990
- // Set the hidden input value to the media URL (not ID)
4991
- const hiddenInput = document.getElementById(fieldId);
4992
- if (hiddenInput) {
4993
- hiddenInput.value = mediaUrl;
4994
- }
6261
+ if (item.slug) {
6262
+ const slugEl = document.createElement('span');
6263
+ slugEl.textContent = item.slug;
6264
+ metaRow.appendChild(slugEl);
6265
+ }
4995
6266
 
4996
- // Update the preview
4997
- const preview = document.getElementById(fieldId + '-preview');
4998
- if (preview) {
4999
- preview.innerHTML = \`<img src="\${mediaUrl}" alt="\${filename}" class="w-32 h-32 object-cover rounded-lg border border-white/20">\`;
5000
- preview.classList.remove('hidden');
5001
- }
6267
+ if (metaRow.childElementCount > 0) {
6268
+ button.appendChild(metaRow);
6269
+ }
5002
6270
 
5003
- // Show the remove button by finding the media actions container and updating it
5004
- const mediaField = hiddenInput?.closest('.media-field-container');
5005
- if (mediaField) {
5006
- const actionsDiv = mediaField.querySelector('.media-actions');
5007
- if (actionsDiv && !actionsDiv.querySelector('button:has-text("Remove")')) {
5008
- const removeBtn = document.createElement('button');
5009
- removeBtn.type = 'button';
5010
- removeBtn.onclick = () => clearMediaField(fieldId);
5011
- removeBtn.className = 'inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all';
5012
- removeBtn.textContent = 'Remove';
5013
- actionsDiv.appendChild(removeBtn);
6271
+ button.addEventListener('click', () => {
6272
+ updateReferenceField(fieldId, item);
6273
+ closeReferenceSelector();
6274
+ });
6275
+
6276
+ resultsContainer.appendChild(button);
6277
+ });
6278
+ };
6279
+
6280
+ const loadResults = async (searchValue = '') => {
6281
+ try {
6282
+ const items = await fetchReferenceItems(collections, searchValue);
6283
+ renderResults(items);
6284
+ } catch (error) {
6285
+ resultsContainer.innerHTML = '<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">Failed to load references.</div>';
5014
6286
  }
5015
- }
6287
+ };
5016
6288
 
5017
- // DON'T close the modal - let user click OK button
5018
- // Visual feedback: highlight the selected item
5019
- document.querySelectorAll('#media-selector-grid [data-media-id]').forEach(el => {
5020
- el.classList.remove('ring-2', 'ring-lime-500', 'dark:ring-lime-400');
6289
+ loadResults();
6290
+
6291
+ searchInput.addEventListener('input', () => {
6292
+ if (referenceSearchTimeout) {
6293
+ clearTimeout(referenceSearchTimeout);
6294
+ }
6295
+ referenceSearchTimeout = setTimeout(() => {
6296
+ loadResults(searchInput.value.trim());
6297
+ }, 250);
5021
6298
  });
5022
- const selectedItem = document.querySelector(\`#media-selector-grid [data-media-id="\${mediaId}"]\`);
5023
- if (selectedItem) {
5024
- selectedItem.classList.add('ring-2', 'ring-lime-500', 'dark:ring-lime-400');
5025
- }
5026
- };
6299
+ }
5027
6300
 
5028
- function setMediaField(fieldId, mediaUrl) {
5029
- document.getElementById(fieldId).value = mediaUrl;
5030
- const preview = document.getElementById(fieldId + '-preview');
5031
- preview.innerHTML = \`<img src="\${mediaUrl}" alt="Selected media" class="w-32 h-32 object-cover rounded-lg ring-1 ring-zinc-950/10 dark:ring-white/10">\`;
5032
- preview.classList.remove('hidden');
6301
+ document.addEventListener('DOMContentLoaded', () => {
6302
+ document.querySelectorAll('[data-reference-field]').forEach(async (container) => {
6303
+ const input = container.querySelector('input[type="hidden"]');
6304
+ const collections = getReferenceCollections(container);
6305
+ if (!input || collections.length === 0) return;
5033
6306
 
5034
- // Close modal
5035
- document.querySelector('.fixed.inset-0')?.remove();
5036
- }
6307
+ if (!input.value) {
6308
+ renderReferenceDisplay(container, null, 'No reference selected.');
6309
+ return;
6310
+ }
6311
+
6312
+ const item = await fetchReferenceById(collections, input.value);
6313
+ if (item) {
6314
+ renderReferenceDisplay(container, item);
6315
+ } else {
6316
+ renderReferenceDisplay(container, null, 'Reference not found.');
6317
+ }
6318
+ });
6319
+ });
5037
6320
 
5038
6321
  // Custom select options
5039
6322
  function addCustomOption(input, selectId) {
@@ -5200,7 +6483,7 @@ function renderContentListPage(data) {
5200
6483
  if (data.search) urlParams.set("search", data.search);
5201
6484
  if (data.page && data.page !== 1) urlParams.set("page", data.page.toString());
5202
6485
  const currentParams = urlParams.toString();
5203
- const hasActiveFilters = data.modelName !== "all" || data.status !== "all" || !!data.search;
6486
+ data.modelName !== "all" || data.status !== "all" || !!data.search;
5204
6487
  const filterBarData = {
5205
6488
  filters: [
5206
6489
  {
@@ -5230,6 +6513,11 @@ function renderContentListPage(data) {
5230
6513
  }
5231
6514
  ],
5232
6515
  actions: [
6516
+ {
6517
+ label: "Advanced Search",
6518
+ className: "btn-primary",
6519
+ onclick: "openAdvancedSearch()"
6520
+ },
5233
6521
  {
5234
6522
  label: "Refresh",
5235
6523
  className: "btn-secondary",
@@ -5381,12 +6669,57 @@ function renderContentListPage(data) {
5381
6669
  <div class="relative bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 rounded-xl">
5382
6670
  <div class="px-6 py-5">
5383
6671
  <div class="flex items-center justify-between">
5384
- <div class="flex items-center space-x-4">
5385
- <!-- Search Input -->
6672
+ <div class="flex items-center space-x-4 flex-1">
6673
+ <!-- Model Filter -->
6674
+ <div>
6675
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Model</label>
6676
+ <div class="grid grid-cols-1">
6677
+ <select
6678
+ name="model"
6679
+ onchange="updateContentFilters('model', this.value)"
6680
+ class="col-start-1 row-start-1 w-full appearance-none rounded-lg bg-white/5 dark:bg-white/5 py-2 pl-3 pr-8 text-sm text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-cyan-500/30 dark:outline-cyan-400/30 *:bg-white dark:*:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cyan-500 dark:focus-visible:outline-cyan-400 min-w-40"
6681
+ >
6682
+ <option value="all" ${data.modelName === "all" ? "selected" : ""}>All Models</option>
6683
+ ${data.models.map((model) => `
6684
+ <option value="${model.name}" ${data.modelName === model.name ? "selected" : ""}>
6685
+ ${model.displayName}
6686
+ </option>
6687
+ `).join("")}
6688
+ </select>
6689
+ <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-cyan-600 dark:text-cyan-400 sm:size-4">
6690
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
6691
+ </svg>
6692
+ </div>
6693
+ </div>
6694
+
6695
+ <!-- Status Filter -->
5386
6696
  <div>
6697
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Status</label>
6698
+ <div class="grid grid-cols-1">
6699
+ <select
6700
+ name="status"
6701
+ onchange="updateContentFilters('status', this.value)"
6702
+ class="col-start-1 row-start-1 w-full appearance-none rounded-lg bg-white/5 dark:bg-white/5 py-2 pl-3 pr-8 text-sm text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-cyan-500/30 dark:outline-cyan-400/30 *:bg-white dark:*:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cyan-500 dark:focus-visible:outline-cyan-400 min-w-40"
6703
+ >
6704
+ <option value="all" ${data.status === "all" ? "selected" : ""}>All Status</option>
6705
+ <option value="draft" ${data.status === "draft" ? "selected" : ""}>Draft</option>
6706
+ <option value="review" ${data.status === "review" ? "selected" : ""}>Under Review</option>
6707
+ <option value="scheduled" ${data.status === "scheduled" ? "selected" : ""}>Scheduled</option>
6708
+ <option value="published" ${data.status === "published" ? "selected" : ""}>Published</option>
6709
+ <option value="archived" ${data.status === "archived" ? "selected" : ""}>Archived</option>
6710
+ <option value="deleted" ${data.status === "deleted" ? "selected" : ""}>Deleted</option>
6711
+ </select>
6712
+ <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-cyan-600 dark:text-cyan-400 sm:size-4">
6713
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
6714
+ </svg>
6715
+ </div>
6716
+ </div>
6717
+
6718
+ <!-- Search Input -->
6719
+ <div class="flex-1 max-w-md">
5387
6720
  <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Search</label>
5388
6721
  <form onsubmit="performContentSearch(event)" class="flex items-center space-x-2">
5389
- <div class="relative group">
6722
+ <div class="relative group flex-1">
5390
6723
  <input
5391
6724
  type="text"
5392
6725
  name="search"
@@ -5394,7 +6727,7 @@ function renderContentListPage(data) {
5394
6727
  value="${data.search || ""}"
5395
6728
  oninput="toggleContentClearButton()"
5396
6729
  placeholder="Search content..."
5397
- class="rounded-full bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm px-4 py-2.5 pl-11 pr-10 text-sm w-72 text-zinc-950 dark:text-white border-2 border-cyan-200/50 dark:border-cyan-700/50 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:border-cyan-500 dark:focus:border-cyan-400 focus:bg-white dark:focus:bg-zinc-800 focus:shadow-lg focus:shadow-cyan-500/20 dark:focus:shadow-cyan-400/20 transition-all duration-300"
6730
+ class="w-full rounded-full bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm px-4 py-2.5 pl-11 pr-10 text-sm text-zinc-950 dark:text-white border-2 border-cyan-200/50 dark:border-cyan-700/50 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:border-cyan-500 dark:focus:border-cyan-400 focus:bg-white dark:focus:bg-zinc-800 focus:shadow-lg focus:shadow-cyan-500/20 dark:focus:shadow-cyan-400/20 transition-all duration-300"
5398
6731
  >
5399
6732
  <div class="absolute left-3.5 top-2.5 flex items-center justify-center w-5 h-5 rounded-full bg-gradient-to-br from-cyan-400 to-blue-500 dark:from-cyan-300 dark:to-blue-400 opacity-90 group-focus-within:opacity-100 transition-opacity">
5400
6733
  <svg class="h-3 w-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
@@ -5466,57 +6799,6 @@ function renderContentListPage(data) {
5466
6799
  }
5467
6800
  </script>
5468
6801
  </div>
5469
-
5470
- ${filterBarData.filters.map((filter) => {
5471
- const selectedOption = filter.options.find((opt) => opt.selected);
5472
- const selectedColor = selectedOption?.color || "cyan";
5473
- const colorMap = {
5474
- "cyan": "bg-cyan-400 dark:bg-cyan-400",
5475
- "lime": "bg-lime-400 dark:bg-lime-400",
5476
- "pink": "bg-pink-400 dark:bg-pink-400",
5477
- "purple": "bg-purple-400 dark:bg-purple-400",
5478
- "amber": "bg-amber-400 dark:bg-amber-400",
5479
- "zinc": "bg-zinc-400 dark:bg-zinc-400"
5480
- };
5481
- return `
5482
- <div>
5483
- <label class="block text-sm/6 font-medium text-zinc-950 dark:text-white">${filter.label}</label>
5484
- <div class="mt-2 grid grid-cols-1">
5485
- <div class="col-start-1 row-start-1 flex items-center gap-3 pl-3 pr-8 pointer-events-none">
5486
- ${filter.name === "status" ? `<span class="inline-block size-2 shrink-0 rounded-full border border-transparent ${colorMap[selectedColor]}"></span>` : ""}
5487
- </div>
5488
- <select
5489
- name="${filter.name}"
5490
- onchange="updateContentFilters('${filter.name}', this.value)"
5491
- class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white/5 dark:bg-white/5 py-1.5 ${filter.name === "status" ? "pl-8" : "pl-3"} pr-8 text-base text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-cyan-500/30 dark:outline-cyan-400/30 *:bg-white dark:*:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cyan-500 dark:focus-visible:outline-cyan-400 sm:text-sm/6 min-w-48"
5492
- >
5493
- ${filter.options.map((opt) => `
5494
- <option value="${opt.value}" ${opt.selected ? "selected" : ""}>${opt.label}</option>
5495
- `).join("")}
5496
- </select>
5497
- <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-cyan-600 dark:text-cyan-400 sm:size-4">
5498
- <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
5499
- </svg>
5500
- </div>
5501
- </div>
5502
- `;
5503
- }).join("")}
5504
-
5505
- <!-- Clear Filters Button -->
5506
- ${hasActiveFilters ? `
5507
- <div>
5508
- <label class="block text-sm/6 font-medium text-zinc-950 dark:text-white mb-2">&nbsp;</label>
5509
- <button
5510
- onclick="clearAllFilters()"
5511
- class="inline-flex items-center gap-x-1.5 px-3 py-2 bg-pink-50 dark:bg-pink-500/10 text-pink-700 dark:text-pink-300 text-sm font-medium rounded-md ring-1 ring-inset ring-pink-600/20 dark:ring-pink-500/20 hover:bg-pink-100 dark:hover:bg-pink-500/20 transition-colors"
5512
- >
5513
- <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
5514
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
5515
- </svg>
5516
- Clear Filters
5517
- </button>
5518
- </div>
5519
- ` : ""}
5520
6802
  </div>
5521
6803
  <div class="flex items-center gap-x-3">
5522
6804
  <span class="text-sm/6 font-medium text-zinc-700 dark:text-zinc-300 px-3 py-1.5 rounded-full bg-white/60 dark:bg-zinc-800/60 backdrop-blur-sm">${data.totalItems} ${data.totalItems === 1 ? "item" : "items"}</span>
@@ -5760,66 +7042,352 @@ function renderContentListPage(data) {
5760
7042
  const menu = document.getElementById('bulk-actions-menu');
5761
7043
  menu.classList.add('hidden');
5762
7044
 
5763
- fetch('/admin/content/bulk-action', {
5764
- method: 'POST',
5765
- headers: {
5766
- 'Content-Type': 'application/json'
5767
- },
5768
- body: JSON.stringify({
5769
- action: currentBulkAction,
5770
- ids: currentSelectedIds
5771
- })
5772
- })
5773
- .then(res => res.json())
5774
- .then(data => {
5775
- if (data.success) {
5776
- location.reload();
5777
- } else {
5778
- alert('Error: ' + (data.error || 'Unknown error'));
7045
+ fetch('/admin/content/bulk-action', {
7046
+ method: 'POST',
7047
+ headers: {
7048
+ 'Content-Type': 'application/json'
7049
+ },
7050
+ body: JSON.stringify({
7051
+ action: currentBulkAction,
7052
+ ids: currentSelectedIds
7053
+ })
7054
+ })
7055
+ .then(res => res.json())
7056
+ .then(data => {
7057
+ if (data.success) {
7058
+ location.reload();
7059
+ } else {
7060
+ alert('Error: ' + (data.error || 'Unknown error'));
7061
+ }
7062
+ })
7063
+ .catch(err => {
7064
+ console.error('Bulk action error:', err);
7065
+ alert('Failed to perform bulk action');
7066
+ })
7067
+ .finally(() => {
7068
+ // Clear context
7069
+ currentBulkAction = null;
7070
+ currentSelectedIds = [];
7071
+ });
7072
+ }
7073
+
7074
+ // Helper to get action text for display
7075
+ function getActionText(action) {
7076
+ const actionCount = currentSelectedIds.length;
7077
+ switch(action) {
7078
+ case 'publish':
7079
+ return \`publish \${actionCount} item\${actionCount > 1 ? 's' : ''}\`;
7080
+ case 'draft':
7081
+ return \`move \${actionCount} item\${actionCount > 1 ? 's' : ''} to draft\`;
7082
+ case 'delete':
7083
+ return \`delete \${actionCount} item\${actionCount > 1 ? 's' : ''}\`;
7084
+ default:
7085
+ return \`perform action on \${actionCount} item\${actionCount > 1 ? 's' : ''}\`;
7086
+ }
7087
+ }
7088
+
7089
+ </script>
7090
+
7091
+ <!-- Confirmation Dialog for Bulk Actions -->
7092
+ ${renderConfirmationDialog({
7093
+ id: "bulk-action-confirm",
7094
+ title: "Confirm Bulk Action",
7095
+ message: "Are you sure you want to perform this action? This operation will affect multiple items.",
7096
+ confirmText: "Confirm",
7097
+ cancelText: "Cancel",
7098
+ confirmClass: "bg-blue-500 hover:bg-blue-400",
7099
+ iconColor: "blue",
7100
+ onConfirm: "executeBulkAction()"
7101
+ })}
7102
+
7103
+ <!-- Confirmation Dialog Script -->
7104
+ ${getConfirmationDialogScript()}
7105
+
7106
+ <!-- Advanced Search Modal -->
7107
+ <div id="advancedSearchModal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
7108
+ <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
7109
+ <!-- Background overlay -->
7110
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="closeAdvancedSearch()"></div>
7111
+
7112
+ <!-- Modal panel -->
7113
+ <div class="inline-block align-bottom bg-white dark:bg-zinc-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
7114
+ <div class="bg-white dark:bg-zinc-900 px-4 pt-5 pb-4 sm:p-6">
7115
+ <!-- Header -->
7116
+ <div class="flex items-center justify-between mb-4">
7117
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white" id="modal-title">
7118
+ \u{1F50D} Advanced Search
7119
+ </h3>
7120
+ <button onclick="closeAdvancedSearch()" class="text-zinc-400 hover:text-zinc-500 dark:hover:text-zinc-300">
7121
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
7122
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
7123
+ </svg>
7124
+ </button>
7125
+ </div>
7126
+
7127
+ <!-- Search Form -->
7128
+ <form id="advancedSearchForm" class="space-y-4">
7129
+ <!-- Search Input -->
7130
+ <div>
7131
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Search Query</label>
7132
+ <div class="relative">
7133
+ <input
7134
+ type="text"
7135
+ id="searchQuery"
7136
+ name="query"
7137
+ placeholder="Enter your search query..."
7138
+ class="w-full rounded-lg bg-white dark:bg-white/5 px-4 py-3 text-sm text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 focus:ring-2 focus:ring-indigo-500"
7139
+ autocomplete="off"
7140
+ />
7141
+ <div id="searchSuggestions" class="hidden absolute z-10 w-full mt-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 max-h-60 overflow-y-auto"></div>
7142
+ </div>
7143
+ </div>
7144
+
7145
+ <!-- Mode Toggle -->
7146
+ <div>
7147
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Search Mode</label>
7148
+ <div class="flex gap-4">
7149
+ <label class="flex items-center">
7150
+ <input type="radio" name="mode" value="ai" checked class="mr-2">
7151
+ <span class="text-sm text-zinc-950 dark:text-white">\u{1F916} AI Search (Semantic)</span>
7152
+ </label>
7153
+ <label class="flex items-center">
7154
+ <input type="radio" name="mode" value="keyword" class="mr-2">
7155
+ <span class="text-sm text-zinc-950 dark:text-white">\u{1F524} Keyword Search</span>
7156
+ </label>
7157
+ </div>
7158
+ </div>
7159
+
7160
+ <!-- Filters -->
7161
+ <div class="border-t border-zinc-200 dark:border-zinc-800 pt-4">
7162
+ <h4 class="text-sm font-semibold text-zinc-950 dark:text-white mb-3">Filters</h4>
7163
+
7164
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
7165
+ <!-- Collection Filter -->
7166
+ <div>
7167
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Collections</label>
7168
+ <select
7169
+ id="filterCollections"
7170
+ name="collections"
7171
+ multiple
7172
+ class="w-full rounded-lg bg-white dark:bg-white/5 px-3 py-2 text-sm text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10"
7173
+ size="4"
7174
+ >
7175
+ <option value="">All Collections</option>
7176
+ ${data.models.map(
7177
+ (model) => `
7178
+ <option value="${model.name}">${model.displayName}</option>
7179
+ `
7180
+ ).join("")}
7181
+ </select>
7182
+ <p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
7183
+ </div>
7184
+
7185
+ <!-- Status Filter -->
7186
+ <div>
7187
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Status</label>
7188
+ <select
7189
+ id="filterStatus"
7190
+ name="status"
7191
+ multiple
7192
+ class="w-full rounded-lg bg-white dark:bg-white/5 px-3 py-2 text-sm text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10"
7193
+ size="4"
7194
+ >
7195
+ <option value="published">Published</option>
7196
+ <option value="draft">Draft</option>
7197
+ <option value="review">Under Review</option>
7198
+ <option value="scheduled">Scheduled</option>
7199
+ <option value="archived">Archived</option>
7200
+ </select>
7201
+ </div>
7202
+ </div>
7203
+ </div>
7204
+
7205
+ <!-- Actions -->
7206
+ <div class="flex items-center justify-end gap-3 pt-4 border-t border-zinc-200 dark:border-zinc-800">
7207
+ <button
7208
+ type="button"
7209
+ onclick="closeAdvancedSearch()"
7210
+ class="inline-flex items-center justify-center rounded-lg bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700"
7211
+ >
7212
+ Cancel
7213
+ </button>
7214
+ <button
7215
+ type="submit"
7216
+ class="inline-flex items-center justify-center rounded-lg bg-indigo-600 text-white px-6 py-2.5 text-sm font-semibold hover:bg-indigo-500 shadow-sm"
7217
+ >
7218
+ Search
7219
+ </button>
7220
+ </div>
7221
+ </form>
7222
+ </div>
7223
+
7224
+ <!-- Results Area -->
7225
+ <div id="searchResults" class="hidden px-4 pb-4 sm:px-6">
7226
+ <div class="border-t border-zinc-200 dark:border-zinc-800 pt-4">
7227
+ <div id="searchResultsContent" class="space-y-3"></div>
7228
+ <div id="searchResultsPagination" class="mt-4 flex items-center justify-between"></div>
7229
+ </div>
7230
+ </div>
7231
+ </div>
7232
+ </div>
7233
+ </div>
7234
+
7235
+ <script>
7236
+ // Open modal
7237
+ function openAdvancedSearch() {
7238
+ document.getElementById('advancedSearchModal').classList.remove('hidden');
7239
+ document.getElementById('searchQuery').focus();
7240
+ }
7241
+
7242
+ // Close modal
7243
+ function closeAdvancedSearch() {
7244
+ document.getElementById('advancedSearchModal').classList.add('hidden');
7245
+ document.getElementById('searchResults').classList.add('hidden');
7246
+ }
7247
+
7248
+ // Autocomplete
7249
+ let autocompleteTimeout;
7250
+ const searchQueryInput = document.getElementById('searchQuery');
7251
+ if (searchQueryInput) {
7252
+ searchQueryInput.addEventListener('input', (e) => {
7253
+ const query = e.target.value.trim();
7254
+ const suggestionsDiv = document.getElementById('searchSuggestions');
7255
+
7256
+ clearTimeout(autocompleteTimeout);
7257
+
7258
+ if (query.length < 2) {
7259
+ suggestionsDiv.classList.add('hidden');
7260
+ return;
7261
+ }
7262
+
7263
+ autocompleteTimeout = setTimeout(async () => {
7264
+ try {
7265
+ const res = await fetch(\`/api/search/suggest?q=\${encodeURIComponent(query)}\`);
7266
+ const { data } = await res.json();
7267
+
7268
+ if (data && data.length > 0) {
7269
+ suggestionsDiv.innerHTML = data.map(s => \`
7270
+ <div class="px-4 py-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer" onclick="selectSuggestion('\${s.replace(/'/g, "\\'")}')">\${s}</div>
7271
+ \`).join('');
7272
+ suggestionsDiv.classList.remove('hidden');
7273
+ } else {
7274
+ suggestionsDiv.classList.add('hidden');
7275
+ }
7276
+ } catch (error) {
7277
+ console.error('Autocomplete error:', error);
7278
+ }
7279
+ }, 300);
7280
+ });
7281
+ }
7282
+
7283
+ function selectSuggestion(suggestion) {
7284
+ document.getElementById('searchQuery').value = suggestion;
7285
+ document.getElementById('searchSuggestions').classList.add('hidden');
7286
+ }
7287
+
7288
+ // Hide suggestions when clicking outside
7289
+ document.addEventListener('click', (e) => {
7290
+ const suggestionsDiv = document.getElementById('searchSuggestions');
7291
+ if (!e.target.closest('#searchQuery') && !e.target.closest('#searchSuggestions')) {
7292
+ suggestionsDiv.classList.add('hidden');
7293
+ }
7294
+ });
7295
+
7296
+ // Form submission
7297
+ const advancedSearchForm = document.getElementById('advancedSearchForm');
7298
+ if (advancedSearchForm) {
7299
+ advancedSearchForm.addEventListener('submit', async (e) => {
7300
+ e.preventDefault();
7301
+
7302
+ const formData = new FormData(e.target);
7303
+ const query = formData.get('query');
7304
+ const mode = formData.get('mode') || 'ai';
7305
+
7306
+ // Build filters
7307
+ const filters = {};
7308
+
7309
+ const collections = Array.from(formData.getAll('collections')).filter(c => c !== '');
7310
+ if (collections.length > 0) {
7311
+ // Need to convert collection names to IDs - for now, pass names
7312
+ filters.collections = collections;
7313
+ }
7314
+
7315
+ const status = Array.from(formData.getAll('status'));
7316
+ if (status.length > 0) {
7317
+ filters.status = status;
7318
+ }
7319
+
7320
+ const dateStart = formData.get('date_start');
7321
+ const dateEnd = formData.get('date_end');
7322
+ if (dateStart || dateEnd) {
7323
+ filters.dateRange = {
7324
+ start: dateStart ? new Date(dateStart) : null,
7325
+ end: dateEnd ? new Date(dateEnd) : null,
7326
+ field: 'created_at'
7327
+ };
7328
+ }
7329
+
7330
+ // Execute search
7331
+ try {
7332
+ const res = await fetch('/api/search', {
7333
+ method: 'POST',
7334
+ headers: {'Content-Type': 'application/json'},
7335
+ body: JSON.stringify({
7336
+ query,
7337
+ mode,
7338
+ filters,
7339
+ limit: 20
7340
+ })
7341
+ });
7342
+
7343
+ const { data } = await res.json();
7344
+
7345
+ if (data && data.results) {
7346
+ displaySearchResults(data);
7347
+ }
7348
+ } catch (error) {
7349
+ console.error('Search error:', error);
7350
+ alert('Search failed. Please try again.');
5779
7351
  }
5780
- })
5781
- .catch(err => {
5782
- console.error('Bulk action error:', err);
5783
- alert('Failed to perform bulk action');
5784
- })
5785
- .finally(() => {
5786
- // Clear context
5787
- currentBulkAction = null;
5788
- currentSelectedIds = [];
5789
7352
  });
5790
7353
  }
5791
7354
 
5792
- // Helper to get action text for display
5793
- function getActionText(action) {
5794
- const actionCount = currentSelectedIds.length;
5795
- switch(action) {
5796
- case 'publish':
5797
- return \`publish \${actionCount} item\${actionCount > 1 ? 's' : ''}\`;
5798
- case 'draft':
5799
- return \`move \${actionCount} item\${actionCount > 1 ? 's' : ''} to draft\`;
5800
- case 'delete':
5801
- return \`delete \${actionCount} item\${actionCount > 1 ? 's' : ''}\`;
5802
- default:
5803
- return \`perform action on \${actionCount} item\${actionCount > 1 ? 's' : ''}\`;
7355
+ function displaySearchResults(searchData) {
7356
+ const resultsDiv = document.getElementById('searchResultsContent');
7357
+ const resultsSection = document.getElementById('searchResults');
7358
+
7359
+ if (searchData.results.length === 0) {
7360
+ resultsDiv.innerHTML = '<p class="text-sm text-zinc-500 dark:text-zinc-400">No results found.</p>';
7361
+ } else {
7362
+ resultsDiv.innerHTML = searchData.results.map(result => \`
7363
+ <div class="p-4 rounded-lg border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800">
7364
+ <div class="flex items-start justify-between">
7365
+ <div class="flex-1">
7366
+ <h4 class="text-sm font-semibold text-zinc-950 dark:text-white mb-1">
7367
+ <a href="/admin/content/\${result.id}/edit" class="hover:text-indigo-600 dark:hover:text-indigo-400">\${result.title || 'Untitled'}</a>
7368
+ </h4>
7369
+ <p class="text-xs text-zinc-500 dark:text-zinc-400 mb-2">
7370
+ \${result.collection_name} \u2022 \${new Date(result.created_at).toLocaleDateString()}
7371
+ \${result.relevance_score ? \` \u2022 Relevance: \${(result.relevance_score * 100).toFixed(0)}%\` : ''}
7372
+ </p>
7373
+ \${result.snippet ? \`<p class="text-sm text-zinc-600 dark:text-zinc-400">\${result.snippet}</p>\` : ''}
7374
+ </div>
7375
+ <div class="ml-4">
7376
+ <span class="px-2 py-1 text-xs rounded-full \${result.status === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'}">\${result.status}</span>
7377
+ </div>
7378
+ </div>
7379
+ </div>
7380
+ \`).join('');
5804
7381
  }
7382
+
7383
+ resultsSection.classList.remove('hidden');
7384
+ resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
5805
7385
  }
5806
7386
 
7387
+ // Make functions globally available
7388
+ window.openAdvancedSearch = openAdvancedSearch;
7389
+ window.closeAdvancedSearch = closeAdvancedSearch;
5807
7390
  </script>
5808
-
5809
- <!-- Confirmation Dialog for Bulk Actions -->
5810
- ${renderConfirmationDialog({
5811
- id: "bulk-action-confirm",
5812
- title: "Confirm Bulk Action",
5813
- message: "Are you sure you want to perform this action? This operation will affect multiple items.",
5814
- confirmText: "Confirm",
5815
- cancelText: "Cancel",
5816
- confirmClass: "bg-blue-500 hover:bg-blue-400",
5817
- iconColor: "blue",
5818
- onConfirm: "executeBulkAction()"
5819
- })}
5820
-
5821
- <!-- Confirmation Dialog Script -->
5822
- ${getConfirmationDialogScript()}
5823
7391
  `;
5824
7392
  const layoutData = {
5825
7393
  title: "Content Management",
@@ -6023,6 +7591,122 @@ async function isPluginActive2(db, pluginId) {
6023
7591
 
6024
7592
  // src/routes/admin-content.ts
6025
7593
  var adminContentRoutes = new Hono();
7594
+ function parseFieldValue(field, formData, options = {}) {
7595
+ const { skipValidation = false } = options;
7596
+ const value = formData.get(field.field_name);
7597
+ const errors = [];
7598
+ const blocksConfig = getBlocksFieldConfig(field.field_options);
7599
+ if (blocksConfig) {
7600
+ const parsed = parseBlocksValue(value, blocksConfig);
7601
+ if (!skipValidation && field.is_required && parsed.value.length === 0) {
7602
+ parsed.errors.push(`${field.field_label} is required`);
7603
+ }
7604
+ return { value: parsed.value, errors: parsed.errors };
7605
+ }
7606
+ if (!skipValidation && field.is_required && (!value || value.toString().trim() === "")) {
7607
+ return { value: null, errors: [`${field.field_label} is required`] };
7608
+ }
7609
+ switch (field.field_type) {
7610
+ case "number":
7611
+ if (value && isNaN(Number(value))) {
7612
+ if (!skipValidation) {
7613
+ errors.push(`${field.field_label} must be a valid number`);
7614
+ }
7615
+ return { value: null, errors };
7616
+ }
7617
+ return { value: value ? Number(value) : null, errors: [] };
7618
+ case "boolean":
7619
+ const submitted = formData.get(`${field.field_name}_submitted`);
7620
+ return { value: submitted ? value === "true" : false, errors: [] };
7621
+ case "select":
7622
+ if (field.field_options?.multiple) {
7623
+ return { value: formData.getAll(`${field.field_name}[]`), errors: [] };
7624
+ }
7625
+ return { value, errors: [] };
7626
+ case "array": {
7627
+ if (!value || value.toString().trim() === "") {
7628
+ if (!skipValidation && field.is_required) {
7629
+ errors.push(`${field.field_label} is required`);
7630
+ }
7631
+ return { value: [], errors };
7632
+ }
7633
+ try {
7634
+ const parsed = JSON.parse(value.toString());
7635
+ if (!Array.isArray(parsed)) {
7636
+ if (!skipValidation) {
7637
+ errors.push(`${field.field_label} must be a JSON array`);
7638
+ }
7639
+ return { value: [], errors };
7640
+ }
7641
+ if (!skipValidation && field.is_required && parsed.length === 0) {
7642
+ errors.push(`${field.field_label} is required`);
7643
+ }
7644
+ return { value: parsed, errors };
7645
+ } catch {
7646
+ if (!skipValidation) {
7647
+ errors.push(`${field.field_label} must be valid JSON`);
7648
+ }
7649
+ return { value: [], errors };
7650
+ }
7651
+ }
7652
+ case "object": {
7653
+ if (!value || value.toString().trim() === "") {
7654
+ if (!skipValidation && field.is_required) {
7655
+ errors.push(`${field.field_label} is required`);
7656
+ }
7657
+ return { value: {}, errors };
7658
+ }
7659
+ try {
7660
+ const parsed = JSON.parse(value.toString());
7661
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
7662
+ if (!skipValidation) {
7663
+ errors.push(`${field.field_label} must be a JSON object`);
7664
+ }
7665
+ return { value: {}, errors };
7666
+ }
7667
+ if (!skipValidation && field.is_required && Object.keys(parsed).length === 0) {
7668
+ errors.push(`${field.field_label} is required`);
7669
+ }
7670
+ return { value: parsed, errors };
7671
+ } catch {
7672
+ if (!skipValidation) {
7673
+ errors.push(`${field.field_label} must be valid JSON`);
7674
+ }
7675
+ return { value: {}, errors };
7676
+ }
7677
+ }
7678
+ case "json": {
7679
+ if (!value || value.toString().trim() === "") {
7680
+ if (!skipValidation && field.is_required) {
7681
+ errors.push(`${field.field_label} is required`);
7682
+ }
7683
+ return { value: null, errors };
7684
+ }
7685
+ try {
7686
+ return { value: JSON.parse(value.toString()), errors: [] };
7687
+ } catch {
7688
+ if (!skipValidation) {
7689
+ errors.push(`${field.field_label} must be valid JSON`);
7690
+ }
7691
+ return { value: null, errors };
7692
+ }
7693
+ }
7694
+ default:
7695
+ return { value, errors: [] };
7696
+ }
7697
+ }
7698
+ function extractFieldData(fields, formData, options = {}) {
7699
+ const data = {};
7700
+ const errors = {};
7701
+ for (const field of fields) {
7702
+ const result = parseFieldValue(field, formData, options);
7703
+ data[field.field_name] = result.value;
7704
+ if (result.errors.length > 0) {
7705
+ errors[field.field_name] = result.errors;
7706
+ }
7707
+ }
7708
+ return { data, errors };
7709
+ }
6026
7710
  adminContentRoutes.use("*", requireAuth());
6027
7711
  async function getCollectionFields(db, collectionId) {
6028
7712
  const cache = getCacheService(CACHE_CONFIGS.collection);
@@ -6496,36 +8180,7 @@ adminContentRoutes.post("/", async (c) => {
6496
8180
  `);
6497
8181
  }
6498
8182
  const fields = await getCollectionFields(db, collectionId);
6499
- const data = {};
6500
- const errors = {};
6501
- for (const field of fields) {
6502
- const value = formData.get(field.field_name);
6503
- if (field.is_required && (!value || value.toString().trim() === "")) {
6504
- errors[field.field_name] = [`${field.field_label} is required`];
6505
- continue;
6506
- }
6507
- switch (field.field_type) {
6508
- case "number":
6509
- if (value && isNaN(Number(value))) {
6510
- errors[field.field_name] = [`${field.field_label} must be a valid number`];
6511
- } else {
6512
- data[field.field_name] = value ? Number(value) : null;
6513
- }
6514
- break;
6515
- case "boolean":
6516
- data[field.field_name] = formData.get(`${field.field_name}_submitted`) ? value === "true" : false;
6517
- break;
6518
- case "select":
6519
- if (field.field_options?.multiple) {
6520
- data[field.field_name] = formData.getAll(`${field.field_name}[]`);
6521
- } else {
6522
- data[field.field_name] = value;
6523
- }
6524
- break;
6525
- default:
6526
- data[field.field_name] = value;
6527
- }
6528
- }
8183
+ const { data, errors } = extractFieldData(fields, formData);
6529
8184
  if (Object.keys(errors).length > 0) {
6530
8185
  const formDataWithErrors = {
6531
8186
  collection,
@@ -6642,36 +8297,7 @@ adminContentRoutes.put("/:id", async (c) => {
6642
8297
  `);
6643
8298
  }
6644
8299
  const fields = await getCollectionFields(db, existingContent.collection_id);
6645
- const data = {};
6646
- const errors = {};
6647
- for (const field of fields) {
6648
- const value = formData.get(field.field_name);
6649
- if (field.is_required && (!value || value.toString().trim() === "")) {
6650
- errors[field.field_name] = [`${field.field_label} is required`];
6651
- continue;
6652
- }
6653
- switch (field.field_type) {
6654
- case "number":
6655
- if (value && isNaN(Number(value))) {
6656
- errors[field.field_name] = [`${field.field_label} must be a valid number`];
6657
- } else {
6658
- data[field.field_name] = value ? Number(value) : null;
6659
- }
6660
- break;
6661
- case "boolean":
6662
- data[field.field_name] = formData.get(`${field.field_name}_submitted`) ? value === "true" : false;
6663
- break;
6664
- case "select":
6665
- if (field.field_options?.multiple) {
6666
- data[field.field_name] = formData.getAll(`${field.field_name}[]`);
6667
- } else {
6668
- data[field.field_name] = value;
6669
- }
6670
- break;
6671
- default:
6672
- data[field.field_name] = value;
6673
- }
6674
- }
8300
+ const { data, errors } = extractFieldData(fields, formData);
6675
8301
  if (Object.keys(errors).length > 0) {
6676
8302
  const formDataWithErrors = {
6677
8303
  id,
@@ -6784,27 +8410,7 @@ adminContentRoutes.post("/preview", async (c) => {
6784
8410
  return c.html("<p>Collection not found</p>");
6785
8411
  }
6786
8412
  const fields = await getCollectionFields(db, collectionId);
6787
- const data = {};
6788
- for (const field of fields) {
6789
- const value = formData.get(field.field_name);
6790
- switch (field.field_type) {
6791
- case "number":
6792
- data[field.field_name] = value ? Number(value) : null;
6793
- break;
6794
- case "boolean":
6795
- data[field.field_name] = value === "true";
6796
- break;
6797
- case "select":
6798
- if (field.field_options?.multiple) {
6799
- data[field.field_name] = formData.getAll(`${field.field_name}[]`);
6800
- } else {
6801
- data[field.field_name] = value;
6802
- }
6803
- break;
6804
- default:
6805
- data[field.field_name] = value;
6806
- }
6807
- }
8413
+ const { data } = extractFieldData(fields, formData, { skipValidation: true });
6808
8414
  const previewHTML = `
6809
8415
  <!DOCTYPE html>
6810
8416
  <html lang="en">
@@ -8151,14 +9757,87 @@ function renderUserEditPage(data) {
8151
9757
  </div>
8152
9758
  </div>
8153
9759
  </div>
9760
+ </div>
9761
+
9762
+ <!-- Profile Information -->
9763
+ <div class="mb-8">
9764
+ <h3 class="text-base font-semibold text-zinc-950 dark:text-white mb-4">Profile Information</h3>
9765
+ <p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Extended profile data for this user</p>
9766
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
9767
+ <div>
9768
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Display Name</label>
9769
+ <input
9770
+ type="text"
9771
+ name="profile_display_name"
9772
+ value="${escapeHtml(data.userToEdit.profile?.displayName || "")}"
9773
+ placeholder="Public display name"
9774
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
9775
+ />
9776
+ </div>
9777
+
9778
+ <div>
9779
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Company</label>
9780
+ <input
9781
+ type="text"
9782
+ name="profile_company"
9783
+ value="${escapeHtml(data.userToEdit.profile?.company || "")}"
9784
+ placeholder="Company or organization"
9785
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
9786
+ />
9787
+ </div>
9788
+
9789
+ <div>
9790
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Job Title</label>
9791
+ <input
9792
+ type="text"
9793
+ name="profile_job_title"
9794
+ value="${escapeHtml(data.userToEdit.profile?.jobTitle || "")}"
9795
+ placeholder="Job title or role"
9796
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
9797
+ />
9798
+ </div>
9799
+
9800
+ <div>
9801
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Website</label>
9802
+ <input
9803
+ type="url"
9804
+ name="profile_website"
9805
+ value="${escapeHtml(data.userToEdit.profile?.website || "")}"
9806
+ placeholder="https://example.com"
9807
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
9808
+ />
9809
+ </div>
9810
+
9811
+ <div>
9812
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Location</label>
9813
+ <input
9814
+ type="text"
9815
+ name="profile_location"
9816
+ value="${escapeHtml(data.userToEdit.profile?.location || "")}"
9817
+ placeholder="City, Country"
9818
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
9819
+ />
9820
+ </div>
9821
+
9822
+ <div>
9823
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Date of Birth</label>
9824
+ <input
9825
+ type="date"
9826
+ name="profile_date_of_birth"
9827
+ value="${data.userToEdit.profile?.dateOfBirth ? new Date(data.userToEdit.profile.dateOfBirth).toISOString().split("T")[0] : ""}"
9828
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
9829
+ />
9830
+ </div>
9831
+ </div>
8154
9832
 
8155
9833
  <div class="mt-6">
8156
9834
  <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Bio</label>
8157
9835
  <textarea
8158
- name="bio"
9836
+ name="profile_bio"
8159
9837
  rows="3"
9838
+ placeholder="Short bio or description"
8160
9839
  class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
8161
- >${escapeHtml(data.userToEdit.bio || "")}</textarea>
9840
+ >${escapeHtml(data.userToEdit.profile?.bio || "")}</textarea>
8162
9841
  </div>
8163
9842
  </div>
8164
9843
 
@@ -9692,7 +11371,7 @@ userRoutes.get("/users/:id/edit", async (c) => {
9692
11371
  const userId = c.req.param("id");
9693
11372
  try {
9694
11373
  const userStmt = db.prepare(`
9695
- SELECT id, email, username, first_name, last_name, phone, bio, avatar_url,
11374
+ SELECT id, email, username, first_name, last_name, phone, avatar_url,
9696
11375
  role, is_active, email_verified, two_factor_enabled, created_at, last_login_at
9697
11376
  FROM users
9698
11377
  WHERE id = ?
@@ -9705,6 +11384,21 @@ userRoutes.get("/users/:id/edit", async (c) => {
9705
11384
  dismissible: true
9706
11385
  }), 404);
9707
11386
  }
11387
+ const profileStmt = db.prepare(`
11388
+ SELECT display_name, bio, company, job_title, website, location, date_of_birth
11389
+ FROM user_profiles
11390
+ WHERE user_id = ?
11391
+ `);
11392
+ const profileData = await profileStmt.bind(userId).first();
11393
+ const profile = profileData ? {
11394
+ displayName: profileData.display_name,
11395
+ bio: profileData.bio,
11396
+ company: profileData.company,
11397
+ jobTitle: profileData.job_title,
11398
+ website: profileData.website,
11399
+ location: profileData.location,
11400
+ dateOfBirth: profileData.date_of_birth
11401
+ } : void 0;
9708
11402
  const editData = {
9709
11403
  id: userToEdit.id,
9710
11404
  email: userToEdit.email,
@@ -9712,14 +11406,14 @@ userRoutes.get("/users/:id/edit", async (c) => {
9712
11406
  firstName: userToEdit.first_name || "",
9713
11407
  lastName: userToEdit.last_name || "",
9714
11408
  phone: userToEdit.phone,
9715
- bio: userToEdit.bio,
9716
11409
  avatarUrl: userToEdit.avatar_url,
9717
11410
  role: userToEdit.role,
9718
11411
  isActive: Boolean(userToEdit.is_active),
9719
11412
  emailVerified: Boolean(userToEdit.email_verified),
9720
11413
  twoFactorEnabled: Boolean(userToEdit.two_factor_enabled),
9721
11414
  createdAt: userToEdit.created_at,
9722
- lastLoginAt: userToEdit.last_login_at
11415
+ lastLoginAt: userToEdit.last_login_at,
11416
+ profile
9723
11417
  };
9724
11418
  const pageData = {
9725
11419
  userToEdit: editData,
@@ -9735,7 +11429,7 @@ userRoutes.get("/users/:id/edit", async (c) => {
9735
11429
  console.error("User edit page error:", error);
9736
11430
  return c.html(renderAlert2({
9737
11431
  type: "error",
9738
- message: "Failed to load user!. Please try again.",
11432
+ message: "Failed to load user. Please try again.",
9739
11433
  dismissible: true
9740
11434
  }), 500);
9741
11435
  }
@@ -9751,10 +11445,17 @@ userRoutes.put("/users/:id", async (c) => {
9751
11445
  const username = sanitizeInput(formData.get("username")?.toString());
9752
11446
  const email = formData.get("email")?.toString()?.trim().toLowerCase() || "";
9753
11447
  const phone = sanitizeInput(formData.get("phone")?.toString()) || null;
9754
- const bio = sanitizeInput(formData.get("bio")?.toString()) || null;
9755
11448
  const role = formData.get("role")?.toString() || "viewer";
9756
11449
  const isActive = formData.get("is_active") === "1";
9757
11450
  const emailVerified = formData.get("email_verified") === "1";
11451
+ const profileDisplayName = sanitizeInput(formData.get("profile_display_name")?.toString()) || null;
11452
+ const profileBio = sanitizeInput(formData.get("profile_bio")?.toString()) || null;
11453
+ const profileCompany = sanitizeInput(formData.get("profile_company")?.toString()) || null;
11454
+ const profileJobTitle = sanitizeInput(formData.get("profile_job_title")?.toString()) || null;
11455
+ const profileWebsite = formData.get("profile_website")?.toString()?.trim() || null;
11456
+ const profileLocation = sanitizeInput(formData.get("profile_location")?.toString()) || null;
11457
+ const profileDateOfBirthStr = formData.get("profile_date_of_birth")?.toString()?.trim() || null;
11458
+ const profileDateOfBirth = profileDateOfBirthStr ? new Date(profileDateOfBirthStr).getTime() : null;
9758
11459
  if (!firstName || !lastName || !username || !email) {
9759
11460
  return c.html(renderAlert2({
9760
11461
  type: "error",
@@ -9770,6 +11471,17 @@ userRoutes.put("/users/:id", async (c) => {
9770
11471
  dismissible: true
9771
11472
  }));
9772
11473
  }
11474
+ if (profileWebsite) {
11475
+ try {
11476
+ new URL(profileWebsite);
11477
+ } catch {
11478
+ return c.html(renderAlert2({
11479
+ type: "error",
11480
+ message: "Please enter a valid website URL.",
11481
+ dismissible: true
11482
+ }));
11483
+ }
11484
+ }
9773
11485
  const checkStmt = db.prepare(`
9774
11486
  SELECT id FROM users
9775
11487
  WHERE (username = ? OR email = ?) AND id != ?
@@ -9778,14 +11490,14 @@ userRoutes.put("/users/:id", async (c) => {
9778
11490
  if (existingUser) {
9779
11491
  return c.html(renderAlert2({
9780
11492
  type: "error",
9781
- message: "Username or email is already taken by another user!.",
11493
+ message: "Username or email is already taken by another user.",
9782
11494
  dismissible: true
9783
11495
  }));
9784
11496
  }
9785
11497
  const updateStmt = db.prepare(`
9786
11498
  UPDATE users SET
9787
11499
  first_name = ?, last_name = ?, username = ?, email = ?,
9788
- phone = ?, bio = ?, role = ?, is_active = ?, email_verified = ?,
11500
+ phone = ?, role = ?, is_active = ?, email_verified = ?,
9789
11501
  updated_at = ?
9790
11502
  WHERE id = ?
9791
11503
  `);
@@ -9795,20 +11507,63 @@ userRoutes.put("/users/:id", async (c) => {
9795
11507
  username,
9796
11508
  email,
9797
11509
  phone,
9798
- bio,
9799
11510
  role,
9800
11511
  isActive ? 1 : 0,
9801
11512
  emailVerified ? 1 : 0,
9802
11513
  Date.now(),
9803
11514
  userId
9804
11515
  ).run();
11516
+ const hasProfileData = profileDisplayName || profileBio || profileCompany || profileJobTitle || profileWebsite || profileLocation || profileDateOfBirth;
11517
+ if (hasProfileData) {
11518
+ const now = Date.now();
11519
+ const profileCheckStmt = db.prepare(`SELECT id FROM user_profiles WHERE user_id = ?`);
11520
+ const existingProfile = await profileCheckStmt.bind(userId).first();
11521
+ if (existingProfile) {
11522
+ const updateProfileStmt = db.prepare(`
11523
+ UPDATE user_profiles SET
11524
+ display_name = ?, bio = ?, company = ?, job_title = ?,
11525
+ website = ?, location = ?, date_of_birth = ?, updated_at = ?
11526
+ WHERE user_id = ?
11527
+ `);
11528
+ await updateProfileStmt.bind(
11529
+ profileDisplayName,
11530
+ profileBio,
11531
+ profileCompany,
11532
+ profileJobTitle,
11533
+ profileWebsite,
11534
+ profileLocation,
11535
+ profileDateOfBirth,
11536
+ now,
11537
+ userId
11538
+ ).run();
11539
+ } else {
11540
+ const profileId = `profile_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
11541
+ const insertProfileStmt = db.prepare(`
11542
+ INSERT INTO user_profiles (id, user_id, display_name, bio, company, job_title, website, location, date_of_birth, created_at, updated_at)
11543
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
11544
+ `);
11545
+ await insertProfileStmt.bind(
11546
+ profileId,
11547
+ userId,
11548
+ profileDisplayName,
11549
+ profileBio,
11550
+ profileCompany,
11551
+ profileJobTitle,
11552
+ profileWebsite,
11553
+ profileLocation,
11554
+ profileDateOfBirth,
11555
+ now,
11556
+ now
11557
+ ).run();
11558
+ }
11559
+ }
9805
11560
  await logActivity(
9806
11561
  db,
9807
11562
  user.userId,
9808
- "user!.update",
11563
+ "user.update",
9809
11564
  "users",
9810
11565
  userId,
9811
- { fields: ["first_name", "last_name", "username", "email", "phone", "bio", "role", "is_active", "email_verified"] },
11566
+ { fields: ["first_name", "last_name", "username", "email", "phone", "role", "is_active", "email_verified", "profile"] },
9812
11567
  c.req.header("x-forwarded-for") || c.req.header("cf-connecting-ip"),
9813
11568
  c.req.header("user-agent")
9814
11569
  );
@@ -9821,7 +11576,7 @@ userRoutes.put("/users/:id", async (c) => {
9821
11576
  console.error("User update error:", error);
9822
11577
  return c.html(renderAlert2({
9823
11578
  type: "error",
9824
- message: "Failed to update user!. Please try again.",
11579
+ message: "Failed to update user. Please try again.",
9825
11580
  dismissible: true
9826
11581
  }));
9827
11582
  }
@@ -13315,6 +15070,9 @@ function renderAuthSettingsForm(settings) {
13315
15070
  }
13316
15071
 
13317
15072
  // src/templates/pages/admin-plugin-settings.template.ts
15073
+ function escapeHtmlAttr(value) {
15074
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15075
+ }
13318
15076
  function renderPluginSettingsPage(data) {
13319
15077
  const { plugin, activity = [], user } = data;
13320
15078
  const pageContent = `
@@ -13592,6 +15350,7 @@ function renderSettingsTab(plugin) {
13592
15350
  const settings = plugin.settings || {};
13593
15351
  const isSeedDataPlugin = plugin.id === "seed-data" || plugin.name === "seed-data";
13594
15352
  const isAuthPlugin = plugin.id === "core-auth" || plugin.name === "core-auth";
15353
+ const isTurnstilePlugin = plugin.id === "turnstile" || plugin.name === "turnstile";
13595
15354
  return `
13596
15355
  ${isSeedDataPlugin ? `
13597
15356
  <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6 mb-6">
@@ -13618,12 +15377,15 @@ function renderSettingsTab(plugin) {
13618
15377
  ${isAuthPlugin ? `
13619
15378
  <h2 class="text-xl font-semibold text-white mb-4">Authentication Settings</h2>
13620
15379
  <p class="text-gray-400 mb-6">Configure user registration fields and validation rules.</p>
15380
+ ` : isTurnstilePlugin ? `
15381
+ <h2 class="text-xl font-semibold text-white mb-4">Cloudflare Turnstile Settings</h2>
15382
+ <p class="text-gray-400 mb-6">Configure CAPTCHA-free bot protection for your forms.</p>
13621
15383
  ` : `
13622
15384
  <h2 class="text-xl font-semibold text-white mb-4">Plugin Settings</h2>
13623
15385
  `}
13624
15386
 
13625
15387
  <form id="settings-form" class="space-y-6">
13626
- ${isAuthPlugin && Object.keys(settings).length > 0 ? renderAuthSettingsForm(settings) : Object.keys(settings).length > 0 ? renderSettingsFields(settings) : renderNoSettings(plugin)}
15388
+ ${isAuthPlugin && Object.keys(settings).length > 0 ? renderAuthSettingsForm(settings) : isTurnstilePlugin && Object.keys(settings).length > 0 ? renderTurnstileSettingsForm(settings) : Object.keys(settings).length > 0 ? renderSettingsFields(settings) : renderNoSettings(plugin)}
13627
15389
 
13628
15390
  ${Object.keys(settings).length > 0 ? `
13629
15391
  <div class="flex items-center justify-end pt-6 border-t border-white/10">
@@ -13687,6 +15449,80 @@ function renderSettingsFields(settings) {
13687
15449
  }
13688
15450
  }).join("");
13689
15451
  }
15452
+ function renderTurnstileSettingsForm(settings) {
15453
+ const inputClass = "backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-gray-300 focus:border-blue-400 focus:outline-none transition-colors w-full";
15454
+ const selectClass = "backdrop-blur-sm bg-zinc-800 border border-white/20 rounded-lg px-3 py-2 text-white focus:border-blue-400 focus:outline-none transition-colors w-full [&>option]:bg-zinc-800 [&>option]:text-white";
15455
+ return `
15456
+ <!-- Enable Toggle -->
15457
+ <div class="flex items-center justify-between">
15458
+ <div>
15459
+ <label for="setting_enabled" class="text-sm font-medium text-gray-300">Enable Turnstile</label>
15460
+ <p class="text-xs text-gray-400">Enable or disable Turnstile verification globally</p>
15461
+ </div>
15462
+ <label class="relative inline-flex items-center cursor-pointer">
15463
+ <input type="checkbox" name="setting_enabled" id="setting_enabled" ${settings.enabled ? "checked" : ""} class="sr-only peer">
15464
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
15465
+ </label>
15466
+ </div>
15467
+
15468
+ <!-- Site Key -->
15469
+ <div>
15470
+ <label for="setting_siteKey" class="block text-sm font-medium text-gray-300 mb-2">Site Key</label>
15471
+ <input type="text" name="setting_siteKey" id="setting_siteKey" value="${escapeHtmlAttr(settings.siteKey || "")}" placeholder="0x4AAAAAAAA..." class="${inputClass}">
15472
+ <p class="text-xs text-gray-400 mt-1">Your Cloudflare Turnstile site key (public)</p>
15473
+ </div>
15474
+
15475
+ <!-- Secret Key -->
15476
+ <div>
15477
+ <label for="setting_secretKey" class="block text-sm font-medium text-gray-300 mb-2">Secret Key</label>
15478
+ <input type="password" name="setting_secretKey" id="setting_secretKey" value="${escapeHtmlAttr(settings.secretKey || "")}" placeholder="0x4AAAAAAAA..." class="${inputClass}">
15479
+ <p class="text-xs text-gray-400 mt-1">Your Cloudflare Turnstile secret key (private)</p>
15480
+ </div>
15481
+
15482
+ <!-- Theme -->
15483
+ <div>
15484
+ <label for="setting_theme" class="block text-sm font-medium text-gray-300 mb-2">Widget Theme</label>
15485
+ <select name="setting_theme" id="setting_theme" class="${selectClass}" style="color: white; background-color: rgb(39, 39, 42);">
15486
+ <option value="auto" ${settings.theme === "auto" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Auto (matches page theme)</option>
15487
+ <option value="light" ${settings.theme === "light" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Light</option>
15488
+ <option value="dark" ${settings.theme === "dark" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Dark</option>
15489
+ </select>
15490
+ <p class="text-xs text-gray-400 mt-1">Visual appearance of the Turnstile widget</p>
15491
+ </div>
15492
+
15493
+ <!-- Size -->
15494
+ <div>
15495
+ <label for="setting_size" class="block text-sm font-medium text-gray-300 mb-2">Widget Size</label>
15496
+ <select name="setting_size" id="setting_size" class="${selectClass}" style="color: white; background-color: rgb(39, 39, 42);">
15497
+ <option value="normal" ${settings.size === "normal" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Normal (300x65px)</option>
15498
+ <option value="compact" ${settings.size === "compact" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Compact (130x120px)</option>
15499
+ </select>
15500
+ <p class="text-xs text-gray-400 mt-1">Size of the Turnstile challenge widget</p>
15501
+ </div>
15502
+
15503
+ <!-- Widget Mode -->
15504
+ <div>
15505
+ <label for="setting_mode" class="block text-sm font-medium text-gray-300 mb-2">Widget Mode</label>
15506
+ <select name="setting_mode" id="setting_mode" class="${selectClass}" style="color: white; background-color: rgb(39, 39, 42);">
15507
+ <option value="managed" ${!settings.mode || settings.mode === "managed" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Managed (Recommended) - Adaptive challenge</option>
15508
+ <option value="non-interactive" ${settings.mode === "non-interactive" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Non-Interactive - Always visible, minimal friction</option>
15509
+ <option value="invisible" ${settings.mode === "invisible" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Invisible - No visible widget</option>
15510
+ </select>
15511
+ <p class="text-xs text-gray-400 mt-1"><strong>Managed:</strong> Shows challenge only when needed. <strong>Non-Interactive:</strong> Always shows but doesn't require interaction. <strong>Invisible:</strong> Runs in background without UI.</p>
15512
+ </div>
15513
+
15514
+ <!-- Appearance (Pre-clearance) -->
15515
+ <div>
15516
+ <label for="setting_appearance" class="block text-sm font-medium text-gray-300 mb-2">Pre-clearance / Appearance</label>
15517
+ <select name="setting_appearance" id="setting_appearance" class="${selectClass}" style="color: white; background-color: rgb(39, 39, 42);">
15518
+ <option value="always" ${!settings.appearance || settings.appearance === "always" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Always - Pre-clearance enabled (verifies immediately)</option>
15519
+ <option value="execute" ${settings.appearance === "execute" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Execute - Challenge on form submit</option>
15520
+ <option value="interaction-only" ${settings.appearance === "interaction-only" ? "selected" : ""} style="background-color: rgb(39, 39, 42); color: white;">Interaction Only - Only after user interaction</option>
15521
+ </select>
15522
+ <p class="text-xs text-gray-400 mt-1">Controls when Turnstile verification occurs. <strong>Always:</strong> Verifies immediately (pre-clearance). <strong>Execute:</strong> Verifies on form submit. <strong>Interaction Only:</strong> Only after user interaction.</p>
15523
+ </div>
15524
+ `;
15525
+ }
13690
15526
  function renderNoSettings(plugin) {
13691
15527
  if (plugin.id === "seed-data" || plugin.name === "seed-data") {
13692
15528
  return `
@@ -13918,6 +15754,32 @@ var AVAILABLE_PLUGINS = [
13918
15754
  permissions: [],
13919
15755
  dependencies: [],
13920
15756
  is_core: false
15757
+ },
15758
+ {
15759
+ id: "turnstile",
15760
+ name: "turnstile-plugin",
15761
+ display_name: "Cloudflare Turnstile",
15762
+ description: "CAPTCHA-free bot protection for forms using Cloudflare Turnstile. Provides seamless spam prevention with configurable modes, themes, and pre-clearance options.",
15763
+ version: "1.0.0",
15764
+ author: "SonicJS Team",
15765
+ category: "security",
15766
+ icon: "\u{1F6E1}\uFE0F",
15767
+ permissions: [],
15768
+ dependencies: [],
15769
+ is_core: true
15770
+ },
15771
+ {
15772
+ id: "ai-search",
15773
+ name: "ai-search-plugin",
15774
+ display_name: "AI Search",
15775
+ description: "Advanced search with Cloudflare AI Search. Full-text search, semantic search, and advanced filtering across all content collections.",
15776
+ version: "1.0.0",
15777
+ author: "SonicJS Team",
15778
+ category: "search",
15779
+ icon: "\u{1F50D}",
15780
+ permissions: [],
15781
+ dependencies: [],
15782
+ is_core: true
13921
15783
  }
13922
15784
  ];
13923
15785
  adminPluginRoutes.get("/", async (c) => {
@@ -13996,6 +15858,9 @@ adminPluginRoutes.get("/:id", async (c) => {
13996
15858
  const user = c.get("user");
13997
15859
  const db = c.env.DB;
13998
15860
  const pluginId = c.req.param("id");
15861
+ if (pluginId === "ai-search") {
15862
+ return c.text("", 404);
15863
+ }
13999
15864
  if (user?.role !== "admin") {
14000
15865
  return c.redirect("/admin/plugins");
14001
15866
  }
@@ -14288,6 +16153,60 @@ adminPluginRoutes.post("/install", async (c) => {
14288
16153
  });
14289
16154
  return c.json({ success: true, plugin: easyMdxPlugin2 });
14290
16155
  }
16156
+ if (body.name === "ai-search-plugin" || body.name === "ai-search") {
16157
+ const defaultSettings = {
16158
+ enabled: true,
16159
+ ai_mode_enabled: true,
16160
+ selected_collections: [],
16161
+ dismissed_collections: [],
16162
+ autocomplete_enabled: true,
16163
+ cache_duration: 1,
16164
+ results_limit: 20,
16165
+ index_media: false
16166
+ };
16167
+ const aiSearchPlugin = await pluginService.installPlugin({
16168
+ id: "ai-search",
16169
+ name: "ai-search-plugin",
16170
+ display_name: "AI Search",
16171
+ description: "Advanced search with Cloudflare AI Search. Full-text search, semantic search, and advanced filtering across all content collections.",
16172
+ version: "1.0.0",
16173
+ author: "SonicJS Team",
16174
+ category: "search",
16175
+ icon: "\u{1F50D}",
16176
+ permissions: [],
16177
+ dependencies: [],
16178
+ is_core: true,
16179
+ settings: defaultSettings
16180
+ });
16181
+ return c.json({ success: true, plugin: aiSearchPlugin });
16182
+ }
16183
+ if (body.name === "turnstile-plugin") {
16184
+ const turnstilePlugin = await pluginService.installPlugin({
16185
+ id: "turnstile",
16186
+ name: "turnstile-plugin",
16187
+ display_name: "Cloudflare Turnstile",
16188
+ description: "CAPTCHA-free bot protection for forms using Cloudflare Turnstile. Provides seamless spam prevention with configurable modes, themes, and pre-clearance options.",
16189
+ version: "1.0.0",
16190
+ author: "SonicJS Team",
16191
+ category: "security",
16192
+ icon: "\u{1F6E1}\uFE0F",
16193
+ permissions: [],
16194
+ dependencies: [],
16195
+ is_core: true,
16196
+ settings: {
16197
+ siteKey: "",
16198
+ secretKey: "",
16199
+ theme: "auto",
16200
+ size: "normal",
16201
+ mode: "managed",
16202
+ appearance: "always",
16203
+ preClearanceEnabled: false,
16204
+ preClearanceLevel: "managed",
16205
+ enabled: false
16206
+ }
16207
+ });
16208
+ return c.json({ success: true, plugin: turnstilePlugin });
16209
+ }
14291
16210
  return c.json({ error: "Plugin not found in registry" }, 404);
14292
16211
  } catch (error) {
14293
16212
  console.error("Error installing plugin:", error);
@@ -19461,16 +21380,30 @@ adminCollectionsRoutes.get("/:id", async (c) => {
19461
21380
  const schema = typeof collection.schema === "string" ? JSON.parse(collection.schema) : collection.schema;
19462
21381
  if (schema && schema.properties) {
19463
21382
  let fieldOrder = 0;
19464
- fields = Object.entries(schema.properties).map(([fieldName, fieldConfig]) => ({
19465
- id: `schema-${fieldName}`,
19466
- field_name: fieldName,
19467
- field_type: fieldConfig.type || "string",
19468
- field_label: fieldConfig.title || fieldName,
19469
- field_options: fieldConfig,
19470
- field_order: fieldOrder++,
19471
- is_required: fieldConfig.required === true || schema.required && schema.required.includes(fieldName),
19472
- is_searchable: fieldConfig.searchable === true || false
19473
- }));
21383
+ fields = Object.entries(schema.properties).map(([fieldName, fieldConfig]) => {
21384
+ let fieldType = fieldConfig.type || "string";
21385
+ if (fieldConfig.enum) {
21386
+ fieldType = "select";
21387
+ } else if (fieldConfig.format === "richtext") {
21388
+ fieldType = "richtext";
21389
+ } else if (fieldConfig.format === "media") {
21390
+ fieldType = "media";
21391
+ } else if (fieldConfig.format === "date-time") {
21392
+ fieldType = "date";
21393
+ } else if (fieldConfig.type === "slug" || fieldConfig.format === "slug") {
21394
+ fieldType = "slug";
21395
+ }
21396
+ return {
21397
+ id: `schema-${fieldName}`,
21398
+ field_name: fieldName,
21399
+ field_type: fieldType,
21400
+ field_label: fieldConfig.title || fieldName,
21401
+ field_options: fieldConfig,
21402
+ field_order: fieldOrder++,
21403
+ is_required: fieldConfig.required === true || schema.required && schema.required.includes(fieldName),
21404
+ is_searchable: fieldConfig.searchable === true || false
21405
+ };
21406
+ });
19474
21407
  }
19475
21408
  } catch (e) {
19476
21409
  console.error("Error parsing collection schema:", e);
@@ -19685,6 +21618,9 @@ adminCollectionsRoutes.post("/:id/fields", async (c) => {
19685
21618
  fieldConfig.enum = parsedOptions.options || [];
19686
21619
  } else if (fieldType === "media") {
19687
21620
  fieldConfig.format = "media";
21621
+ } else if (fieldType === "slug") {
21622
+ fieldConfig.type = "slug";
21623
+ fieldConfig.format = "slug";
19688
21624
  } else if (fieldType === "quill") {
19689
21625
  fieldConfig.type = "quill";
19690
21626
  } else if (fieldType === "mdxeditor") {
@@ -21802,6 +23738,6 @@ var ROUTES_INFO = {
21802
23738
  reference: "https://github.com/sonicjs/sonicjs"
21803
23739
  };
21804
23740
 
21805
- export { PluginBuilder, ROUTES_INFO, adminCheckboxRoutes, adminCollectionsRoutes, adminDesignRoutes, adminLogsRoutes, adminMediaRoutes, adminPluginRoutes, adminSettingsRoutes, admin_api_default, admin_code_examples_default, admin_content_default, admin_testimonials_default, api_content_crud_default, api_default, api_media_default, api_system_default, auth_default, checkAdminUserExists, router, test_cleanup_default, userRoutes };
21806
- //# sourceMappingURL=chunk-FT6NBHNX.js.map
21807
- //# sourceMappingURL=chunk-FT6NBHNX.js.map
23741
+ export { ROUTES_INFO, adminCheckboxRoutes, adminCollectionsRoutes, adminDesignRoutes, adminLogsRoutes, adminMediaRoutes, adminPluginRoutes, adminSettingsRoutes, admin_api_default, admin_code_examples_default, admin_content_default, admin_testimonials_default, api_content_crud_default, api_default, api_media_default, api_system_default, auth_default, router, test_cleanup_default, userRoutes };
23742
+ //# sourceMappingURL=chunk-F6GZURXJ.js.map
23743
+ //# sourceMappingURL=chunk-F6GZURXJ.js.map