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