@sonicjs-cms/core 2.9.0 → 2.10.1

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 (68) hide show
  1. package/dist/{chunk-DQZVU3WB.cjs → chunk-5GO3AMON.cjs} +13 -7
  2. package/dist/chunk-5GO3AMON.cjs.map +1 -0
  3. package/dist/{chunk-YFJJU26H.js → chunk-BUPNX3ZM.js} +375 -3
  4. package/dist/chunk-BUPNX3ZM.js.map +1 -0
  5. package/dist/{chunk-SHU7Q66Q.cjs → chunk-E2GKK5HX.cjs} +7 -3
  6. package/dist/chunk-E2GKK5HX.cjs.map +1 -0
  7. package/dist/{chunk-LDFMYRG6.cjs → chunk-EAJJHE5F.cjs} +9 -2
  8. package/dist/chunk-EAJJHE5F.cjs.map +1 -0
  9. package/dist/{chunk-STTZVLY2.js → chunk-FW5CGNM2.js} +9 -2
  10. package/dist/chunk-FW5CGNM2.js.map +1 -0
  11. package/dist/{chunk-KSB6FXOP.cjs → chunk-HGKBMUYY.cjs} +1194 -278
  12. package/dist/chunk-HGKBMUYY.cjs.map +1 -0
  13. package/dist/{chunk-25YNV4RK.js → chunk-JFMBYQTC.js} +10 -4
  14. package/dist/chunk-JFMBYQTC.js.map +1 -0
  15. package/dist/{chunk-64APW3DW.cjs → chunk-LFAQUR7P.cjs} +9 -2
  16. package/dist/chunk-LFAQUR7P.cjs.map +1 -0
  17. package/dist/{chunk-2JGQKF7B.js → chunk-SDAGUFOF.js} +1079 -163
  18. package/dist/chunk-SDAGUFOF.js.map +1 -0
  19. package/dist/{chunk-MPT5PA6U.cjs → chunk-TWCQVJ6M.cjs} +381 -2
  20. package/dist/chunk-TWCQVJ6M.cjs.map +1 -0
  21. package/dist/{chunk-7JMMLHPQ.js → chunk-VJCLJH3X.js} +9 -2
  22. package/dist/chunk-VJCLJH3X.js.map +1 -0
  23. package/dist/{chunk-3FHMXGLF.js → chunk-YXTFJPMN.js} +7 -3
  24. package/dist/chunk-YXTFJPMN.js.map +1 -0
  25. package/dist/{collection-config-DckWhkdL.d.cts → collection-config-B4PG-AaF.d.cts} +2 -0
  26. package/dist/{collection-config-DckWhkdL.d.ts → collection-config-B4PG-AaF.d.ts} +2 -0
  27. package/dist/index.cjs +170 -142
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +3 -3
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +10 -10
  32. package/dist/index.js.map +1 -1
  33. package/dist/middleware.cjs +29 -29
  34. package/dist/middleware.js +3 -3
  35. package/dist/migrations-ADK6YNM2.js +4 -0
  36. package/dist/{migrations-SZSR3C3G.js.map → migrations-ADK6YNM2.js.map} +1 -1
  37. package/dist/migrations-EM2D6EG2.cjs +13 -0
  38. package/dist/{migrations-QQWGDWGB.cjs.map → migrations-EM2D6EG2.cjs.map} +1 -1
  39. package/dist/{plugin-bootstrap-BAz7NY0H.d.cts → plugin-bootstrap-B8PXeGj_.d.cts} +230 -2
  40. package/dist/{plugin-bootstrap-Cz3-bj8X.d.ts → plugin-bootstrap-CD63DZ-p.d.ts} +230 -2
  41. package/dist/routes.cjs +29 -29
  42. package/dist/routes.js +6 -6
  43. package/dist/services.cjs +60 -32
  44. package/dist/services.d.cts +2 -2
  45. package/dist/services.d.ts +2 -2
  46. package/dist/services.js +3 -3
  47. package/dist/types.d.cts +1 -1
  48. package/dist/types.d.ts +1 -1
  49. package/dist/utils.cjs +11 -11
  50. package/dist/utils.d.cts +1 -1
  51. package/dist/utils.d.ts +1 -1
  52. package/dist/utils.js +1 -1
  53. package/migrations/033_form_content_integration.sql +19 -0
  54. package/package.json +5 -1
  55. package/dist/chunk-25YNV4RK.js.map +0 -1
  56. package/dist/chunk-2JGQKF7B.js.map +0 -1
  57. package/dist/chunk-3FHMXGLF.js.map +0 -1
  58. package/dist/chunk-64APW3DW.cjs.map +0 -1
  59. package/dist/chunk-7JMMLHPQ.js.map +0 -1
  60. package/dist/chunk-DQZVU3WB.cjs.map +0 -1
  61. package/dist/chunk-KSB6FXOP.cjs.map +0 -1
  62. package/dist/chunk-LDFMYRG6.cjs.map +0 -1
  63. package/dist/chunk-MPT5PA6U.cjs.map +0 -1
  64. package/dist/chunk-SHU7Q66Q.cjs.map +0 -1
  65. package/dist/chunk-STTZVLY2.js.map +0 -1
  66. package/dist/chunk-YFJJU26H.js.map +0 -1
  67. package/dist/migrations-QQWGDWGB.cjs +0 -13
  68. package/dist/migrations-SZSR3C3G.js +0 -4
@@ -1,10 +1,10 @@
1
- import { getCacheService, CACHE_CONFIGS, getLogger, SettingsService, getAppInstance, buildRouteList, CATEGORY_INFO } from './chunk-7JMMLHPQ.js';
2
- import { requireAuth, isPluginActive, optionalAuth, requireRole, rateLimit, AuthManager, logActivity, generateCsrfToken } from './chunk-25YNV4RK.js';
3
- import { PluginService } from './chunk-YFJJU26H.js';
4
- import { MigrationService } from './chunk-STTZVLY2.js';
1
+ import { getCacheService, CACHE_CONFIGS, getLogger, SettingsService, getAppInstance, buildRouteList, CATEGORY_INFO } from './chunk-VJCLJH3X.js';
2
+ import { requireAuth, requireRole, isPluginActive, optionalAuth, rateLimit, AuthManager, logActivity, generateCsrfToken } from './chunk-JFMBYQTC.js';
3
+ import { PluginService, createContentFromSubmission } from './chunk-BUPNX3ZM.js';
4
+ import { MigrationService } from './chunk-FW5CGNM2.js';
5
5
  import { init_admin_layout_catalyst_template, renderDesignPage, renderCheckboxPage, renderTestimonialsList, renderCodeExamplesList, renderAlert, renderTable, renderPagination, renderConfirmationDialog, getConfirmationDialogScript, renderAdminLayoutCatalyst, renderAdminLayout, adminLayoutV2, renderForm } from './chunk-JJS7JZCH.js';
6
6
  import { PluginBuilder, TurnstileService } from './chunk-J5WGMRSU.js';
7
- import { QueryFilterBuilder, getCoreVersion, getBlocksFieldConfig, parseBlocksValue } from './chunk-3FHMXGLF.js';
7
+ import { QueryFilterBuilder, getCoreVersion, getBlocksFieldConfig, parseBlocksValue } from './chunk-YXTFJPMN.js';
8
8
  import { metricsTracker } from './chunk-FICTAGD4.js';
9
9
  import { escapeHtml, sanitizeRichText, sanitizeInput } from './chunk-TQABQWOP.js';
10
10
  import { Hono } from 'hono';
@@ -119,7 +119,7 @@ apiContentCrudRoutes.get("/:id", async (c) => {
119
119
  }, 500);
120
120
  }
121
121
  });
122
- apiContentCrudRoutes.post("/", requireAuth(), async (c) => {
122
+ apiContentCrudRoutes.post("/", requireAuth(), requireRole(["admin", "editor", "author"]), async (c) => {
123
123
  try {
124
124
  const db = c.env.DB;
125
125
  const user = c.get("user");
@@ -185,7 +185,7 @@ apiContentCrudRoutes.post("/", requireAuth(), async (c) => {
185
185
  }, 500);
186
186
  }
187
187
  });
188
- apiContentCrudRoutes.put("/:id", requireAuth(), async (c) => {
188
+ apiContentCrudRoutes.put("/:id", requireAuth(), requireRole(["admin", "editor", "author"]), async (c) => {
189
189
  try {
190
190
  const id = c.req.param("id");
191
191
  const db = c.env.DB;
@@ -249,7 +249,7 @@ apiContentCrudRoutes.put("/:id", requireAuth(), async (c) => {
249
249
  }, 500);
250
250
  }
251
251
  });
252
- apiContentCrudRoutes.delete("/:id", requireAuth(), async (c) => {
252
+ apiContentCrudRoutes.delete("/:id", requireAuth(), requireRole(["admin", "editor", "author"]), async (c) => {
253
253
  try {
254
254
  const id = c.req.param("id");
255
255
  const db = c.env.DB;
@@ -748,7 +748,7 @@ apiRoutes.get("/collections", async (c) => {
748
748
  }
749
749
  c.header("X-Cache-Status", "MISS");
750
750
  c.header("X-Cache-Source", "database");
751
- const stmt = db.prepare("SELECT * FROM collections WHERE is_active = 1");
751
+ const stmt = db.prepare("SELECT * FROM collections WHERE is_active = 1 AND (source_type IS NULL OR source_type = 'user')");
752
752
  const { results } = await stmt.all();
753
753
  const transformedResults = results.map((row) => ({
754
754
  ...row,
@@ -1777,7 +1777,7 @@ adminApiRoutes.get("/stats", async (c) => {
1777
1777
  const db = c.env.DB;
1778
1778
  let collectionsCount = 0;
1779
1779
  try {
1780
- const collectionsStmt = db.prepare("SELECT COUNT(*) as count FROM collections WHERE is_active = 1");
1780
+ const collectionsStmt = db.prepare("SELECT COUNT(*) as count FROM collections WHERE is_active = 1 AND (source_type IS NULL OR source_type = 'user')");
1781
1781
  const collectionsResult = await collectionsStmt.first();
1782
1782
  collectionsCount = collectionsResult?.count || 0;
1783
1783
  } catch (error) {
@@ -1785,7 +1785,7 @@ adminApiRoutes.get("/stats", async (c) => {
1785
1785
  }
1786
1786
  let contentCount = 0;
1787
1787
  try {
1788
- const contentStmt = db.prepare("SELECT COUNT(*) as count FROM content WHERE deleted_at IS NULL");
1788
+ const contentStmt = db.prepare("SELECT COUNT(*) as count FROM content c JOIN collections col ON c.collection_id = col.id WHERE c.deleted_at IS NULL AND (col.source_type IS NULL OR col.source_type = 'user')");
1789
1789
  const contentResult = await contentStmt.first();
1790
1790
  contentCount = contentResult?.count || 0;
1791
1791
  } catch (error) {
@@ -1927,6 +1927,7 @@ adminApiRoutes.get("/collections", async (c) => {
1927
1927
  SELECT id, name, display_name, description, created_at, updated_at, is_active, managed
1928
1928
  FROM collections
1929
1929
  WHERE ${includeInactive ? "1=1" : "is_active = 1"}
1930
+ AND (source_type IS NULL OR source_type = 'user')
1930
1931
  AND (name LIKE ? OR display_name LIKE ? OR description LIKE ?)
1931
1932
  ORDER BY created_at DESC
1932
1933
  `);
@@ -1937,7 +1938,8 @@ adminApiRoutes.get("/collections", async (c) => {
1937
1938
  stmt = db.prepare(`
1938
1939
  SELECT id, name, display_name, description, created_at, updated_at, is_active, managed
1939
1940
  FROM collections
1940
- ${includeInactive ? "" : "WHERE is_active = 1"}
1941
+ WHERE (source_type IS NULL OR source_type = 'user')
1942
+ ${includeInactive ? "" : "AND is_active = 1"}
1941
1943
  ORDER BY created_at DESC
1942
1944
  `);
1943
1945
  const queryResults = await stmt.all();
@@ -2281,7 +2283,7 @@ adminApiRoutes.delete("/collections/:id", async (c) => {
2281
2283
  });
2282
2284
  adminApiRoutes.get("/migrations/status", async (c) => {
2283
2285
  try {
2284
- const { MigrationService: MigrationService2 } = await import('./migrations-SZSR3C3G.js');
2286
+ const { MigrationService: MigrationService2 } = await import('./migrations-ADK6YNM2.js');
2285
2287
  const db = c.env.DB;
2286
2288
  const migrationService = new MigrationService2(db);
2287
2289
  const status = await migrationService.getMigrationStatus();
@@ -2306,7 +2308,7 @@ adminApiRoutes.post("/migrations/run", async (c) => {
2306
2308
  error: "Unauthorized. Admin access required."
2307
2309
  }, 403);
2308
2310
  }
2309
- const { MigrationService: MigrationService2 } = await import('./migrations-SZSR3C3G.js');
2311
+ const { MigrationService: MigrationService2 } = await import('./migrations-ADK6YNM2.js');
2310
2312
  const db = c.env.DB;
2311
2313
  const migrationService = new MigrationService2(db);
2312
2314
  const result = await migrationService.runPendingMigrations();
@@ -2325,7 +2327,7 @@ adminApiRoutes.post("/migrations/run", async (c) => {
2325
2327
  });
2326
2328
  adminApiRoutes.get("/migrations/validate", async (c) => {
2327
2329
  try {
2328
- const { MigrationService: MigrationService2 } = await import('./migrations-SZSR3C3G.js');
2330
+ const { MigrationService: MigrationService2 } = await import('./migrations-ADK6YNM2.js');
2329
2331
  const db = c.env.DB;
2330
2332
  const migrationService = new MigrationService2(db);
2331
2333
  const validation = await migrationService.validateSchema();
@@ -4775,6 +4777,39 @@ function getReadFieldValueScript() {
4775
4777
  window.__sonicReadFieldValueInit = true;
4776
4778
 
4777
4779
  window.sonicReadFieldValue = function(fieldWrapper) {
4780
+ const getDirectChild = (parent, selector) => {
4781
+ if (!(parent instanceof Element)) return null;
4782
+ return Array.from(parent.children).find(
4783
+ (child) => child instanceof Element && child.matches(selector),
4784
+ ) || null;
4785
+ };
4786
+ const getDirectStructuredSubfields = (host) =>
4787
+ Array.from(host.children).filter(
4788
+ (child) => child instanceof Element && child.classList.contains('structured-subfield'),
4789
+ );
4790
+ const getStructuredObjectFieldsHost = (container) => {
4791
+ const directFieldsHost = getDirectChild(container, '[data-structured-object-fields]');
4792
+ if (directFieldsHost) return directFieldsHost;
4793
+ const groupContent = getDirectChild(container, '.field-group-content');
4794
+ const nestedFieldsHost = groupContent
4795
+ ? getDirectChild(groupContent, '[data-structured-object-fields]')
4796
+ : null;
4797
+ if (nestedFieldsHost) return nestedFieldsHost;
4798
+ return getDirectChild(container, '[data-array-item-fields]') || container;
4799
+ };
4800
+ const getDirectStructuredObject = (fieldWrapper) => {
4801
+ const directObject = getDirectChild(fieldWrapper, '[data-structured-object]');
4802
+ if (directObject) return directObject;
4803
+ const formGroup = getDirectChild(fieldWrapper, '.form-group');
4804
+ return formGroup ? getDirectChild(formGroup, '[data-structured-object]') : null;
4805
+ };
4806
+ const getDirectStructuredArray = (fieldWrapper) => {
4807
+ const directArray = getDirectChild(fieldWrapper, '[data-structured-array]');
4808
+ if (directArray) return directArray;
4809
+ const formGroup = getDirectChild(fieldWrapper, '.form-group');
4810
+ return formGroup ? getDirectChild(formGroup, '[data-structured-array]') : null;
4811
+ };
4812
+
4778
4813
  const fieldType = fieldWrapper.dataset.fieldType;
4779
4814
  const select = fieldWrapper.querySelector('select');
4780
4815
  const textarea = fieldWrapper.querySelector('textarea');
@@ -4784,7 +4819,47 @@ function getReadFieldValueScript() {
4784
4819
  const nonHiddenInput = inputs.find((input) => input.type !== 'hidden' && input.type !== 'checkbox');
4785
4820
  const hiddenInput = inputs.find((input) => input.type === 'hidden');
4786
4821
 
4822
+ const readStructuredFieldsHost = (host) => {
4823
+ const fields = getDirectStructuredSubfields(host);
4824
+ if (fields.length === 1 && fields[0].dataset.structuredField === '__value') {
4825
+ return window.sonicReadFieldValue(fields[0]);
4826
+ }
4827
+ return fields.reduce((acc, subfield) => {
4828
+ const fieldName = subfield.dataset.structuredField;
4829
+ if (!fieldName || fieldName === '__value') return acc;
4830
+ acc[fieldName] = window.sonicReadFieldValue(subfield);
4831
+ return acc;
4832
+ }, {});
4833
+ };
4834
+
4835
+ const readStructuredObject = () => {
4836
+ const objectContainer = getDirectStructuredObject(fieldWrapper);
4837
+ if (!objectContainer) return null;
4838
+ const host = getStructuredObjectFieldsHost(objectContainer);
4839
+ return readStructuredFieldsHost(host);
4840
+ };
4841
+
4842
+ const readStructuredArray = () => {
4843
+ const arrayContainer = getDirectStructuredArray(fieldWrapper);
4844
+ if (!arrayContainer) return null;
4845
+ const list = arrayContainer.querySelector('[data-structured-array-list]');
4846
+ if (!list) return [];
4847
+ const items = Array.from(list.querySelectorAll(':scope > .structured-array-item'));
4848
+ return items.map((item) => {
4849
+ const host =
4850
+ item.querySelector(':scope > [data-array-item-fields]') ||
4851
+ item.querySelector('[data-array-item-fields]') ||
4852
+ item;
4853
+ return readStructuredFieldsHost(host);
4854
+ });
4855
+ };
4856
+
4787
4857
  if (fieldType === 'object' || fieldType === 'array') {
4858
+ const liveValue = fieldType === 'array' ? readStructuredArray() : readStructuredObject();
4859
+ if (liveValue !== null) {
4860
+ return liveValue;
4861
+ }
4862
+
4788
4863
  if (!hiddenInput) {
4789
4864
  return fieldType === 'array' ? [] : {};
4790
4865
  }
@@ -4833,6 +4908,15 @@ function getReadFieldValueScript() {
4833
4908
  </script>
4834
4909
  `;
4835
4910
  }
4911
+ var STRUCTURED_INDEX_TOKEN = "__INDEX__";
4912
+ var BLOCK_INDEX_TOKEN = "__BLOCK_INDEX__";
4913
+ function sanitizeStructuredGroupId(fieldName) {
4914
+ return `object-${fieldName}`.split(BLOCK_INDEX_TOKEN).map(
4915
+ (blockSegment) => blockSegment.split(STRUCTURED_INDEX_TOKEN).map(
4916
+ (segment) => segment.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
4917
+ ).join(STRUCTURED_INDEX_TOKEN)
4918
+ ).join(BLOCK_INDEX_TOKEN);
4919
+ }
4836
4920
  function isMarkdownEditorFieldType(fieldType) {
4837
4921
  return fieldType === "markdown" || fieldType === "mdxeditor" || fieldType === "easymde";
4838
4922
  }
@@ -5413,12 +5497,14 @@ function renderDynamicField(field, options = {}) {
5413
5497
 
5414
5498
  ${isMultiple ? `
5415
5499
  <div class="media-preview-grid grid grid-cols-4 gap-2 mb-2 ${mediaValues.length === 0 ? "hidden" : ""}" id="${fieldId}-preview">
5416
- ${mediaValues.map((url, idx) => `
5500
+ ${mediaValues.map(
5501
+ (url, idx) => `
5417
5502
  <div class="relative media-preview-item" data-url="${url}">
5418
5503
  ${renderMediaPreview(url, `Media ${idx + 1}`, "w-full h-24 object-cover rounded-lg border border-white/20")}
5419
5504
  <button
5420
5505
  type="button"
5421
5506
  onclick="removeMediaFromMultiple('${fieldId}', '${url}')"
5507
+ data-media-remove="true"
5422
5508
  class="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 hover:bg-red-700"
5423
5509
  ${disabled ? "disabled" : ""}
5424
5510
  >
@@ -5427,7 +5513,8 @@ function renderDynamicField(field, options = {}) {
5427
5513
  </svg>
5428
5514
  </button>
5429
5515
  </div>
5430
- `).join("")}
5516
+ `
5517
+ ).join("")}
5431
5518
  </div>
5432
5519
  ` : `
5433
5520
  <div class="media-preview ${singleValue ? "" : "hidden"}" id="${fieldId}-preview">
@@ -5451,6 +5538,7 @@ function renderDynamicField(field, options = {}) {
5451
5538
  <button
5452
5539
  type="button"
5453
5540
  onclick="clearMediaField('${fieldId}')"
5541
+ data-media-remove="true"
5454
5542
  class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all"
5455
5543
  ${disabled ? "disabled" : ""}
5456
5544
  >
@@ -5484,7 +5572,7 @@ function renderDynamicField(field, options = {}) {
5484
5572
  }
5485
5573
  const showLabel = field.field_type !== "boolean";
5486
5574
  return `
5487
- <div class="form-group">
5575
+ <div class="form-group" data-has-errors="${errors.length > 0 ? "true" : "false"}">
5488
5576
  ${showLabel ? `
5489
5577
  <label for="${fieldId}" class="block text-sm/6 font-medium text-zinc-950 dark:text-white mb-2">
5490
5578
  ${escapeHtml3(field.field_label)}
@@ -5493,7 +5581,7 @@ function renderDynamicField(field, options = {}) {
5493
5581
  ` : ""}
5494
5582
  ${fieldHTML}
5495
5583
  ${errors.length > 0 ? `
5496
- <div class="mt-2 text-sm text-pink-600 dark:text-pink-400">
5584
+ <div class="mt-2 text-sm text-pink-600 dark:text-pink-400" data-validation-error-message>
5497
5585
  ${errors.map((error) => `<div>${escapeHtml3(error)}</div>`).join("")}
5498
5586
  </div>
5499
5587
  ` : ""}
@@ -5508,8 +5596,8 @@ function renderDynamicField(field, options = {}) {
5508
5596
  function renderFieldGroup(title, fields, collapsible = false) {
5509
5597
  const groupId = title.toLowerCase().replace(/\s+/g, "-");
5510
5598
  return `
5511
- <div class="field-group rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 mb-6">
5512
- <div class="field-group-header border-b border-zinc-950/5 dark:border-white/10 px-6 py-4 ${collapsible ? "cursor-pointer" : ""}" ${collapsible ? `onclick="toggleFieldGroup('${groupId}')"` : ""}>
5599
+ <div class="field-group rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 mb-6" data-group-id="${escapeHtml3(groupId)}">
5600
+ <div class="field-group-header border-b border-zinc-950/5 dark:border-white/10 px-6 py-4 ${collapsible ? "cursor-pointer" : ""}" ${collapsible ? `onclick="toggleFieldGroup(this)"` : ""}>
5513
5601
  <h3 class="text-base/7 font-semibold text-zinc-950 dark:text-white flex items-center">
5514
5602
  ${escapeHtml3(title)}
5515
5603
  ${collapsible ? `
@@ -5553,6 +5641,12 @@ function renderBlocksField(field, options, baseClasses, errorClasses) {
5553
5641
  >
5554
5642
  <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml3(JSON.stringify(blockValues))}">
5555
5643
 
5644
+ <div class="flex items-center justify-between border-b border-zinc-950/5 dark:border-white/10 py-4">
5645
+ <h3 class="text-base/7 font-semibold text-zinc-950 dark:text-white">
5646
+ ${escapeHtml3(field.field_label || "Content Blocks")}
5647
+ </h3>
5648
+ </div>
5649
+
5556
5650
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5557
5651
  <div class="flex-1">
5558
5652
  <select
@@ -5583,12 +5677,14 @@ function renderBlocksField(field, options, baseClasses, errorClasses) {
5583
5677
  `;
5584
5678
  }
5585
5679
  function renderStructuredObjectField(field, options, baseClasses, errorClasses) {
5586
- const { value = {}, pluginStatuses = {} } = options;
5680
+ const { value = {}, pluginStatuses = {}, errors = [] } = options;
5587
5681
  const opts = field.field_options || {};
5588
5682
  const properties = opts.properties && typeof opts.properties === "object" ? opts.properties : {};
5589
5683
  const fieldId = `field-${field.field_name}`;
5590
5684
  const fieldName = field.field_name;
5591
5685
  const objectValue = normalizeStructuredObjectValue(value);
5686
+ const objectLayout = opts.objectLayout || "nested";
5687
+ const useNestedLayout = objectLayout !== "flat";
5592
5688
  const subfields = Object.entries(properties).map(
5593
5689
  ([propertyName, propertyConfig]) => renderStructuredSubfield(
5594
5690
  field,
@@ -5599,11 +5695,40 @@ function renderStructuredObjectField(field, options, baseClasses, errorClasses)
5599
5695
  field.field_name
5600
5696
  )
5601
5697
  ).join("");
5698
+ const groupTitle = field.field_label || field.field_name;
5699
+ if (!useNestedLayout) {
5700
+ return `
5701
+ <div class="space-y-4" data-structured-object data-field-name="${escapeHtml3(fieldName)}">
5702
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml3(JSON.stringify(objectValue))}">
5703
+ <div class="flex items-center justify-between border-b border-zinc-950/5 dark:border-white/10 py-4 first-of-type:pt-0">
5704
+ <h3 class="text-base/7 font-semibold text-zinc-950 dark:text-white">
5705
+ ${escapeHtml3(groupTitle)}
5706
+ </h3>
5707
+ </div>
5708
+ <div class="space-y-4" data-structured-object-fields>
5709
+ ${subfields}
5710
+ </div>
5711
+ </div>
5712
+ ${getStructuredFieldScript()}
5713
+ `;
5714
+ }
5715
+ const groupId = sanitizeStructuredGroupId(field.field_name);
5716
+ const isCollapsed = errors.length > 0 ? false : opts.collapsed !== false;
5602
5717
  return `
5603
- <div class="space-y-4" data-structured-object data-field-name="${escapeHtml3(fieldName)}">
5604
- <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml3(JSON.stringify(objectValue))}">
5605
- <div class="space-y-4" data-structured-object-fields>
5606
- ${subfields}
5718
+ <div class="field-group rounded-lg shadow-sm mb-6" data-group-id="${escapeHtml3(groupId)}" data-structured-object data-field-name="${escapeHtml3(fieldName)}">
5719
+ <div class="field-group-header border-b border-zinc-950/5 dark:border-white/10 pr-6 pb-4 cursor-pointer" onclick="toggleFieldGroup(this)">
5720
+ <h3 class="text-base/7 font-semibold text-zinc-950 dark:text-white flex items-center">
5721
+ ${escapeHtml3(groupTitle)}
5722
+ <svg id="${groupId}-icon" class="w-5 h-5 ml-2 transform transition-transform ${isCollapsed ? "-rotate-90" : ""} text-zinc-500 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
5723
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
5724
+ </svg>
5725
+ </h3>
5726
+ </div>
5727
+ <div id="${groupId}-content" class="field-group-content px-6 py-6 space-y-4 ${isCollapsed ? "hidden" : ""}">
5728
+ <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml3(JSON.stringify(objectValue))}">
5729
+ <div class="space-y-4" data-structured-object-fields>
5730
+ ${subfields}
5731
+ </div>
5607
5732
  </div>
5608
5733
  </div>
5609
5734
  ${getStructuredFieldScript()}
@@ -5674,7 +5799,7 @@ function renderStructuredArrayField(field, options, baseClasses, errorClasses) {
5674
5799
  function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginStatuses, arrayItemTitle) {
5675
5800
  const itemFields = renderStructuredItemFields(field, itemConfig, index, itemValue, pluginStatuses);
5676
5801
  return `
5677
- <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="${escapeHtml3(index)}" draggable="true">
5802
+ <div class="structured-array-item rounded-lg border border-zinc-200 dark:border-white/10 bg-white/60 dark:bg-zinc-600/5 p-4 shadow-lg shadow-zinc-950/20" data-array-index="${escapeHtml3(index)}" draggable="true">
5678
5803
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5679
5804
  <div class="flex items-center gap-3">
5680
5805
  <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">
@@ -5687,6 +5812,11 @@ function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginSt
5687
5812
  </div>
5688
5813
  </div>
5689
5814
  <div class="flex flex-wrap gap-2 text-xs">
5815
+ <button type="button" data-action="toggle-item" 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" aria-label="Expand item" title="Expand">
5816
+ <svg class="h-4 w-4 transition-transform -rotate-90 text-zinc-500 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" data-item-toggle-icon>
5817
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
5818
+ </svg>
5819
+ </button>
5690
5820
  <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">
5691
5821
  <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
5692
5822
  <path stroke-linecap="round" stroke-linejoin="round" d="M12 6l-4 4m4-4l4 4m-4-4v12"/>
@@ -5705,7 +5835,7 @@ function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginSt
5705
5835
  </button>
5706
5836
  </div>
5707
5837
  </div>
5708
- <div class="mt-4 space-y-4" data-array-item-fields>
5838
+ <div class="mt-4 space-y-4 hidden" data-array-item-fields>
5709
5839
  ${itemFields}
5710
5840
  </div>
5711
5841
  </div>
@@ -5815,7 +5945,7 @@ function normalizeBlocksValue(value, discriminator) {
5815
5945
  function renderBlockTemplate(field, block, discriminator, pluginStatuses) {
5816
5946
  return `
5817
5947
  <template data-block-template="${escapeHtml3(block.name)}">
5818
- ${renderBlockCard(field, block, discriminator, "__INDEX__", {}, pluginStatuses)}
5948
+ ${renderBlockCard(field, block, discriminator, BLOCK_INDEX_TOKEN, {}, pluginStatuses)}
5819
5949
  </template>
5820
5950
  `;
5821
5951
  }
@@ -5857,7 +5987,7 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5857
5987
  `;
5858
5988
  }).join("");
5859
5989
  return `
5860
- <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="${escapeHtml3(block.name)}" data-block-discriminator="${escapeHtml3(discriminator)}" draggable="true">
5990
+ <div class="blocks-item rounded-lg border border-zinc-200 dark:border-white/10 dark:bg-zinc-600/5 p-4 shadow-lg shadow-zinc-950/20" data-block-type="${escapeHtml3(block.name)}" data-block-discriminator="${escapeHtml3(discriminator)}" draggable="true">
5861
5991
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5862
5992
  <div class="flex items-start gap-3">
5863
5993
  <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">
@@ -5865,7 +5995,7 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5865
5995
  <path stroke-linecap="round" stroke-linejoin="round" d="M4 8h16M4 16h16"/>
5866
5996
  </svg>
5867
5997
  </div>
5868
- <div>
5998
+ <div class="cursor-pointer" data-action="toggle-block">
5869
5999
  <div class="text-sm font-semibold text-zinc-900 dark:text-white">
5870
6000
  ${escapeHtml3(block.label)}
5871
6001
  <span class="ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400" data-block-order-label></span>
@@ -5874,6 +6004,11 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5874
6004
  </div>
5875
6005
  </div>
5876
6006
  <div class="flex flex-wrap gap-2 text-xs">
6007
+ <button type="button" data-action="toggle-block" 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" aria-label="Expand block" title="Expand">
6008
+ <svg class="h-4 w-4 transition-transform -rotate-90 text-zinc-500 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" data-block-toggle-icon>
6009
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
6010
+ </svg>
6011
+ </button>
5877
6012
  <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">
5878
6013
  <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
5879
6014
  <path stroke-linecap="round" stroke-linejoin="round" d="M12 6l-4 4m4-4l4 4m-4-4v12"/>
@@ -5892,7 +6027,7 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5892
6027
  </button>
5893
6028
  </div>
5894
6029
  </div>
5895
- <div class="mt-4 space-y-4">
6030
+ <div class="mt-4 space-y-4 hidden" data-block-content>
5896
6031
  ${blockFields}
5897
6032
  </div>
5898
6033
  </div>
@@ -5926,9 +6061,101 @@ function getStructuredFieldScript() {
5926
6061
 
5927
6062
  function initializeStructuredFields() {
5928
6063
  const readFieldValue = window.sonicReadFieldValue;
6064
+ const getDirectChild = (parent, selector) => {
6065
+ if (!(parent instanceof Element)) return null;
6066
+ return Array.from(parent.children).find(
6067
+ (child) => child instanceof Element && child.matches(selector),
6068
+ ) || null;
6069
+ };
6070
+ const getDirectStructuredSubfields = (host) =>
6071
+ Array.from(host.children).filter(
6072
+ (child) => child instanceof Element && child.classList.contains('structured-subfield'),
6073
+ );
6074
+ const getStructuredValueHost = (container) => {
6075
+ const directObjectHost = getDirectChild(container, '[data-structured-object-fields]');
6076
+ if (directObjectHost) return directObjectHost;
6077
+ const groupContent = getDirectChild(container, '.field-group-content');
6078
+ const nestedObjectHost = groupContent
6079
+ ? getDirectChild(groupContent, '[data-structured-object-fields]')
6080
+ : null;
6081
+ if (nestedObjectHost) return nestedObjectHost;
6082
+ return getDirectChild(container, '[data-array-item-fields]') || container;
6083
+ };
6084
+ const getCollectionScope = () => {
6085
+ const url = new URL(window.location.href);
6086
+ const collectionFromQuery = url.searchParams.get('collection');
6087
+ const form = document.getElementById('content-form');
6088
+ const collectionInput = form?.querySelector('input[name="collection_id"]');
6089
+ const collectionFromForm = collectionInput instanceof HTMLInputElement ? collectionInput.value : '';
6090
+ const collectionId = collectionFromQuery || collectionFromForm || '';
6091
+ return window.location.pathname + ':' + collectionId;
6092
+ };
6093
+
6094
+ const getArrayStateKey = (container) => {
6095
+ const fieldName = container.dataset.fieldName || 'unknown';
6096
+ return 'sonic:ui:repeaters:' + getCollectionScope() + ':' + fieldName;
6097
+ };
6098
+
6099
+ const readArrayState = (container) => {
6100
+ try {
6101
+ const raw = sessionStorage.getItem(getArrayStateKey(container));
6102
+ if (!raw) return null;
6103
+ const parsed = JSON.parse(raw);
6104
+ return Array.isArray(parsed) ? parsed : null;
6105
+ } catch {
6106
+ return null;
6107
+ }
6108
+ };
6109
+
6110
+ const writeArrayState = (container, state) => {
6111
+ try {
6112
+ sessionStorage.setItem(getArrayStateKey(container), JSON.stringify(state));
6113
+ } catch {}
6114
+ };
6115
+
6116
+ const setArrayItemExpanded = (item, isExpanded) => {
6117
+ const content = item.querySelector('[data-array-item-fields]');
6118
+ const icon = item.querySelector('[data-item-toggle-icon]');
6119
+ if (content instanceof HTMLElement) {
6120
+ content.classList.toggle('hidden', !isExpanded);
6121
+ }
6122
+ if (icon instanceof Element) {
6123
+ icon.classList.toggle('-rotate-90', !isExpanded);
6124
+ }
6125
+ };
6126
+
6127
+ const getArrayItems = (container, list) => {
6128
+ if (list) {
6129
+ return Array.from(list.querySelectorAll(':scope > .structured-array-item'));
6130
+ }
6131
+ return Array.from(
6132
+ container.querySelectorAll(':scope > [data-structured-array-list] > .structured-array-item'),
6133
+ );
6134
+ };
6135
+
6136
+ const captureArrayState = (container) => {
6137
+ return getArrayItems(container).map((item) => {
6138
+ const content = item.querySelector('[data-array-item-fields]');
6139
+ return content instanceof HTMLElement ? !content.classList.contains('hidden') : false;
6140
+ });
6141
+ };
6142
+
6143
+ const applyArrayState = (container, state) => {
6144
+ const items = getArrayItems(container);
6145
+ items.forEach((item, index) => {
6146
+ if (typeof state[index] === 'boolean') {
6147
+ setArrayItemExpanded(item, state[index]);
6148
+ }
6149
+ });
6150
+ };
6151
+
6152
+ const syncArrayState = (container) => {
6153
+ writeArrayState(container, captureArrayState(container));
6154
+ };
5929
6155
 
5930
6156
  const readStructuredValue = (container) => {
5931
- const fields = Array.from(container.querySelectorAll('.structured-subfield'));
6157
+ const fieldHost = getStructuredValueHost(container);
6158
+ const fields = getDirectStructuredSubfields(fieldHost);
5932
6159
  if (fields.length === 1 && fields[0].dataset.structuredField === '__value') {
5933
6160
  return readFieldValue(fields[0]);
5934
6161
  }
@@ -5942,111 +6169,229 @@ function getStructuredFieldScript() {
5942
6169
  };
5943
6170
 
5944
6171
  document.querySelectorAll('[data-structured-object]').forEach((container) => {
6172
+ if (container.closest('template')) {
6173
+ return;
6174
+ }
5945
6175
  if (container.dataset.structuredInitialized === 'true') {
5946
6176
  return;
5947
6177
  }
5948
- container.dataset.structuredInitialized = 'true';
5949
- const hiddenInput = container.querySelector('input[type="hidden"]');
6178
+ if (container.dataset.structuredInitializing === 'true') {
6179
+ return;
6180
+ }
6181
+ container.dataset.structuredInitializing = 'true';
6182
+ try {
6183
+ const hiddenInput = container.querySelector('input[type="hidden"]');
5950
6184
 
5951
- const updateHiddenInput = () => {
5952
- if (!hiddenInput) return;
5953
- const value = readStructuredValue(container);
5954
- hiddenInput.value = JSON.stringify(value);
5955
- };
6185
+ const updateHiddenInput = () => {
6186
+ if (!hiddenInput) return;
6187
+ const value = readStructuredValue(container);
6188
+ hiddenInput.value = JSON.stringify(value);
6189
+ };
5956
6190
 
5957
- container.addEventListener('input', updateHiddenInput);
5958
- container.addEventListener('change', updateHiddenInput);
5959
- updateHiddenInput();
6191
+ container.addEventListener('input', updateHiddenInput);
6192
+ container.addEventListener('change', updateHiddenInput);
6193
+ updateHiddenInput();
6194
+ container.dataset.structuredInitialized = 'true';
6195
+ } catch (error) {
6196
+ delete container.dataset.structuredInitialized;
6197
+ console.error('[structured-object] initialization failed', error);
6198
+ } finally {
6199
+ delete container.dataset.structuredInitializing;
6200
+ }
5960
6201
  });
5961
6202
 
5962
6203
  document.querySelectorAll('[data-structured-array]').forEach((container) => {
6204
+ if (container.closest('template')) {
6205
+ return;
6206
+ }
5963
6207
  if (container.dataset.structuredInitialized === 'true') {
5964
6208
  return;
5965
6209
  }
5966
- container.dataset.structuredInitialized = 'true';
5967
- const list = container.querySelector('[data-structured-array-list]');
5968
- const hiddenInput = container.querySelector('input[type="hidden"]');
5969
- const template = container.querySelector('template[data-structured-array-template]');
6210
+ if (container.dataset.structuredInitializing === 'true') {
6211
+ return;
6212
+ }
6213
+ container.dataset.structuredInitializing = 'true';
6214
+ try {
6215
+ const list = container.querySelector(':scope > [data-structured-array-list]');
6216
+ const hiddenInput = container.querySelector(':scope > input[type="hidden"]');
6217
+ const template = container.querySelector(':scope > template[data-structured-array-template]');
6218
+ if (
6219
+ template instanceof HTMLTemplateElement &&
6220
+ typeof template.innerHTML === 'string' &&
6221
+ template.innerHTML.trim()
6222
+ ) {
6223
+ container.__sonicStructuredArrayTemplate = template.innerHTML;
6224
+ }
5970
6225
 
5971
- const updateOrderLabels = () => {
5972
- const items = Array.from(container.querySelectorAll('.structured-array-item'));
5973
- items.forEach((item, index) => {
5974
- const label = item.querySelector('[data-array-order-label]');
5975
- if (label) {
5976
- label.textContent = '#'+ (index + 1);
6226
+ const getLiveList = () =>
6227
+ list || container.querySelector(':scope > [data-structured-array-list]');
6228
+ const getLiveHiddenInput = () =>
6229
+ hiddenInput || container.querySelector(':scope > input[type="hidden"]');
6230
+ const getTemplateHtml = () => {
6231
+ if (typeof container.__sonicStructuredArrayTemplate === 'string' &&
6232
+ container.__sonicStructuredArrayTemplate.trim()) {
6233
+ return container.__sonicStructuredArrayTemplate;
5977
6234
  }
5978
6235
 
5979
- const moveUpButton = item.querySelector('[data-action="move-up"]');
5980
- if (moveUpButton instanceof HTMLButtonElement) {
5981
- moveUpButton.disabled = index === 0;
6236
+ const liveTemplate =
6237
+ template instanceof HTMLTemplateElement
6238
+ ? template
6239
+ : container.querySelector(':scope > template[data-structured-array-template]');
6240
+ if (
6241
+ liveTemplate instanceof HTMLTemplateElement &&
6242
+ typeof liveTemplate.innerHTML === 'string' &&
6243
+ liveTemplate.innerHTML.trim()
6244
+ ) {
6245
+ container.__sonicStructuredArrayTemplate = liveTemplate.innerHTML;
6246
+ return liveTemplate.innerHTML;
5982
6247
  }
6248
+ return typeof container.__sonicStructuredArrayTemplate === 'string'
6249
+ ? container.__sonicStructuredArrayTemplate
6250
+ : '';
6251
+ };
5983
6252
 
5984
- const moveDownButton = item.querySelector('[data-action="move-down"]');
5985
- if (moveDownButton instanceof HTMLButtonElement) {
5986
- moveDownButton.disabled = index === items.length - 1;
6253
+ const updateOrderLabels = () => {
6254
+ const liveList = getLiveList();
6255
+ if (!liveList) return;
6256
+ const items = getArrayItems(container, liveList);
6257
+ items.forEach((item, index) => {
6258
+ const label = item.querySelector('[data-array-order-label]');
6259
+ if (label) {
6260
+ label.textContent = '#'+ (index + 1);
6261
+ }
6262
+
6263
+ const moveUpButton = item.querySelector('[data-action="move-up"]');
6264
+ if (moveUpButton instanceof HTMLButtonElement) {
6265
+ moveUpButton.disabled = index === 0;
6266
+ }
6267
+
6268
+ const moveDownButton = item.querySelector('[data-action="move-down"]');
6269
+ if (moveDownButton instanceof HTMLButtonElement) {
6270
+ moveDownButton.disabled = index === items.length - 1;
6271
+ }
6272
+ });
6273
+ };
6274
+
6275
+ const updateHiddenInput = () => {
6276
+ const liveHiddenInput = getLiveHiddenInput();
6277
+ const liveList = getLiveList();
6278
+ if (!liveHiddenInput || !liveList) return;
6279
+ const items = getArrayItems(container, liveList);
6280
+ const values = items.map((item) => readStructuredValue(item));
6281
+ liveHiddenInput.value = JSON.stringify(values);
6282
+ // Notify parent structured containers after non-input actions (add/remove/move)
6283
+ // so nested array mutations are persisted correctly.
6284
+ liveHiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
6285
+
6286
+ const emptyState = liveList.querySelector(':scope > [data-structured-empty]');
6287
+ if (emptyState) {
6288
+ emptyState.style.display = values.length === 0 ? 'block' : 'none';
5987
6289
  }
5988
- });
5989
- };
6290
+ updateOrderLabels();
6291
+ };
5990
6292
 
5991
- const updateHiddenInput = () => {
5992
- if (!hiddenInput || !list) return;
5993
- const items = Array.from(list.querySelectorAll('.structured-array-item'));
5994
- const values = items.map((item) => readStructuredValue(item));
5995
- hiddenInput.value = JSON.stringify(values);
6293
+ const addArrayItem = () => {
6294
+ const liveList = getLiveList();
6295
+ if (!liveList) return;
6296
+ const templateHtml = getTemplateHtml();
6297
+ if (!templateHtml) return;
6298
+ try {
6299
+ const nextIndex = getArrayItems(container, liveList).length;
6300
+ const html = templateHtml.replace(/__INDEX__/g, String(nextIndex));
6301
+ liveList.insertAdjacentHTML('beforeend', html);
6302
+ const newItem = liveList.lastElementChild;
6303
+ if (newItem instanceof HTMLElement) {
6304
+ // Ensure cloned template content can be initialized even if stale
6305
+ // data-structured-initialized attributes were copied.
6306
+ newItem
6307
+ .querySelectorAll('[data-structured-object], [data-structured-array]')
6308
+ .forEach((nestedContainer) => {
6309
+ if (nestedContainer instanceof HTMLElement) {
6310
+ delete nestedContainer.dataset.structuredInitialized;
6311
+ }
6312
+ });
6313
+ setArrayItemExpanded(newItem, true);
6314
+ }
6315
+ if (typeof initializeTinyMCE === 'function') {
6316
+ initializeTinyMCE();
6317
+ }
6318
+ if (typeof window.initializeQuillEditors === 'function') {
6319
+ window.initializeQuillEditors();
6320
+ }
6321
+ if (typeof initializeMDXEditor === 'function') {
6322
+ initializeMDXEditor();
6323
+ }
6324
+ if (typeof window.initializeStructuredFields === 'function') {
6325
+ window.initializeStructuredFields();
6326
+ }
6327
+ updateHiddenInput();
6328
+ syncArrayState(container);
6329
+ } catch (error) {
6330
+ console.error('[structured-array] add-item failed', error);
6331
+ }
6332
+ };
5996
6333
 
5997
- const emptyState = list.querySelector('[data-structured-empty]');
5998
- if (emptyState) {
5999
- emptyState.style.display = values.length === 0 ? 'block' : 'none';
6334
+ const topLevelAddButton = container.querySelector(
6335
+ ':scope > .flex.items-center.justify-between.gap-3 [data-action="add-item"]',
6336
+ );
6337
+ if (topLevelAddButton instanceof HTMLElement) {
6338
+ topLevelAddButton.addEventListener('click', (event) => {
6339
+ event.preventDefault();
6340
+ event.stopPropagation();
6341
+ addArrayItem();
6342
+ });
6000
6343
  }
6001
- updateOrderLabels();
6002
- };
6003
6344
 
6004
- if (typeof window.initializeDragSortable === 'function' && list) {
6005
- window.initializeDragSortable(list, {
6006
- itemSelector: '.structured-array-item',
6007
- handleSelector: '[data-action="drag-handle"]',
6008
- onUpdate: updateHiddenInput
6009
- });
6010
- }
6345
+ const dragList = getLiveList();
6346
+ if (typeof window.initializeDragSortable === 'function' && dragList) {
6347
+ window.initializeDragSortable(dragList, {
6348
+ itemSelector: '.structured-array-item',
6349
+ handleSelector: '[data-action="drag-handle"]',
6350
+ onUpdate: () => {
6351
+ updateHiddenInput();
6352
+ syncArrayState(container);
6353
+ }
6354
+ });
6355
+ }
6011
6356
 
6012
- container.addEventListener('click', (event) => {
6357
+ container.addEventListener('click', (event) => {
6013
6358
  const target = event.target;
6014
6359
  if (!(target instanceof Element)) return;
6015
6360
  const actionButton = target.closest('[data-action]');
6016
6361
  if (!actionButton || actionButton.hasAttribute('disabled')) return;
6362
+ const actionOwner = actionButton.closest('[data-structured-array]');
6363
+ if (actionOwner !== container) return;
6017
6364
 
6018
- const action = actionButton.getAttribute('data-action');
6365
+ const action = actionButton.getAttribute('data-action');
6019
6366
 
6020
- if (action === 'add-item') {
6021
- if (!list || !template) return;
6022
- const nextIndex = list.querySelectorAll('.structured-array-item').length;
6023
- const html = template.innerHTML.replace(/__INDEX__/g, String(nextIndex));
6024
- list.insertAdjacentHTML('beforeend', html);
6025
- if (typeof initializeTinyMCE === 'function') {
6026
- initializeTinyMCE();
6027
- }
6028
- if (typeof window.initializeQuillEditors === 'function') {
6029
- window.initializeQuillEditors();
6030
- }
6031
- if (typeof initializeMDXEditor === 'function') {
6032
- initializeMDXEditor();
6367
+ if (action === 'add-item') {
6368
+ addArrayItem();
6369
+ return;
6033
6370
  }
6034
- updateHiddenInput();
6035
- return;
6036
- }
6037
6371
 
6038
6372
  const item = actionButton.closest('.structured-array-item');
6039
- if (!item || !list) return;
6373
+ const liveList = getLiveList();
6374
+ if (!item || !liveList) return;
6375
+
6376
+ if (action === 'toggle-item') {
6377
+ const content = item.querySelector('[data-array-item-fields]');
6378
+ if (!(content instanceof HTMLElement)) return;
6379
+ setArrayItemExpanded(item, content.classList.contains('hidden'));
6380
+ syncArrayState(container);
6381
+ return;
6382
+ }
6040
6383
 
6041
6384
  if (action === 'remove-item') {
6042
6385
  if (typeof requestRepeaterDelete === 'function') {
6043
6386
  requestRepeaterDelete(() => {
6044
6387
  item.remove();
6045
6388
  updateHiddenInput();
6389
+ syncArrayState(container);
6046
6390
  });
6047
6391
  } else {
6048
6392
  item.remove();
6049
6393
  updateHiddenInput();
6394
+ syncArrayState(container);
6050
6395
  }
6051
6396
  return;
6052
6397
  }
@@ -6054,8 +6399,9 @@ function getStructuredFieldScript() {
6054
6399
  if (action === 'move-up') {
6055
6400
  const previous = item.previousElementSibling;
6056
6401
  if (previous) {
6057
- list.insertBefore(item, previous);
6402
+ liveList.insertBefore(item, previous);
6058
6403
  updateHiddenInput();
6404
+ syncArrayState(container);
6059
6405
  }
6060
6406
  return;
6061
6407
  }
@@ -6063,29 +6409,43 @@ function getStructuredFieldScript() {
6063
6409
  if (action === 'move-down') {
6064
6410
  const next = item.nextElementSibling;
6065
6411
  if (next) {
6066
- list.insertBefore(next, item);
6412
+ liveList.insertBefore(next, item);
6067
6413
  updateHiddenInput();
6414
+ syncArrayState(container);
6068
6415
  }
6069
6416
  }
6070
- });
6417
+ });
6071
6418
 
6072
- container.addEventListener('input', (event) => {
6419
+ container.addEventListener('input', (event) => {
6073
6420
  const target = event.target;
6074
6421
  if (!(target instanceof Element)) return;
6075
6422
  if (target.closest('[data-structured-array-list]')) {
6076
6423
  updateHiddenInput();
6077
6424
  }
6078
- });
6425
+ });
6079
6426
 
6080
- container.addEventListener('change', (event) => {
6427
+ container.addEventListener('change', (event) => {
6081
6428
  const target = event.target;
6082
6429
  if (!(target instanceof Element)) return;
6083
6430
  if (target.closest('[data-structured-array-list]')) {
6084
6431
  updateHiddenInput();
6085
6432
  }
6086
- });
6433
+ });
6087
6434
 
6088
- updateHiddenInput();
6435
+ updateHiddenInput();
6436
+ const savedArrayState = readArrayState(container);
6437
+ if (savedArrayState) {
6438
+ applyArrayState(container, savedArrayState);
6439
+ } else {
6440
+ syncArrayState(container);
6441
+ }
6442
+ container.dataset.structuredInitialized = 'true';
6443
+ } catch (error) {
6444
+ delete container.dataset.structuredInitialized;
6445
+ console.error('[structured-array] initialization failed', error);
6446
+ } finally {
6447
+ delete container.dataset.structuredInitializing;
6448
+ }
6089
6449
  });
6090
6450
  }
6091
6451
 
@@ -6100,7 +6460,10 @@ function getStructuredFieldScript() {
6100
6460
  document.addEventListener('htmx:afterSwap', function() {
6101
6461
  setTimeout(initializeStructuredFields, 50);
6102
6462
  });
6103
- } else if (typeof window.initializeStructuredFields === 'function') {
6463
+ } else if (
6464
+ typeof window.initializeStructuredFields === 'function' &&
6465
+ document.readyState !== 'loading'
6466
+ ) {
6104
6467
  window.initializeStructuredFields();
6105
6468
  }
6106
6469
  </script>
@@ -6112,6 +6475,68 @@ function getBlocksFieldScript() {
6112
6475
  <script>
6113
6476
  if (!window.__sonicBlocksFieldInit) {
6114
6477
  window.__sonicBlocksFieldInit = true;
6478
+ const getCollectionScope = () => {
6479
+ const url = new URL(window.location.href);
6480
+ const collectionFromQuery = url.searchParams.get('collection');
6481
+ const form = document.getElementById('content-form');
6482
+ const collectionInput = form?.querySelector('input[name="collection_id"]');
6483
+ const collectionFromForm = collectionInput instanceof HTMLInputElement ? collectionInput.value : '';
6484
+ const collectionId = collectionFromQuery || collectionFromForm || '';
6485
+ return window.location.pathname + ':' + collectionId;
6486
+ };
6487
+
6488
+ const getBlocksStateKey = (container) => {
6489
+ const fieldName = container.dataset.fieldName || 'unknown';
6490
+ return 'sonic:ui:blocks:' + getCollectionScope() + ':' + fieldName;
6491
+ };
6492
+
6493
+ const readBlocksState = (container) => {
6494
+ try {
6495
+ const raw = sessionStorage.getItem(getBlocksStateKey(container));
6496
+ if (!raw) return null;
6497
+ const parsed = JSON.parse(raw);
6498
+ return Array.isArray(parsed) ? parsed : null;
6499
+ } catch {
6500
+ return null;
6501
+ }
6502
+ };
6503
+
6504
+ const writeBlocksState = (container, state) => {
6505
+ try {
6506
+ sessionStorage.setItem(getBlocksStateKey(container), JSON.stringify(state));
6507
+ } catch {}
6508
+ };
6509
+
6510
+ const setBlockExpanded = (item, isExpanded) => {
6511
+ const content = item.querySelector('[data-block-content]');
6512
+ const icon = item.querySelector('[data-block-toggle-icon]');
6513
+ if (content instanceof HTMLElement) {
6514
+ content.classList.toggle('hidden', !isExpanded);
6515
+ }
6516
+ if (icon instanceof Element) {
6517
+ icon.classList.toggle('-rotate-90', !isExpanded);
6518
+ }
6519
+ };
6520
+
6521
+ const captureBlocksState = (container) => {
6522
+ return Array.from(container.querySelectorAll('.blocks-item')).map((item) => {
6523
+ const content = item.querySelector('[data-block-content]');
6524
+ return content instanceof HTMLElement ? !content.classList.contains('hidden') : false;
6525
+ });
6526
+ };
6527
+
6528
+ const applyBlocksState = (container, state) => {
6529
+ const items = Array.from(container.querySelectorAll('.blocks-item'));
6530
+ items.forEach((item, index) => {
6531
+ if (typeof state[index] === 'boolean') {
6532
+ setBlockExpanded(item, state[index]);
6533
+ }
6534
+ });
6535
+ };
6536
+
6537
+ const syncBlocksState = (container) => {
6538
+ writeBlocksState(container, captureBlocksState(container));
6539
+ };
6115
6540
 
6116
6541
  function initializeBlocksFields() {
6117
6542
  document.querySelectorAll('.blocks-field').forEach((container) => {
@@ -6199,7 +6624,10 @@ function getBlocksFieldScript() {
6199
6624
  window.initializeDragSortable(list, {
6200
6625
  itemSelector: '.blocks-item',
6201
6626
  handleSelector: '[data-action="drag-handle"]',
6202
- onUpdate: updateHiddenInput
6627
+ onUpdate: () => {
6628
+ updateHiddenInput();
6629
+ syncBlocksState(container);
6630
+ }
6203
6631
  });
6204
6632
  }
6205
6633
 
@@ -6221,8 +6649,12 @@ function getBlocksFieldScript() {
6221
6649
  if (!template) return;
6222
6650
 
6223
6651
  const nextIndex = list.querySelectorAll('.blocks-item').length;
6224
- const html = template.innerHTML.replace(/__INDEX__/g, String(nextIndex));
6652
+ const html = template.innerHTML.replace(/__BLOCK_INDEX__/g, String(nextIndex));
6225
6653
  list.insertAdjacentHTML('beforeend', html);
6654
+ const newItem = list.lastElementChild;
6655
+ if (newItem instanceof HTMLElement) {
6656
+ setBlockExpanded(newItem, true);
6657
+ }
6226
6658
  if (typeSelect) {
6227
6659
  typeSelect.value = '';
6228
6660
  }
@@ -6231,21 +6663,32 @@ function getBlocksFieldScript() {
6231
6663
  window.initializeStructuredFields();
6232
6664
  }
6233
6665
  updateHiddenInput();
6666
+ syncBlocksState(container);
6234
6667
  return;
6235
6668
  }
6236
6669
 
6237
6670
  const item = actionButton.closest('.blocks-item');
6238
6671
  if (!item || !list) return;
6239
6672
 
6673
+ if (action === 'toggle-block') {
6674
+ const content = item.querySelector('[data-block-content]');
6675
+ if (!(content instanceof HTMLElement)) return;
6676
+ setBlockExpanded(item, content.classList.contains('hidden'));
6677
+ syncBlocksState(container);
6678
+ return;
6679
+ }
6680
+
6240
6681
  if (action === 'remove-block') {
6241
6682
  if (typeof requestRepeaterDelete === 'function') {
6242
6683
  requestRepeaterDelete(() => {
6243
6684
  item.remove();
6244
6685
  updateHiddenInput();
6686
+ syncBlocksState(container);
6245
6687
  }, 'block');
6246
6688
  } else {
6247
6689
  item.remove();
6248
6690
  updateHiddenInput();
6691
+ syncBlocksState(container);
6249
6692
  }
6250
6693
  return;
6251
6694
  }
@@ -6255,6 +6698,7 @@ function getBlocksFieldScript() {
6255
6698
  if (previous) {
6256
6699
  list.insertBefore(item, previous);
6257
6700
  updateHiddenInput();
6701
+ syncBlocksState(container);
6258
6702
  }
6259
6703
  return;
6260
6704
  }
@@ -6264,6 +6708,7 @@ function getBlocksFieldScript() {
6264
6708
  if (next) {
6265
6709
  list.insertBefore(next, item);
6266
6710
  updateHiddenInput();
6711
+ syncBlocksState(container);
6267
6712
  }
6268
6713
  }
6269
6714
  });
@@ -6285,6 +6730,12 @@ function getBlocksFieldScript() {
6285
6730
  });
6286
6731
 
6287
6732
  updateHiddenInput();
6733
+ const savedBlocksState = readBlocksState(container);
6734
+ if (savedBlocksState) {
6735
+ applyBlocksState(container, savedBlocksState);
6736
+ } else {
6737
+ syncBlocksState(container);
6738
+ }
6288
6739
  });
6289
6740
  }
6290
6741
 
@@ -6321,6 +6772,7 @@ init_admin_layout_catalyst_template();
6321
6772
  function renderContentFormPage(data) {
6322
6773
  const isEdit = data.isEdit || !!data.id;
6323
6774
  const title = isEdit ? `Edit: ${data.title || "Content"}` : `New ${data.collection.display_name}`;
6775
+ const hasValidationErrors = Boolean(data.validationErrors && Object.keys(data.validationErrors).length > 0);
6324
6776
  const backUrl = data.referrerParams ? `/admin/content?${data.referrerParams}` : `/admin/content?collection=${data.collection.id}`;
6325
6777
  const coreFields = data.fields.filter((f) => ["title", "slug", "content"].includes(f.field_name));
6326
6778
  const contentFields = data.fields.filter((f) => !["title", "slug", "content"].includes(f.field_name) && !f.field_name.startsWith("meta_"));
@@ -6409,6 +6861,7 @@ function renderContentFormPage(data) {
6409
6861
  ${isEdit ? `hx-put="/admin/content/${data.id}"` : `hx-post="/admin/content"`}
6410
6862
  hx-target="#form-messages"
6411
6863
  hx-encoding="multipart/form-data"
6864
+ data-has-validation-errors="${hasValidationErrors ? "true" : "false"}"
6412
6865
  class="space-y-6"
6413
6866
  >
6414
6867
  <input type="hidden" name="collection_id" value="${data.collection.id}">
@@ -6689,39 +7142,456 @@ function renderContentFormPage(data) {
6689
7142
 
6690
7143
  <!-- Dynamic Field Scripts -->
6691
7144
  <script>
7145
+ const contentFormCollectionId = ${JSON.stringify(data.collection.id)};
7146
+
7147
+ function getFieldGroupScope() {
7148
+ const url = new URL(window.location.href);
7149
+ const urlCollectionId = url.searchParams.get('collection');
7150
+ const effectiveCollectionId = urlCollectionId || contentFormCollectionId || '';
7151
+ return window.location.pathname + ':' + effectiveCollectionId;
7152
+ }
7153
+
7154
+ function getItemPosition(itemSelector, item) {
7155
+ if (!(item instanceof Element)) return -1;
7156
+ const parent = item.parentElement;
7157
+ if (!parent) return -1;
7158
+ return Array.from(parent.querySelectorAll(':scope > ' + itemSelector)).indexOf(item);
7159
+ }
7160
+
7161
+ function stripIndexedFieldPrefix(fullFieldName, prefix) {
7162
+ if (!fullFieldName || !prefix || !fullFieldName.startsWith(prefix)) {
7163
+ return fullFieldName;
7164
+ }
7165
+
7166
+ const remainder = fullFieldName.slice(prefix.length);
7167
+ const indexMatch = remainder.match(/^(\\d+)(-|__)(.*)$/);
7168
+ if (!indexMatch) {
7169
+ return fullFieldName;
7170
+ }
7171
+
7172
+ return indexMatch[3];
7173
+ }
7174
+
7175
+ function getFieldGroupStorageKey(groupOrId) {
7176
+ const defaultGroupId = typeof groupOrId === 'string' ? groupOrId : (groupOrId?.getAttribute('data-group-id') || 'unknown');
7177
+ const group = typeof groupOrId === 'string'
7178
+ ? document.querySelector('.field-group[data-group-id="' + defaultGroupId + '"]')
7179
+ : groupOrId;
7180
+
7181
+ const scopePrefix = 'sonic:ui:objects:' + getFieldGroupScope() + ':';
7182
+ if (!(group instanceof Element)) {
7183
+ return scopePrefix + defaultGroupId;
7184
+ }
7185
+
7186
+ const fullFieldName = group.getAttribute('data-field-name') || '';
7187
+
7188
+ const blocksField = group.closest('.blocks-field');
7189
+ const blockItem = group.closest('.blocks-item');
7190
+ if (blocksField instanceof Element && blockItem instanceof Element) {
7191
+ const blocksFieldName = blocksField.getAttribute('data-field-name') || 'unknown';
7192
+ const blockPosition = getItemPosition('.blocks-item', blockItem);
7193
+ const relativePath = stripIndexedFieldPrefix(fullFieldName, 'block-' + blocksFieldName + '-') || defaultGroupId;
7194
+ return scopePrefix + 'blocks:' + blocksFieldName + ':' + blockPosition + ':' + relativePath;
7195
+ }
7196
+
7197
+ const arrayField = group.closest('[data-structured-array][data-field-name]');
7198
+ const arrayItem = group.closest('.structured-array-item');
7199
+ if (arrayField instanceof Element && arrayItem instanceof Element) {
7200
+ const arrayFieldName = arrayField.getAttribute('data-field-name') || 'unknown';
7201
+ const itemPosition = getItemPosition('.structured-array-item', arrayItem);
7202
+ const relativePath = stripIndexedFieldPrefix(fullFieldName, 'array-' + arrayFieldName + '-') || defaultGroupId;
7203
+ return scopePrefix + 'repeaters:' + arrayFieldName + ':' + itemPosition + ':' + relativePath;
7204
+ }
7205
+
7206
+ return scopePrefix + defaultGroupId;
7207
+ }
7208
+
7209
+ function loadFieldGroupState(group) {
7210
+ try {
7211
+ const value = sessionStorage.getItem(getFieldGroupStorageKey(group));
7212
+ if (value === '1') return true;
7213
+ if (value === '0') return false;
7214
+ } catch {}
7215
+ return null;
7216
+ }
7217
+
7218
+ function saveFieldGroupState(group, isCollapsed) {
7219
+ try {
7220
+ sessionStorage.setItem(getFieldGroupStorageKey(group), isCollapsed ? '1' : '0');
7221
+ } catch {}
7222
+ }
7223
+
7224
+ function resolveFieldGroupElements(groupOrId) {
7225
+ let group = null;
7226
+
7227
+ if (groupOrId instanceof Element) {
7228
+ group = groupOrId.classList.contains('field-group')
7229
+ ? groupOrId
7230
+ : groupOrId.closest('.field-group[data-group-id]');
7231
+ } else if (typeof groupOrId === 'string' && groupOrId) {
7232
+ group = document.querySelector('.field-group[data-group-id="' + groupOrId + '"]');
7233
+ }
7234
+
7235
+ let content = null;
7236
+ let icon = null;
7237
+
7238
+ if (group instanceof Element) {
7239
+ content = group.querySelector(':scope > .field-group-content');
7240
+ icon = group.querySelector(':scope > .field-group-header svg[id$="-icon"]');
7241
+ }
7242
+
7243
+ // Legacy fallback for any existing calls still passing string IDs.
7244
+ if (!(content instanceof HTMLElement) && typeof groupOrId === 'string') {
7245
+ content = document.getElementById(groupOrId + '-content');
7246
+ }
7247
+ if (!(icon instanceof Element) && typeof groupOrId === 'string') {
7248
+ icon = document.getElementById(groupOrId + '-icon');
7249
+ }
7250
+
7251
+ if (!(group instanceof Element) && content instanceof Element) {
7252
+ group = content.closest('.field-group[data-group-id]');
7253
+ }
7254
+
7255
+ return { group, content, icon };
7256
+ }
7257
+
7258
+ function applyFieldGroupState(groupOrId, isCollapsed) {
7259
+ const { content, icon } = resolveFieldGroupElements(groupOrId);
7260
+ if (!(content instanceof HTMLElement) || !(icon instanceof Element)) return;
7261
+ content.classList.toggle('hidden', isCollapsed);
7262
+ icon.classList.toggle('-rotate-90', isCollapsed);
7263
+ }
7264
+
7265
+ function restoreFieldGroupStates() {
7266
+ document.querySelectorAll('.field-group[data-group-id]').forEach((group) => {
7267
+ const savedState = loadFieldGroupState(group);
7268
+ if (savedState === null) return;
7269
+ applyFieldGroupState(group, savedState);
7270
+ });
7271
+ }
7272
+
7273
+ function persistAllFieldGroupStates() {
7274
+ document.querySelectorAll('.field-group[data-group-id]').forEach((group) => {
7275
+ const { content } = resolveFieldGroupElements(group);
7276
+ if (!(content instanceof HTMLElement)) return;
7277
+ saveFieldGroupState(group, content.classList.contains('hidden'));
7278
+ });
7279
+ }
7280
+
7281
+ function setValidationHeaderIndicator(container) {
7282
+ if (!(container instanceof Element)) return;
7283
+ let header = null;
7284
+ let markerTarget = null;
7285
+
7286
+ if (container.classList.contains('field-group')) {
7287
+ header = container.querySelector(':scope > .field-group-header');
7288
+ markerTarget = container.querySelector(':scope > .field-group-header h3');
7289
+ } else if (container.classList.contains('structured-array-item')) {
7290
+ header = container.querySelector('[data-action="toggle-item"]');
7291
+ markerTarget = header;
7292
+ } else if (container.classList.contains('blocks-item')) {
7293
+ header = container.querySelector('[data-action="toggle-block"]');
7294
+ markerTarget = header;
7295
+ }
7296
+
7297
+ if (!(header instanceof HTMLElement)) return;
7298
+ if (!(markerTarget instanceof HTMLElement)) {
7299
+ markerTarget = header;
7300
+ }
7301
+
7302
+ header.dataset.validationHeaderError = 'true';
7303
+ header.classList.add('text-pink-700', 'dark:text-pink-300');
7304
+
7305
+ if (!markerTarget.querySelector('[data-validation-indicator]')) {
7306
+ const marker = document.createElement('span');
7307
+ marker.setAttribute('data-validation-indicator', 'true');
7308
+ marker.className = 'ml-2 inline-block h-2 w-2 rounded-full bg-pink-500 align-middle';
7309
+ marker.setAttribute('aria-hidden', 'true');
7310
+ markerTarget.appendChild(marker);
7311
+ }
7312
+ }
7313
+
7314
+ function clearValidationIndicators() {
7315
+ document.querySelectorAll('[data-validation-header-error="true"]').forEach((el) => {
7316
+ if (!(el instanceof HTMLElement)) return;
7317
+ delete el.dataset.validationHeaderError;
7318
+ el.classList.remove('text-pink-700', 'dark:text-pink-300');
7319
+ });
7320
+
7321
+ document.querySelectorAll('[data-validation-indicator]').forEach((el) => el.remove());
7322
+ }
7323
+
7324
+ function expandContainerForValidation(container) {
7325
+ if (!(container instanceof Element)) return;
7326
+
7327
+ if (container.classList.contains('field-group')) {
7328
+ applyFieldGroupState(container, false);
7329
+ return;
7330
+ }
7331
+
7332
+ if (container.classList.contains('structured-array-item')) {
7333
+ const content = container.querySelector('[data-array-item-fields]');
7334
+ const icon = container.querySelector('[data-item-toggle-icon]');
7335
+ if (content instanceof HTMLElement) {
7336
+ content.classList.remove('hidden');
7337
+ }
7338
+ if (icon instanceof Element) {
7339
+ icon.classList.remove('-rotate-90');
7340
+ }
7341
+ return;
7342
+ }
7343
+
7344
+ if (container.classList.contains('blocks-item')) {
7345
+ const content = container.querySelector('[data-block-content]');
7346
+ const icon = container.querySelector('[data-block-toggle-icon]');
7347
+ if (content instanceof HTMLElement) {
7348
+ content.classList.remove('hidden');
7349
+ }
7350
+ if (icon instanceof Element) {
7351
+ icon.classList.remove('-rotate-90');
7352
+ }
7353
+ }
7354
+ }
7355
+
7356
+ function walkErrorContainers(node, expand) {
7357
+ if (!(node instanceof Element)) return;
7358
+ const visited = new Set();
7359
+ let cursor = node;
7360
+ while (cursor) {
7361
+ const candidates = [
7362
+ cursor.closest('.structured-array-item'),
7363
+ cursor.closest('.blocks-item'),
7364
+ cursor.closest('.field-group[data-group-id]')
7365
+ ].filter((c) => c instanceof Element && !visited.has(c));
7366
+
7367
+ if (candidates.length === 0) break;
7368
+
7369
+ // Pick nearest ancestor container to preserve "first-error path only".
7370
+ let nearest = candidates[0];
7371
+ let bestDistance = Number.MAX_SAFE_INTEGER;
7372
+ for (const candidate of candidates) {
7373
+ let distance = 0;
7374
+ let walker = cursor;
7375
+ while (walker && walker !== candidate) {
7376
+ walker = walker.parentElement;
7377
+ distance += 1;
7378
+ }
7379
+ if (distance < bestDistance) {
7380
+ bestDistance = distance;
7381
+ nearest = candidate;
7382
+ }
7383
+ }
7384
+
7385
+ visited.add(nearest);
7386
+ setValidationHeaderIndicator(nearest);
7387
+ if (expand) {
7388
+ expandContainerForValidation(nearest);
7389
+ }
7390
+ cursor = nearest.parentElement;
7391
+ }
7392
+ }
7393
+
7394
+ function getFocusableTargetFromErrorGroup(group) {
7395
+ if (!(group instanceof Element)) return null;
7396
+ return (
7397
+ group.querySelector('input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"]') ||
7398
+ group.querySelector('button:not([disabled])')
7399
+ );
7400
+ }
7401
+
7402
+ function revealServerValidationErrors() {
7403
+ clearValidationIndicators();
7404
+
7405
+ const errorGroups = Array.from(document.querySelectorAll('.form-group[data-has-errors="true"]'));
7406
+ if (errorGroups.length === 0) return;
7407
+
7408
+ // Add indicators for all errored sections, expand only first-error path.
7409
+ errorGroups.forEach((group, index) => {
7410
+ walkErrorContainers(group, index === 0);
7411
+ });
7412
+
7413
+ const firstTarget = getFocusableTargetFromErrorGroup(errorGroups[0]);
7414
+ if (firstTarget instanceof HTMLElement) {
7415
+ firstTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
7416
+ firstTarget.focus({ preventScroll: true });
7417
+ }
7418
+ }
7419
+
7420
+ function revealNativeValidationErrors(form) {
7421
+ if (!(form instanceof HTMLFormElement)) return;
7422
+ clearValidationIndicators();
7423
+
7424
+ const invalidControls = Array.from(form.querySelectorAll(':invalid'));
7425
+ if (invalidControls.length === 0) return;
7426
+
7427
+ invalidControls.forEach((control, index) => {
7428
+ walkErrorContainers(control, index === 0);
7429
+ });
7430
+
7431
+ const first = invalidControls[0];
7432
+ if (first instanceof HTMLElement) {
7433
+ first.scrollIntoView({ behavior: 'smooth', block: 'center' });
7434
+ first.focus({ preventScroll: true });
7435
+ }
7436
+ }
7437
+
6692
7438
  // Field group toggle
6693
- function toggleFieldGroup(groupId) {
6694
- const content = document.getElementById(groupId + '-content');
6695
- const icon = document.getElementById(groupId + '-icon');
6696
-
6697
- if (content.classList.contains('hidden')) {
6698
- content.classList.remove('hidden');
6699
- icon.classList.remove('rotate-[-90deg]');
6700
- } else {
6701
- content.classList.add('hidden');
6702
- icon.classList.add('rotate-[-90deg]');
7439
+ function toggleFieldGroup(groupOrTrigger) {
7440
+ const { group, content } = resolveFieldGroupElements(groupOrTrigger);
7441
+ if (!(group instanceof Element)) return;
7442
+ if (!(content instanceof HTMLElement)) return;
7443
+
7444
+ const isCollapsed = !content.classList.contains('hidden');
7445
+ applyFieldGroupState(group, isCollapsed);
7446
+ saveFieldGroupState(group, isCollapsed);
7447
+ }
7448
+
7449
+ if (document.readyState === 'loading') {
7450
+ document.addEventListener('DOMContentLoaded', () => {
7451
+ restoreFieldGroupStates();
7452
+ const form = document.getElementById('content-form');
7453
+ if (form?.getAttribute('data-has-validation-errors') === 'true') {
7454
+ revealServerValidationErrors();
7455
+ }
7456
+ });
7457
+ } else {
7458
+ restoreFieldGroupStates();
7459
+ const form = document.getElementById('content-form');
7460
+ if (form?.getAttribute('data-has-validation-errors') === 'true') {
7461
+ revealServerValidationErrors();
6703
7462
  }
6704
7463
  }
6705
7464
 
7465
+ document.addEventListener('htmx:afterSwap', function() {
7466
+ setTimeout(() => {
7467
+ restoreFieldGroupStates();
7468
+ const form = document.getElementById('content-form');
7469
+ if (form?.getAttribute('data-has-validation-errors') === 'true') {
7470
+ revealServerValidationErrors();
7471
+ }
7472
+ }, 50);
7473
+ });
7474
+
7475
+ const contentFormEl = document.getElementById('content-form');
7476
+ if (contentFormEl instanceof HTMLFormElement) {
7477
+ contentFormEl.addEventListener('submit', () => {
7478
+ persistAllFieldGroupStates();
7479
+ }, true);
7480
+ }
7481
+
7482
+ window.addEventListener('beforeunload', () => {
7483
+ persistAllFieldGroupStates();
7484
+ });
7485
+
7486
+ document.addEventListener('visibilitychange', () => {
7487
+ if (document.visibilityState === 'hidden') {
7488
+ persistAllFieldGroupStates();
7489
+ }
7490
+ });
7491
+
7492
+ let pendingNativeValidationReveal = false;
7493
+ document.addEventListener('invalid', function(event) {
7494
+ const target = event.target;
7495
+ if (!(target instanceof Element)) return;
7496
+ const form = target.closest('form');
7497
+ if (!(form instanceof HTMLFormElement)) return;
7498
+
7499
+ if (pendingNativeValidationReveal) return;
7500
+ pendingNativeValidationReveal = true;
7501
+
7502
+ // Expand only first invalid path synchronously so the browser can focus it
7503
+ // and avoid "invalid form control is not focusable" errors.
7504
+ walkErrorContainers(target, true);
7505
+
7506
+ setTimeout(() => {
7507
+ pendingNativeValidationReveal = false;
7508
+ revealNativeValidationErrors(form);
7509
+ }, 0);
7510
+ }, true);
7511
+
6706
7512
  // Media field functions
6707
- let currentMediaFieldId = null;
7513
+ function notifyFieldChange(input) {
7514
+ if (!input) return;
7515
+ input.dispatchEvent(new Event('input', { bubbles: true }));
7516
+ input.dispatchEvent(new Event('change', { bubbles: true }));
7517
+ }
7518
+
7519
+ function getActiveMediaModal() {
7520
+ const modal = document.getElementById('media-selector-modal');
7521
+ return modal instanceof HTMLElement ? modal : null;
7522
+ }
7523
+
7524
+ function getMediaFieldElements(fieldId) {
7525
+ if (!fieldId) {
7526
+ return {
7527
+ fieldId: '',
7528
+ hiddenInput: null,
7529
+ preview: null,
7530
+ mediaField: null,
7531
+ actionsDiv: null,
7532
+ };
7533
+ }
7534
+
7535
+ const hiddenInput = document.getElementById(fieldId);
7536
+ const preview = document.getElementById(fieldId + '-preview');
7537
+ const mediaField = hiddenInput?.closest('.media-field-container') || null;
7538
+ const actionsDiv = mediaField?.querySelector('.media-actions') || null;
7539
+
7540
+ return {
7541
+ fieldId,
7542
+ hiddenInput,
7543
+ preview,
7544
+ mediaField,
7545
+ actionsDiv,
7546
+ };
7547
+ }
7548
+
7549
+ function getActiveMediaTarget() {
7550
+ const modal = getActiveMediaModal();
7551
+ const fieldId = modal?.dataset.targetFieldId || '';
7552
+ return {
7553
+ modal,
7554
+ originalValue: modal?.dataset.originalValue || '',
7555
+ ...getMediaFieldElements(fieldId),
7556
+ };
7557
+ }
7558
+
7559
+ function ensureSingleMediaRemoveButton(fieldId, actionsDiv) {
7560
+ if (!(actionsDiv instanceof HTMLElement)) return;
7561
+ const existingRemoveButton = actionsDiv.querySelector('[data-media-remove="true"]');
7562
+ if (existingRemoveButton) return;
7563
+
7564
+ const removeBtn = document.createElement('button');
7565
+ removeBtn.type = 'button';
7566
+ removeBtn.setAttribute('data-media-remove', 'true');
7567
+ removeBtn.onclick = () => clearMediaField(fieldId);
7568
+ removeBtn.className = 'inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all';
7569
+ removeBtn.textContent = 'Remove';
7570
+ actionsDiv.appendChild(removeBtn);
7571
+ }
6708
7572
 
6709
7573
  function openMediaSelector(fieldId) {
6710
- currentMediaFieldId = fieldId;
7574
+ const existingModal = getActiveMediaModal();
7575
+ if (existingModal) {
7576
+ existingModal.remove();
7577
+ }
7578
+
6711
7579
  // Store the original value in case user cancels
6712
- const originalValue = document.getElementById(fieldId)?.value || '';
7580
+ const originalValue = getMediaFieldElements(fieldId).hiddenInput?.value || '';
6713
7581
 
6714
7582
  // Open media library modal
6715
7583
  const modal = document.createElement('div');
6716
7584
  modal.className = 'fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50';
6717
7585
  modal.id = 'media-selector-modal';
7586
+ modal.dataset.targetFieldId = fieldId;
7587
+ modal.dataset.originalValue = originalValue;
6718
7588
  modal.innerHTML = \`
6719
7589
  <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-4xl max-h-[90vh] overflow-y-auto">
6720
7590
  <h3 class="text-lg font-semibold text-zinc-950 dark:text-white mb-4">Select Media</h3>
6721
7591
  <div id="media-grid-container" hx-get="/admin/media/selector" hx-trigger="load"></div>
6722
7592
  <div class="mt-4 flex justify-end space-x-2">
6723
7593
  <button
6724
- onclick="cancelMediaSelection('\${fieldId}', '\${originalValue}')"
7594
+ onclick="cancelMediaSelection()"
6725
7595
  class="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 transition-colors">
6726
7596
  Cancel
6727
7597
  </button>
@@ -6741,23 +7611,23 @@ function renderContentFormPage(data) {
6741
7611
  }
6742
7612
 
6743
7613
  function closeMediaSelector() {
6744
- const modal = document.getElementById('media-selector-modal');
7614
+ const modal = getActiveMediaModal();
6745
7615
  if (modal) {
6746
7616
  modal.remove();
6747
7617
  }
6748
- currentMediaFieldId = null;
6749
7618
  }
6750
7619
 
6751
- function cancelMediaSelection(fieldId, originalValue) {
7620
+ function cancelMediaSelection() {
7621
+ const { hiddenInput, preview, originalValue } = getActiveMediaTarget();
7622
+
6752
7623
  // Restore original value
6753
- const hiddenInput = document.getElementById(fieldId);
6754
7624
  if (hiddenInput) {
6755
7625
  hiddenInput.value = originalValue;
7626
+ notifyFieldChange(hiddenInput);
6756
7627
  }
6757
7628
 
6758
7629
  // If original value was empty, hide the preview and show select button
6759
7630
  if (!originalValue) {
6760
- const preview = document.getElementById(fieldId + '-preview');
6761
7631
  if (preview) {
6762
7632
  preview.classList.add('hidden');
6763
7633
  }
@@ -6768,11 +7638,11 @@ function renderContentFormPage(data) {
6768
7638
  }
6769
7639
 
6770
7640
  function clearMediaField(fieldId) {
6771
- const hiddenInput = document.getElementById(fieldId);
6772
- const preview = document.getElementById(fieldId + '-preview');
7641
+ const { hiddenInput, preview, actionsDiv } = getMediaFieldElements(fieldId);
6773
7642
 
6774
7643
  if (hiddenInput) {
6775
7644
  hiddenInput.value = '';
7645
+ notifyFieldChange(hiddenInput);
6776
7646
  }
6777
7647
 
6778
7648
  if (preview) {
@@ -6782,25 +7652,34 @@ function renderContentFormPage(data) {
6782
7652
  }
6783
7653
  preview.classList.add('hidden');
6784
7654
  }
7655
+
7656
+ const removeButton = actionsDiv?.querySelector('[data-media-remove="true"]');
7657
+ if (removeButton) {
7658
+ removeButton.remove();
7659
+ }
6785
7660
  }
6786
7661
 
6787
7662
  // Global function to remove a single media from multiple selection
6788
7663
  window.removeMediaFromMultiple = function(fieldId, urlToRemove) {
6789
- const hiddenInput = document.getElementById(fieldId);
7664
+ const { hiddenInput, preview } = getMediaFieldElements(fieldId);
6790
7665
  if (!hiddenInput) return;
6791
7666
 
6792
7667
  const values = hiddenInput.value.split(',').filter(url => url !== urlToRemove);
6793
7668
  hiddenInput.value = values.join(',');
7669
+ notifyFieldChange(hiddenInput);
6794
7670
 
6795
7671
  // Remove preview item
6796
- const previewItem = document.querySelector(\`[data-url="\${urlToRemove}"]\`);
7672
+ const previewItem =
7673
+ preview &&
7674
+ Array.from(preview.querySelectorAll('[data-url]')).find(
7675
+ (item) => item.getAttribute('data-url') === urlToRemove,
7676
+ );
6797
7677
  if (previewItem) {
6798
7678
  previewItem.remove();
6799
7679
  }
6800
7680
 
6801
7681
  // Hide preview grid if empty
6802
7682
  if (values.length === 0) {
6803
- const preview = document.getElementById(fieldId + '-preview');
6804
7683
  if (preview) {
6805
7684
  preview.classList.add('hidden');
6806
7685
  }
@@ -6809,39 +7688,24 @@ function renderContentFormPage(data) {
6809
7688
 
6810
7689
  // Global function called by media selector buttons
6811
7690
  window.selectMediaFile = function(mediaId, mediaUrl, filename) {
6812
- if (!currentMediaFieldId) {
7691
+ const { fieldId, hiddenInput, preview, actionsDiv } = getActiveMediaTarget();
7692
+ if (!fieldId || !hiddenInput) {
6813
7693
  console.error('No field ID set for media selection');
6814
7694
  return;
6815
7695
  }
6816
7696
 
6817
- const fieldId = currentMediaFieldId;
6818
-
6819
7697
  // Set the hidden input value to the media URL (not ID)
6820
- const hiddenInput = document.getElementById(fieldId);
6821
- if (hiddenInput) {
6822
- hiddenInput.value = mediaUrl;
6823
- }
7698
+ hiddenInput.value = mediaUrl;
7699
+ notifyFieldChange(hiddenInput);
6824
7700
 
6825
7701
  // Update the preview
6826
- const preview = document.getElementById(fieldId + '-preview');
6827
7702
  if (preview) {
6828
7703
  preview.innerHTML = \`<img src="\${mediaUrl}" alt="\${filename}" class="w-32 h-32 object-cover rounded-lg border border-white/20">\`;
6829
7704
  preview.classList.remove('hidden');
6830
7705
  }
6831
7706
 
6832
7707
  // Show the remove button by finding the media actions container and updating it
6833
- const mediaField = hiddenInput?.closest('.media-field-container');
6834
- if (mediaField) {
6835
- const actionsDiv = mediaField.querySelector('.media-actions');
6836
- if (actionsDiv && !actionsDiv.querySelector('button:has-text("Remove")')) {
6837
- const removeBtn = document.createElement('button');
6838
- removeBtn.type = 'button';
6839
- removeBtn.onclick = () => clearMediaField(fieldId);
6840
- removeBtn.className = 'inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all';
6841
- removeBtn.textContent = 'Remove';
6842
- actionsDiv.appendChild(removeBtn);
6843
- }
6844
- }
7708
+ ensureSingleMediaRemoveButton(fieldId, actionsDiv);
6845
7709
 
6846
7710
  // DON'T close the modal - let user click OK button
6847
7711
  // Visual feedback: highlight the selected item
@@ -6855,7 +7719,9 @@ function renderContentFormPage(data) {
6855
7719
  };
6856
7720
 
6857
7721
  function setMediaField(fieldId, mediaUrl) {
6858
- document.getElementById(fieldId).value = mediaUrl;
7722
+ const hiddenInput = document.getElementById(fieldId);
7723
+ hiddenInput.value = mediaUrl;
7724
+ notifyFieldChange(hiddenInput);
6859
7725
  const preview = document.getElementById(fieldId + '-preview');
6860
7726
  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">\`;
6861
7727
  preview.classList.remove('hidden');
@@ -8493,7 +9359,7 @@ adminContentRoutes.get("/", async (c) => {
8493
9359
  const status = url.searchParams.get("status") || "all";
8494
9360
  const search = url.searchParams.get("search") || "";
8495
9361
  const offset = (page - 1) * limit;
8496
- const collectionsStmt = db.prepare("SELECT id, name, display_name FROM collections WHERE is_active = 1 ORDER BY display_name");
9362
+ const collectionsStmt = db.prepare("SELECT id, name, display_name FROM collections WHERE is_active = 1 AND (source_type IS NULL OR source_type = 'user') ORDER BY display_name");
8497
9363
  const { results: collectionsResults } = await collectionsStmt.all();
8498
9364
  const models = (collectionsResults || []).map((row) => ({
8499
9365
  name: row.name,
@@ -8501,6 +9367,7 @@ adminContentRoutes.get("/", async (c) => {
8501
9367
  }));
8502
9368
  const conditions = [];
8503
9369
  const params = [];
9370
+ conditions.push("(col.source_type IS NULL OR col.source_type = 'user')");
8504
9371
  if (status !== "deleted") {
8505
9372
  conditions.push("c.status != 'deleted'");
8506
9373
  }
@@ -8629,7 +9496,7 @@ adminContentRoutes.get("/new", async (c) => {
8629
9496
  const collectionId = url.searchParams.get("collection");
8630
9497
  if (!collectionId) {
8631
9498
  const db2 = c.env.DB;
8632
- const collectionsStmt = db2.prepare("SELECT id, name, display_name, description FROM collections WHERE is_active = 1 ORDER BY display_name");
9499
+ const collectionsStmt = db2.prepare("SELECT id, name, display_name, description FROM collections WHERE is_active = 1 AND (source_type IS NULL OR source_type = 'user') ORDER BY display_name");
8633
9500
  const { results } = await collectionsStmt.all();
8634
9501
  const collections = (results || []).map((row) => ({
8635
9502
  id: row.id,
@@ -11450,6 +12317,13 @@ function renderUsersListPage(data) {
11450
12317
  // src/routes/admin-users.ts
11451
12318
  var userRoutes = new Hono();
11452
12319
  userRoutes.use("*", requireAuth());
12320
+ userRoutes.use("/users/*", requireRole(["admin"]));
12321
+ userRoutes.use("/users", requireRole(["admin"]));
12322
+ userRoutes.use("/invite-user", requireRole(["admin"]));
12323
+ userRoutes.use("/resend-invitation/*", requireRole(["admin"]));
12324
+ userRoutes.use("/cancel-invitation/*", requireRole(["admin"]));
12325
+ userRoutes.use("/activity-logs", requireRole(["admin"]));
12326
+ userRoutes.use("/activity-logs/*", requireRole(["admin"]));
11453
12327
  userRoutes.get("/", (c) => {
11454
12328
  return c.redirect("/admin/dashboard");
11455
12329
  });
@@ -11937,7 +12811,9 @@ userRoutes.post("/users/new", async (c) => {
11937
12811
  const email = formData.get("email")?.toString()?.trim().toLowerCase() || "";
11938
12812
  const phone = sanitizeInput(formData.get("phone")?.toString()) || null;
11939
12813
  const bio = sanitizeInput(formData.get("bio")?.toString()) || null;
11940
- const role = formData.get("role")?.toString() || "viewer";
12814
+ const roleInput = formData.get("role")?.toString() || "viewer";
12815
+ const validRoles = ["admin", "editor", "author", "viewer"];
12816
+ const role = validRoles.includes(roleInput) ? roleInput : "viewer";
11941
12817
  const password = formData.get("password")?.toString() || "";
11942
12818
  const confirmPassword = formData.get("confirm_password")?.toString() || "";
11943
12819
  const isActive = formData.get("is_active") === "1";
@@ -12157,7 +13033,9 @@ userRoutes.put("/users/:id", async (c) => {
12157
13033
  const username = sanitizeInput(formData.get("username")?.toString());
12158
13034
  const email = formData.get("email")?.toString()?.trim().toLowerCase() || "";
12159
13035
  const phone = sanitizeInput(formData.get("phone")?.toString()) || null;
12160
- const role = formData.get("role")?.toString() || "viewer";
13036
+ const roleInput = formData.get("role")?.toString() || "viewer";
13037
+ const validRoles = ["admin", "editor", "author", "viewer"];
13038
+ const role = validRoles.includes(roleInput) ? roleInput : "viewer";
12161
13039
  const isActive = formData.get("is_active") === "1";
12162
13040
  const emailVerified = formData.get("email_verified") === "1";
12163
13041
  const profileDisplayName = sanitizeInput(formData.get("profile_display_name")?.toString()) || null;
@@ -17482,6 +18360,18 @@ adminPluginRoutes.post("/:id/settings", async (c) => {
17482
18360
  const settings = await c.req.json();
17483
18361
  const pluginService = new PluginService(db);
17484
18362
  await pluginService.updatePluginSettings(pluginId, settings);
18363
+ if (pluginId === "core-auth") {
18364
+ try {
18365
+ const cacheKv = c.env.CACHE_KV;
18366
+ if (cacheKv) {
18367
+ await cacheKv.delete("auth:settings");
18368
+ await cacheKv.delete("auth:registration-enabled");
18369
+ console.log("[AuthSettings] Cache cleared after updating authentication settings");
18370
+ }
18371
+ } catch (cacheError) {
18372
+ console.error("[AuthSettings] Failed to clear cache:", cacheError);
18373
+ }
18374
+ }
17485
18375
  return c.json({ success: true });
17486
18376
  } catch (error) {
17487
18377
  console.error("Error updating plugin settings:", error);
@@ -20637,7 +21527,7 @@ router.get("/stats", async (c) => {
20637
21527
  const db = c.env.DB;
20638
21528
  let collectionsCount = 0;
20639
21529
  try {
20640
- const collectionsStmt = db.prepare("SELECT COUNT(*) as count FROM collections WHERE is_active = 1");
21530
+ const collectionsStmt = db.prepare("SELECT COUNT(*) as count FROM collections WHERE is_active = 1 AND (source_type IS NULL OR source_type = 'user')");
20641
21531
  const collectionsResult = await collectionsStmt.first();
20642
21532
  collectionsCount = collectionsResult?.count || 0;
20643
21533
  } catch (error) {
@@ -20645,7 +21535,7 @@ router.get("/stats", async (c) => {
20645
21535
  }
20646
21536
  let contentCount = 0;
20647
21537
  try {
20648
- const contentStmt = db.prepare("SELECT COUNT(*) as count FROM content");
21538
+ const contentStmt = db.prepare("SELECT COUNT(*) as count FROM content c JOIN collections col ON c.collection_id = col.id WHERE (col.source_type IS NULL OR col.source_type = 'user')");
20649
21539
  const contentResult = await contentStmt.first();
20650
21540
  contentCount = contentResult?.count || 0;
20651
21541
  } catch (error) {
@@ -22426,6 +23316,9 @@ function renderCollectionFormPage(data) {
22426
23316
  // src/routes/admin-collections.ts
22427
23317
  var adminCollectionsRoutes = new Hono();
22428
23318
  adminCollectionsRoutes.use("*", requireAuth());
23319
+ adminCollectionsRoutes.post("*", requireRole(["admin"]));
23320
+ adminCollectionsRoutes.put("*", requireRole(["admin"]));
23321
+ adminCollectionsRoutes.delete("*", requireRole(["admin"]));
22429
23322
  adminCollectionsRoutes.get("/", async (c) => {
22430
23323
  try {
22431
23324
  const user = c.get("user");
@@ -22439,6 +23332,7 @@ adminCollectionsRoutes.get("/", async (c) => {
22439
23332
  SELECT id, name, display_name, description, created_at, managed, schema
22440
23333
  FROM collections
22441
23334
  WHERE is_active = 1
23335
+ AND (source_type IS NULL OR source_type = 'user')
22442
23336
  AND (name LIKE ? OR display_name LIKE ? OR description LIKE ?)
22443
23337
  ORDER BY created_at DESC
22444
23338
  `);
@@ -22446,7 +23340,7 @@ adminCollectionsRoutes.get("/", async (c) => {
22446
23340
  const queryResults = await stmt.bind(searchParam, searchParam, searchParam).all();
22447
23341
  results = queryResults.results;
22448
23342
  } else {
22449
- stmt = db.prepare("SELECT id, name, display_name, description, created_at, managed, schema FROM collections WHERE is_active = 1 ORDER BY created_at DESC");
23343
+ stmt = db.prepare("SELECT id, name, display_name, description, created_at, managed, schema FROM collections WHERE is_active = 1 AND (source_type IS NULL OR source_type = 'user') ORDER BY created_at DESC");
22450
23344
  const queryResults = await stmt.all();
22451
23345
  results = queryResults.results;
22452
23346
  }
@@ -27518,14 +28412,36 @@ publicFormsRoutes.post("/:identifier/submit", async (c) => {
27518
28412
  now
27519
28413
  ).run();
27520
28414
  await db.prepare(`
27521
- UPDATE forms
28415
+ UPDATE forms
27522
28416
  SET submission_count = submission_count + 1,
27523
28417
  updated_at = ?
27524
28418
  WHERE id = ?
27525
28419
  `).bind(now, form.id).run();
28420
+ let contentId = null;
28421
+ try {
28422
+ contentId = await createContentFromSubmission(
28423
+ db,
28424
+ sanitizedData,
28425
+ { id: form.id, name: form.name, display_name: form.display_name },
28426
+ submissionId,
28427
+ {
28428
+ ipAddress: c.req.header("cf-connecting-ip") || null,
28429
+ userAgent: c.req.header("user-agent") || null,
28430
+ userEmail: sanitizedData?.email || null,
28431
+ userId: null
28432
+ // anonymous submission
28433
+ }
28434
+ );
28435
+ if (!contentId) {
28436
+ console.warn("[FormSubmit] Content creation returned null for submission:", submissionId);
28437
+ }
28438
+ } catch (contentError) {
28439
+ console.error("[FormSubmit] Error creating content from submission:", contentError);
28440
+ }
27526
28441
  return c.json({
27527
28442
  success: true,
27528
28443
  submissionId,
28444
+ contentId,
27529
28445
  message: "Form submitted successfully"
27530
28446
  });
27531
28447
  } catch (error) {
@@ -27936,5 +28852,5 @@ var ROUTES_INFO = {
27936
28852
  };
27937
28853
 
27938
28854
  export { ROUTES_INFO, adminCheckboxRoutes, adminCollectionsRoutes, adminDesignRoutes, adminFormsRoutes, 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, getConfirmationDialogScript2 as getConfirmationDialogScript, public_forms_default, renderConfirmationDialog2 as renderConfirmationDialog, router, router2, test_cleanup_default, userRoutes };
27939
- //# sourceMappingURL=chunk-2JGQKF7B.js.map
27940
- //# sourceMappingURL=chunk-2JGQKF7B.js.map
28855
+ //# sourceMappingURL=chunk-SDAGUFOF.js.map
28856
+ //# sourceMappingURL=chunk-SDAGUFOF.js.map