@sonicjs-cms/core 2.8.3 → 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 (87) hide show
  1. package/dist/{app-DnQ26Lho.d.cts → app-Ozl9agJG.d.cts} +1 -1
  2. package/dist/{app-DnQ26Lho.d.ts → app-Ozl9agJG.d.ts} +1 -1
  3. package/dist/{chunk-YFJJU26H.js → chunk-27AOVQTR.js} +10 -2
  4. package/dist/chunk-27AOVQTR.js.map +1 -0
  5. package/dist/{chunk-Y3VMEGY2.js → chunk-4TTMQQC7.js} +4 -4
  6. package/dist/{chunk-Y3VMEGY2.js.map → chunk-4TTMQQC7.js.map} +1 -1
  7. package/dist/{chunk-VNLR35GO.cjs → chunk-64APW3DW.cjs} +339 -2
  8. package/dist/chunk-64APW3DW.cjs.map +1 -0
  9. package/dist/{chunk-GTFMI24U.js → chunk-6O3RJV3C.js} +2 -2
  10. package/dist/{chunk-GTFMI24U.js.map → chunk-6O3RJV3C.js.map} +1 -1
  11. package/dist/{chunk-G44QUVNM.js → chunk-7JMMLHPQ.js} +337 -4
  12. package/dist/chunk-7JMMLHPQ.js.map +1 -0
  13. package/dist/chunk-CJYFSKH7.js +54 -54
  14. package/dist/chunk-CJYFSKH7.js.map +1 -1
  15. package/dist/{chunk-JDIM5AG7.cjs → chunk-EKPLKUZT.cjs} +11 -5
  16. package/dist/chunk-EKPLKUZT.cjs.map +1 -0
  17. package/dist/{chunk-MPT5PA6U.cjs → chunk-IIBRG5S5.cjs} +10 -2
  18. package/dist/chunk-IIBRG5S5.cjs.map +1 -0
  19. package/dist/{chunk-K4Q4SFJJ.cjs → chunk-IT2TC4ZD.cjs} +7 -7
  20. package/dist/{chunk-K4Q4SFJJ.cjs.map → chunk-IT2TC4ZD.cjs.map} +1 -1
  21. package/dist/{chunk-5XAI2XUF.js → chunk-IZWNIUJI.js} +11 -5
  22. package/dist/chunk-IZWNIUJI.js.map +1 -0
  23. package/dist/{chunk-CH5UHZVM.js → chunk-JTNUM7JE.js} +1218 -442
  24. package/dist/chunk-JTNUM7JE.js.map +1 -0
  25. package/dist/chunk-MNFY6DWY.cjs +54 -54
  26. package/dist/chunk-MNFY6DWY.cjs.map +1 -1
  27. package/dist/{chunk-R4WR3VTN.cjs → chunk-RCA6R6VE.cjs} +1329 -553
  28. package/dist/chunk-RCA6R6VE.cjs.map +1 -0
  29. package/dist/{chunk-HXHVU5GM.cjs → chunk-ZMVWMJ3S.cjs} +2 -2
  30. package/dist/{chunk-HXHVU5GM.cjs.map → chunk-ZMVWMJ3S.cjs.map} +1 -1
  31. package/dist/{collection-config-i8EaAF7z.d.cts → collection-config-B4PG-AaF.d.cts} +4 -2
  32. package/dist/{collection-config-i8EaAF7z.d.ts → collection-config-B4PG-AaF.d.ts} +4 -2
  33. package/dist/{filter-bar.template-Daw8ZDoq.d.cts → filter-bar.template-DlVYMk-T.d.cts} +1 -1
  34. package/dist/{filter-bar.template-Daw8ZDoq.d.ts → filter-bar.template-DlVYMk-T.d.ts} +1 -1
  35. package/dist/index.cjs +142 -141
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +8 -8
  38. package/dist/index.d.ts +8 -8
  39. package/dist/index.js +11 -10
  40. package/dist/index.js.map +1 -1
  41. package/dist/middleware.cjs +29 -29
  42. package/dist/middleware.d.cts +1 -1
  43. package/dist/middleware.d.ts +1 -1
  44. package/dist/middleware.js +3 -3
  45. package/dist/migrations-N2C2VPJU.js +4 -0
  46. package/dist/{migrations-KHWFJ2HN.js.map → migrations-N2C2VPJU.js.map} +1 -1
  47. package/dist/migrations-ONIAY6GK.cjs +13 -0
  48. package/dist/{migrations-7X4RPH7O.cjs.map → migrations-ONIAY6GK.cjs.map} +1 -1
  49. package/dist/{plugin-zvZpaiP5.d.cts → plugin-0Xogrln-.d.cts} +1 -1
  50. package/dist/{plugin-zvZpaiP5.d.ts → plugin-0Xogrln-.d.ts} +1 -1
  51. package/dist/{plugin-bootstrap-CJozpgmI.d.cts → plugin-bootstrap-WmpvYM5w.d.ts} +2 -2
  52. package/dist/{plugin-bootstrap-DU5VmuHZ.d.ts → plugin-bootstrap-fpG98Otb.d.cts} +2 -2
  53. package/dist/{plugin-manager-Baa6xXqB.d.ts → plugin-manager-Clf2gXwj.d.ts} +2 -2
  54. package/dist/{plugin-manager-vBal9Zip.d.cts → plugin-manager-GcIeb226.d.cts} +2 -2
  55. package/dist/plugins.d.cts +2 -2
  56. package/dist/plugins.d.ts +2 -2
  57. package/dist/routes.cjs +29 -29
  58. package/dist/routes.d.cts +1 -1
  59. package/dist/routes.d.ts +1 -1
  60. package/dist/routes.js +6 -6
  61. package/dist/services.cjs +44 -28
  62. package/dist/services.d.cts +29 -4
  63. package/dist/services.d.ts +29 -4
  64. package/dist/services.js +3 -3
  65. package/dist/{telemetry-UiD1i9GS.d.cts → telemetry-B9vIV4wh.d.cts} +1 -1
  66. package/dist/{telemetry-UiD1i9GS.d.ts → telemetry-B9vIV4wh.d.ts} +1 -1
  67. package/dist/templates.d.cts +1 -1
  68. package/dist/templates.d.ts +1 -1
  69. package/dist/types.d.cts +3 -3
  70. package/dist/types.d.ts +3 -3
  71. package/dist/utils.cjs +11 -11
  72. package/dist/utils.d.cts +3 -3
  73. package/dist/utils.d.ts +3 -3
  74. package/dist/utils.js +1 -1
  75. package/dist/{version-C_CXrN_T.d.cts → version-ChpccWQ1.d.cts} +1 -1
  76. package/dist/{version-C_CXrN_T.d.ts → version-ChpccWQ1.d.ts} +1 -1
  77. package/package.json +9 -3
  78. package/dist/chunk-5XAI2XUF.js.map +0 -1
  79. package/dist/chunk-CH5UHZVM.js.map +0 -1
  80. package/dist/chunk-G44QUVNM.js.map +0 -1
  81. package/dist/chunk-JDIM5AG7.cjs.map +0 -1
  82. package/dist/chunk-MPT5PA6U.cjs.map +0 -1
  83. package/dist/chunk-R4WR3VTN.cjs.map +0 -1
  84. package/dist/chunk-VNLR35GO.cjs.map +0 -1
  85. package/dist/chunk-YFJJU26H.js.map +0 -1
  86. package/dist/migrations-7X4RPH7O.cjs +0 -13
  87. package/dist/migrations-KHWFJ2HN.js +0 -4
@@ -1,10 +1,10 @@
1
- import { getCacheService, CACHE_CONFIGS, getLogger, SettingsService } from './chunk-G44QUVNM.js';
2
- import { requireAuth, isPluginActive, optionalAuth, requireRole, rateLimit, AuthManager, logActivity, generateCsrfToken } from './chunk-Y3VMEGY2.js';
3
- import { PluginService } from './chunk-YFJJU26H.js';
4
- import { MigrationService } from './chunk-GTFMI24U.js';
1
+ import { getCacheService, CACHE_CONFIGS, getLogger, SettingsService, getAppInstance, buildRouteList, CATEGORY_INFO } from './chunk-7JMMLHPQ.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-5XAI2XUF.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-KHWFJ2HN.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-KHWFJ2HN.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-KHWFJ2HN.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();
@@ -4284,7 +4284,7 @@ function getMDXEditorInitScript(config) {
4284
4284
  const toolbar = config?.toolbar || "full";
4285
4285
  const placeholder = config?.placeholder || "Start writing your content...";
4286
4286
  return `
4287
- // Initialize EasyMDE (Markdown Editor) for all richtext fields
4287
+ // Initialize EasyMDE (Markdown Editor) only for markdown-marked fields
4288
4288
  function initializeMDXEditor() {
4289
4289
  if (typeof EasyMDE === 'undefined') {
4290
4290
  console.warn('EasyMDE not loaded yet, retrying...');
@@ -4293,7 +4293,7 @@ function getMDXEditorInitScript(config) {
4293
4293
  }
4294
4294
 
4295
4295
  // Find all textareas that need EasyMDE
4296
- document.querySelectorAll('.richtext-container textarea').forEach((textarea) => {
4296
+ document.querySelectorAll('.richtext-container[data-editor-provider="easymde"] textarea').forEach((textarea) => {
4297
4297
  // Skip if already initialized
4298
4298
  if (textarea.dataset.mdxeditorInitialized === 'true') {
4299
4299
  return;
@@ -4392,11 +4392,11 @@ function getTinyMCEInitScript(config) {
4392
4392
  const contentCss = skin.includes("dark") ? "dark" : "default";
4393
4393
  const defaultHeight = config?.defaultHeight || 300;
4394
4394
  return `
4395
- // Initialize TinyMCE for all richtext fields
4395
+ // Initialize TinyMCE only for TinyMCE-backed richtext fields
4396
4396
  function initializeTinyMCE() {
4397
4397
  if (typeof tinymce !== 'undefined') {
4398
4398
  // Find all textareas that need TinyMCE
4399
- document.querySelectorAll('.richtext-container textarea').forEach((textarea) => {
4399
+ document.querySelectorAll('.richtext-container[data-editor-provider="tinymce"] textarea').forEach((textarea) => {
4400
4400
  // Skip if already initialized
4401
4401
  if (tinymce.get(textarea.id)) {
4402
4402
  return;
@@ -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,33 @@ 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
+ }
4918
+ function isMarkdownEditorFieldType(fieldType) {
4919
+ return fieldType === "markdown" || fieldType === "mdxeditor" || fieldType === "easymde";
4920
+ }
4921
+ function getEditorMetadata(fieldType) {
4922
+ if (fieldType === "richtext" || fieldType === "tinymce") {
4923
+ return {
4924
+ family: "richtext",
4925
+ provider: "tinymce"
4926
+ };
4927
+ }
4928
+ if (isMarkdownEditorFieldType(fieldType)) {
4929
+ return {
4930
+ family: "markdown",
4931
+ provider: "easymde"
4932
+ };
4933
+ }
4934
+ return null;
4935
+ }
4836
4936
  function renderDynamicField(field, options = {}) {
4837
4937
  const { value = "", errors = [], disabled = false, className = "", pluginStatuses = {}, collectionId = "", contentId = "" } = options;
4838
4938
  const opts = field.field_options || {};
@@ -4846,10 +4946,10 @@ function renderDynamicField(field, options = {}) {
4846
4946
  if (field.field_type === "quill" && !pluginStatuses.quillEnabled) {
4847
4947
  fallbackToTextarea = true;
4848
4948
  fallbackWarning = "\u26A0\uFE0F Quill Editor plugin is inactive. Using textarea fallback.";
4849
- } else if ((field.field_type === "mdxeditor" || field.field_type === "easymde" || field.field_type === "markdown") && !pluginStatuses.mdxeditorEnabled) {
4949
+ } else if (isMarkdownEditorFieldType(field.field_type) && !pluginStatuses.mdxeditorEnabled) {
4850
4950
  fallbackToTextarea = true;
4851
- fallbackWarning = "\u26A0\uFE0F EasyMDE plugin is inactive. Using textarea fallback.";
4852
- } else if (field.field_type === "tinymce" && !pluginStatuses.tinymceEnabled) {
4951
+ fallbackWarning = "\u26A0\uFE0F Markdown editor plugin is inactive. Using textarea fallback.";
4952
+ } else if ((field.field_type === "richtext" || field.field_type === "tinymce") && !pluginStatuses.tinymceEnabled) {
4853
4953
  fallbackToTextarea = true;
4854
4954
  fallbackWarning = "\u26A0\uFE0F TinyMCE plugin is inactive. Using textarea fallback.";
4855
4955
  }
@@ -4971,8 +5071,10 @@ function renderDynamicField(field, options = {}) {
4971
5071
  `;
4972
5072
  break;
4973
5073
  case "richtext":
5074
+ case "tinymce": {
5075
+ const editorMetadata = getEditorMetadata(field.field_type);
4974
5076
  fieldHTML = `
4975
- <div class="richtext-container" data-height="${opts.height || 300}" data-toolbar="${opts.toolbar || "full"}">
5077
+ <div class="richtext-container" data-height="${opts.height || 300}" data-toolbar="${opts.toolbar || "full"}" data-editor-family="${editorMetadata?.family || ""}" data-editor-provider="${editorMetadata?.provider || ""}">
4976
5078
  <textarea
4977
5079
  id="${fieldId}"
4978
5080
  name="${fieldName}"
@@ -4983,6 +5085,7 @@ function renderDynamicField(field, options = {}) {
4983
5085
  </div>
4984
5086
  `;
4985
5087
  break;
5088
+ }
4986
5089
  case "quill":
4987
5090
  fieldHTML = `
4988
5091
  <div class="quill-editor-container" data-field-id="${fieldId}">
@@ -5006,12 +5109,12 @@ function renderDynamicField(field, options = {}) {
5006
5109
  </div>
5007
5110
  `;
5008
5111
  break;
5009
- case "mdxeditor":
5010
- case "tinymce":
5011
- case "easymde":
5012
5112
  case "markdown":
5113
+ case "mdxeditor":
5114
+ case "easymde": {
5115
+ const editorMetadata = getEditorMetadata(field.field_type);
5013
5116
  fieldHTML = `
5014
- <div class="richtext-container" data-height="${opts.height || 300}" data-toolbar="${opts.toolbar || "full"}">
5117
+ <div class="richtext-container" data-height="${opts.height || 300}" data-toolbar="${opts.toolbar || "full"}" data-editor-family="${editorMetadata?.family || ""}" data-editor-provider="${editorMetadata?.provider || ""}">
5015
5118
  <textarea
5016
5119
  id="${fieldId}"
5017
5120
  name="${fieldName}"
@@ -5022,6 +5125,7 @@ function renderDynamicField(field, options = {}) {
5022
5125
  </div>
5023
5126
  `;
5024
5127
  break;
5128
+ }
5025
5129
  case "number":
5026
5130
  fieldHTML = `
5027
5131
  <input
@@ -5391,12 +5495,14 @@ function renderDynamicField(field, options = {}) {
5391
5495
 
5392
5496
  ${isMultiple ? `
5393
5497
  <div class="media-preview-grid grid grid-cols-4 gap-2 mb-2 ${mediaValues.length === 0 ? "hidden" : ""}" id="${fieldId}-preview">
5394
- ${mediaValues.map((url, idx) => `
5498
+ ${mediaValues.map(
5499
+ (url, idx) => `
5395
5500
  <div class="relative media-preview-item" data-url="${url}">
5396
5501
  ${renderMediaPreview(url, `Media ${idx + 1}`, "w-full h-24 object-cover rounded-lg border border-white/20")}
5397
5502
  <button
5398
5503
  type="button"
5399
5504
  onclick="removeMediaFromMultiple('${fieldId}', '${url}')"
5505
+ data-media-remove="true"
5400
5506
  class="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 hover:bg-red-700"
5401
5507
  ${disabled ? "disabled" : ""}
5402
5508
  >
@@ -5405,7 +5511,8 @@ function renderDynamicField(field, options = {}) {
5405
5511
  </svg>
5406
5512
  </button>
5407
5513
  </div>
5408
- `).join("")}
5514
+ `
5515
+ ).join("")}
5409
5516
  </div>
5410
5517
  ` : `
5411
5518
  <div class="media-preview ${singleValue ? "" : "hidden"}" id="${fieldId}-preview">
@@ -5429,6 +5536,7 @@ function renderDynamicField(field, options = {}) {
5429
5536
  <button
5430
5537
  type="button"
5431
5538
  onclick="clearMediaField('${fieldId}')"
5539
+ data-media-remove="true"
5432
5540
  class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all"
5433
5541
  ${disabled ? "disabled" : ""}
5434
5542
  >
@@ -5462,7 +5570,7 @@ function renderDynamicField(field, options = {}) {
5462
5570
  }
5463
5571
  const showLabel = field.field_type !== "boolean";
5464
5572
  return `
5465
- <div class="form-group">
5573
+ <div class="form-group" data-has-errors="${errors.length > 0 ? "true" : "false"}">
5466
5574
  ${showLabel ? `
5467
5575
  <label for="${fieldId}" class="block text-sm/6 font-medium text-zinc-950 dark:text-white mb-2">
5468
5576
  ${escapeHtml3(field.field_label)}
@@ -5471,7 +5579,7 @@ function renderDynamicField(field, options = {}) {
5471
5579
  ` : ""}
5472
5580
  ${fieldHTML}
5473
5581
  ${errors.length > 0 ? `
5474
- <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>
5475
5583
  ${errors.map((error) => `<div>${escapeHtml3(error)}</div>`).join("")}
5476
5584
  </div>
5477
5585
  ` : ""}
@@ -5486,8 +5594,8 @@ function renderDynamicField(field, options = {}) {
5486
5594
  function renderFieldGroup(title, fields, collapsible = false) {
5487
5595
  const groupId = title.toLowerCase().replace(/\s+/g, "-");
5488
5596
  return `
5489
- <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">
5490
- <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)"` : ""}>
5491
5599
  <h3 class="text-base/7 font-semibold text-zinc-950 dark:text-white flex items-center">
5492
5600
  ${escapeHtml3(title)}
5493
5601
  ${collapsible ? `
@@ -5531,6 +5639,12 @@ function renderBlocksField(field, options, baseClasses, errorClasses) {
5531
5639
  >
5532
5640
  <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml3(JSON.stringify(blockValues))}">
5533
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
+
5534
5648
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5535
5649
  <div class="flex-1">
5536
5650
  <select
@@ -5561,12 +5675,14 @@ function renderBlocksField(field, options, baseClasses, errorClasses) {
5561
5675
  `;
5562
5676
  }
5563
5677
  function renderStructuredObjectField(field, options, baseClasses, errorClasses) {
5564
- const { value = {}, pluginStatuses = {} } = options;
5678
+ const { value = {}, pluginStatuses = {}, errors = [] } = options;
5565
5679
  const opts = field.field_options || {};
5566
5680
  const properties = opts.properties && typeof opts.properties === "object" ? opts.properties : {};
5567
5681
  const fieldId = `field-${field.field_name}`;
5568
5682
  const fieldName = field.field_name;
5569
5683
  const objectValue = normalizeStructuredObjectValue(value);
5684
+ const objectLayout = opts.objectLayout || "nested";
5685
+ const useNestedLayout = objectLayout !== "flat";
5570
5686
  const subfields = Object.entries(properties).map(
5571
5687
  ([propertyName, propertyConfig]) => renderStructuredSubfield(
5572
5688
  field,
@@ -5577,11 +5693,40 @@ function renderStructuredObjectField(field, options, baseClasses, errorClasses)
5577
5693
  field.field_name
5578
5694
  )
5579
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;
5580
5715
  return `
5581
- <div class="space-y-4" data-structured-object data-field-name="${escapeHtml3(fieldName)}">
5582
- <input type="hidden" id="${fieldId}" name="${fieldName}" value="${escapeHtml3(JSON.stringify(objectValue))}">
5583
- <div class="space-y-4" data-structured-object-fields>
5584
- ${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>
5585
5730
  </div>
5586
5731
  </div>
5587
5732
  ${getStructuredFieldScript()}
@@ -5652,7 +5797,7 @@ function renderStructuredArrayField(field, options, baseClasses, errorClasses) {
5652
5797
  function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginStatuses, arrayItemTitle) {
5653
5798
  const itemFields = renderStructuredItemFields(field, itemConfig, index, itemValue, pluginStatuses);
5654
5799
  return `
5655
- <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">
5656
5801
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5657
5802
  <div class="flex items-center gap-3">
5658
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">
@@ -5665,6 +5810,11 @@ function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginSt
5665
5810
  </div>
5666
5811
  </div>
5667
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>
5668
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">
5669
5819
  <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
5670
5820
  <path stroke-linecap="round" stroke-linejoin="round" d="M12 6l-4 4m4-4l4 4m-4-4v12"/>
@@ -5683,7 +5833,7 @@ function renderStructuredArrayItem(field, itemConfig, index, itemValue, pluginSt
5683
5833
  </button>
5684
5834
  </div>
5685
5835
  </div>
5686
- <div class="mt-4 space-y-4" data-array-item-fields>
5836
+ <div class="mt-4 space-y-4 hidden" data-array-item-fields>
5687
5837
  ${itemFields}
5688
5838
  </div>
5689
5839
  </div>
@@ -5793,7 +5943,7 @@ function normalizeBlocksValue(value, discriminator) {
5793
5943
  function renderBlockTemplate(field, block, discriminator, pluginStatuses) {
5794
5944
  return `
5795
5945
  <template data-block-template="${escapeHtml3(block.name)}">
5796
- ${renderBlockCard(field, block, discriminator, "__INDEX__", {}, pluginStatuses)}
5946
+ ${renderBlockCard(field, block, discriminator, BLOCK_INDEX_TOKEN, {}, pluginStatuses)}
5797
5947
  </template>
5798
5948
  `;
5799
5949
  }
@@ -5835,7 +5985,7 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5835
5985
  `;
5836
5986
  }).join("");
5837
5987
  return `
5838
- <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">
5839
5989
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
5840
5990
  <div class="flex items-start gap-3">
5841
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">
@@ -5843,7 +5993,7 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5843
5993
  <path stroke-linecap="round" stroke-linejoin="round" d="M4 8h16M4 16h16"/>
5844
5994
  </svg>
5845
5995
  </div>
5846
- <div>
5996
+ <div class="cursor-pointer" data-action="toggle-block">
5847
5997
  <div class="text-sm font-semibold text-zinc-900 dark:text-white">
5848
5998
  ${escapeHtml3(block.label)}
5849
5999
  <span class="ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400" data-block-order-label></span>
@@ -5852,6 +6002,11 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5852
6002
  </div>
5853
6003
  </div>
5854
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>
5855
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">
5856
6011
  <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="4">
5857
6012
  <path stroke-linecap="round" stroke-linejoin="round" d="M12 6l-4 4m4-4l4 4m-4-4v12"/>
@@ -5870,7 +6025,7 @@ function renderBlockCard(field, block, discriminator, index, data, pluginStatuse
5870
6025
  </button>
5871
6026
  </div>
5872
6027
  </div>
5873
- <div class="mt-4 space-y-4">
6028
+ <div class="mt-4 space-y-4 hidden" data-block-content>
5874
6029
  ${blockFields}
5875
6030
  </div>
5876
6031
  </div>
@@ -5904,9 +6059,101 @@ function getStructuredFieldScript() {
5904
6059
 
5905
6060
  function initializeStructuredFields() {
5906
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
+ };
5907
6153
 
5908
6154
  const readStructuredValue = (container) => {
5909
- const fields = Array.from(container.querySelectorAll('.structured-subfield'));
6155
+ const fieldHost = getStructuredValueHost(container);
6156
+ const fields = getDirectStructuredSubfields(fieldHost);
5910
6157
  if (fields.length === 1 && fields[0].dataset.structuredField === '__value') {
5911
6158
  return readFieldValue(fields[0]);
5912
6159
  }
@@ -5920,111 +6167,229 @@ function getStructuredFieldScript() {
5920
6167
  };
5921
6168
 
5922
6169
  document.querySelectorAll('[data-structured-object]').forEach((container) => {
6170
+ if (container.closest('template')) {
6171
+ return;
6172
+ }
5923
6173
  if (container.dataset.structuredInitialized === 'true') {
5924
6174
  return;
5925
6175
  }
5926
- container.dataset.structuredInitialized = 'true';
5927
- 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"]');
5928
6182
 
5929
- const updateHiddenInput = () => {
5930
- if (!hiddenInput) return;
5931
- const value = readStructuredValue(container);
5932
- hiddenInput.value = JSON.stringify(value);
5933
- };
6183
+ const updateHiddenInput = () => {
6184
+ if (!hiddenInput) return;
6185
+ const value = readStructuredValue(container);
6186
+ hiddenInput.value = JSON.stringify(value);
6187
+ };
5934
6188
 
5935
- container.addEventListener('input', updateHiddenInput);
5936
- container.addEventListener('change', updateHiddenInput);
5937
- 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
+ }
5938
6199
  });
5939
6200
 
5940
6201
  document.querySelectorAll('[data-structured-array]').forEach((container) => {
6202
+ if (container.closest('template')) {
6203
+ return;
6204
+ }
5941
6205
  if (container.dataset.structuredInitialized === 'true') {
5942
6206
  return;
5943
6207
  }
5944
- container.dataset.structuredInitialized = 'true';
5945
- const list = container.querySelector('[data-structured-array-list]');
5946
- const hiddenInput = container.querySelector('input[type="hidden"]');
5947
- 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
+ }
5948
6223
 
5949
- const updateOrderLabels = () => {
5950
- const items = Array.from(container.querySelectorAll('.structured-array-item'));
5951
- items.forEach((item, index) => {
5952
- const label = item.querySelector('[data-array-order-label]');
5953
- if (label) {
5954
- 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;
5955
6232
  }
5956
6233
 
5957
- const moveUpButton = item.querySelector('[data-action="move-up"]');
5958
- if (moveUpButton instanceof HTMLButtonElement) {
5959
- 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;
5960
6245
  }
6246
+ return typeof container.__sonicStructuredArrayTemplate === 'string'
6247
+ ? container.__sonicStructuredArrayTemplate
6248
+ : '';
6249
+ };
5961
6250
 
5962
- const moveDownButton = item.querySelector('[data-action="move-down"]');
5963
- if (moveDownButton instanceof HTMLButtonElement) {
5964
- 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';
5965
6287
  }
5966
- });
5967
- };
6288
+ updateOrderLabels();
6289
+ };
5968
6290
 
5969
- const updateHiddenInput = () => {
5970
- if (!hiddenInput || !list) return;
5971
- const items = Array.from(list.querySelectorAll('.structured-array-item'));
5972
- const values = items.map((item) => readStructuredValue(item));
5973
- 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
+ };
5974
6331
 
5975
- const emptyState = list.querySelector('[data-structured-empty]');
5976
- if (emptyState) {
5977
- 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
+ });
5978
6341
  }
5979
- updateOrderLabels();
5980
- };
5981
6342
 
5982
- if (typeof window.initializeDragSortable === 'function' && list) {
5983
- window.initializeDragSortable(list, {
5984
- itemSelector: '.structured-array-item',
5985
- handleSelector: '[data-action="drag-handle"]',
5986
- onUpdate: updateHiddenInput
5987
- });
5988
- }
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
+ }
5989
6354
 
5990
- container.addEventListener('click', (event) => {
6355
+ container.addEventListener('click', (event) => {
5991
6356
  const target = event.target;
5992
6357
  if (!(target instanceof Element)) return;
5993
6358
  const actionButton = target.closest('[data-action]');
5994
6359
  if (!actionButton || actionButton.hasAttribute('disabled')) return;
6360
+ const actionOwner = actionButton.closest('[data-structured-array]');
6361
+ if (actionOwner !== container) return;
5995
6362
 
5996
- const action = actionButton.getAttribute('data-action');
6363
+ const action = actionButton.getAttribute('data-action');
5997
6364
 
5998
- if (action === 'add-item') {
5999
- if (!list || !template) return;
6000
- const nextIndex = list.querySelectorAll('.structured-array-item').length;
6001
- const html = template.innerHTML.replace(/__INDEX__/g, String(nextIndex));
6002
- list.insertAdjacentHTML('beforeend', html);
6003
- if (typeof initializeTinyMCE === 'function') {
6004
- initializeTinyMCE();
6005
- }
6006
- if (typeof window.initializeQuillEditors === 'function') {
6007
- window.initializeQuillEditors();
6008
- }
6009
- if (typeof initializeMDXEditor === 'function') {
6010
- initializeMDXEditor();
6365
+ if (action === 'add-item') {
6366
+ addArrayItem();
6367
+ return;
6011
6368
  }
6012
- updateHiddenInput();
6013
- return;
6014
- }
6015
6369
 
6016
6370
  const item = actionButton.closest('.structured-array-item');
6017
- 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
+ }
6018
6381
 
6019
6382
  if (action === 'remove-item') {
6020
6383
  if (typeof requestRepeaterDelete === 'function') {
6021
6384
  requestRepeaterDelete(() => {
6022
6385
  item.remove();
6023
6386
  updateHiddenInput();
6387
+ syncArrayState(container);
6024
6388
  });
6025
6389
  } else {
6026
6390
  item.remove();
6027
6391
  updateHiddenInput();
6392
+ syncArrayState(container);
6028
6393
  }
6029
6394
  return;
6030
6395
  }
@@ -6032,8 +6397,9 @@ function getStructuredFieldScript() {
6032
6397
  if (action === 'move-up') {
6033
6398
  const previous = item.previousElementSibling;
6034
6399
  if (previous) {
6035
- list.insertBefore(item, previous);
6400
+ liveList.insertBefore(item, previous);
6036
6401
  updateHiddenInput();
6402
+ syncArrayState(container);
6037
6403
  }
6038
6404
  return;
6039
6405
  }
@@ -6041,29 +6407,43 @@ function getStructuredFieldScript() {
6041
6407
  if (action === 'move-down') {
6042
6408
  const next = item.nextElementSibling;
6043
6409
  if (next) {
6044
- list.insertBefore(next, item);
6410
+ liveList.insertBefore(next, item);
6045
6411
  updateHiddenInput();
6412
+ syncArrayState(container);
6046
6413
  }
6047
6414
  }
6048
- });
6415
+ });
6049
6416
 
6050
- container.addEventListener('input', (event) => {
6417
+ container.addEventListener('input', (event) => {
6051
6418
  const target = event.target;
6052
6419
  if (!(target instanceof Element)) return;
6053
6420
  if (target.closest('[data-structured-array-list]')) {
6054
6421
  updateHiddenInput();
6055
6422
  }
6056
- });
6423
+ });
6057
6424
 
6058
- container.addEventListener('change', (event) => {
6425
+ container.addEventListener('change', (event) => {
6059
6426
  const target = event.target;
6060
6427
  if (!(target instanceof Element)) return;
6061
6428
  if (target.closest('[data-structured-array-list]')) {
6062
6429
  updateHiddenInput();
6063
6430
  }
6064
- });
6431
+ });
6065
6432
 
6066
- 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
+ }
6067
6447
  });
6068
6448
  }
6069
6449
 
@@ -6078,7 +6458,10 @@ function getStructuredFieldScript() {
6078
6458
  document.addEventListener('htmx:afterSwap', function() {
6079
6459
  setTimeout(initializeStructuredFields, 50);
6080
6460
  });
6081
- } else if (typeof window.initializeStructuredFields === 'function') {
6461
+ } else if (
6462
+ typeof window.initializeStructuredFields === 'function' &&
6463
+ document.readyState !== 'loading'
6464
+ ) {
6082
6465
  window.initializeStructuredFields();
6083
6466
  }
6084
6467
  </script>
@@ -6090,6 +6473,68 @@ function getBlocksFieldScript() {
6090
6473
  <script>
6091
6474
  if (!window.__sonicBlocksFieldInit) {
6092
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
+ };
6093
6538
 
6094
6539
  function initializeBlocksFields() {
6095
6540
  document.querySelectorAll('.blocks-field').forEach((container) => {
@@ -6177,7 +6622,10 @@ function getBlocksFieldScript() {
6177
6622
  window.initializeDragSortable(list, {
6178
6623
  itemSelector: '.blocks-item',
6179
6624
  handleSelector: '[data-action="drag-handle"]',
6180
- onUpdate: updateHiddenInput
6625
+ onUpdate: () => {
6626
+ updateHiddenInput();
6627
+ syncBlocksState(container);
6628
+ }
6181
6629
  });
6182
6630
  }
6183
6631
 
@@ -6199,8 +6647,12 @@ function getBlocksFieldScript() {
6199
6647
  if (!template) return;
6200
6648
 
6201
6649
  const nextIndex = list.querySelectorAll('.blocks-item').length;
6202
- const html = template.innerHTML.replace(/__INDEX__/g, String(nextIndex));
6650
+ const html = template.innerHTML.replace(/__BLOCK_INDEX__/g, String(nextIndex));
6203
6651
  list.insertAdjacentHTML('beforeend', html);
6652
+ const newItem = list.lastElementChild;
6653
+ if (newItem instanceof HTMLElement) {
6654
+ setBlockExpanded(newItem, true);
6655
+ }
6204
6656
  if (typeSelect) {
6205
6657
  typeSelect.value = '';
6206
6658
  }
@@ -6209,21 +6661,32 @@ function getBlocksFieldScript() {
6209
6661
  window.initializeStructuredFields();
6210
6662
  }
6211
6663
  updateHiddenInput();
6664
+ syncBlocksState(container);
6212
6665
  return;
6213
6666
  }
6214
6667
 
6215
6668
  const item = actionButton.closest('.blocks-item');
6216
6669
  if (!item || !list) return;
6217
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
+
6218
6679
  if (action === 'remove-block') {
6219
6680
  if (typeof requestRepeaterDelete === 'function') {
6220
6681
  requestRepeaterDelete(() => {
6221
6682
  item.remove();
6222
6683
  updateHiddenInput();
6684
+ syncBlocksState(container);
6223
6685
  }, 'block');
6224
6686
  } else {
6225
6687
  item.remove();
6226
6688
  updateHiddenInput();
6689
+ syncBlocksState(container);
6227
6690
  }
6228
6691
  return;
6229
6692
  }
@@ -6233,6 +6696,7 @@ function getBlocksFieldScript() {
6233
6696
  if (previous) {
6234
6697
  list.insertBefore(item, previous);
6235
6698
  updateHiddenInput();
6699
+ syncBlocksState(container);
6236
6700
  }
6237
6701
  return;
6238
6702
  }
@@ -6242,6 +6706,7 @@ function getBlocksFieldScript() {
6242
6706
  if (next) {
6243
6707
  list.insertBefore(next, item);
6244
6708
  updateHiddenInput();
6709
+ syncBlocksState(container);
6245
6710
  }
6246
6711
  }
6247
6712
  });
@@ -6263,6 +6728,12 @@ function getBlocksFieldScript() {
6263
6728
  });
6264
6729
 
6265
6730
  updateHiddenInput();
6731
+ const savedBlocksState = readBlocksState(container);
6732
+ if (savedBlocksState) {
6733
+ applyBlocksState(container, savedBlocksState);
6734
+ } else {
6735
+ syncBlocksState(container);
6736
+ }
6266
6737
  });
6267
6738
  }
6268
6739
 
@@ -6299,6 +6770,7 @@ init_admin_layout_catalyst_template();
6299
6770
  function renderContentFormPage(data) {
6300
6771
  const isEdit = data.isEdit || !!data.id;
6301
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);
6302
6774
  const backUrl = data.referrerParams ? `/admin/content?${data.referrerParams}` : `/admin/content?collection=${data.collection.id}`;
6303
6775
  const coreFields = data.fields.filter((f) => ["title", "slug", "content"].includes(f.field_name));
6304
6776
  const contentFields = data.fields.filter((f) => !["title", "slug", "content"].includes(f.field_name) && !f.field_name.startsWith("meta_"));
@@ -6387,6 +6859,7 @@ function renderContentFormPage(data) {
6387
6859
  ${isEdit ? `hx-put="/admin/content/${data.id}"` : `hx-post="/admin/content"`}
6388
6860
  hx-target="#form-messages"
6389
6861
  hx-encoding="multipart/form-data"
6862
+ data-has-validation-errors="${hasValidationErrors ? "true" : "false"}"
6390
6863
  class="space-y-6"
6391
6864
  >
6392
6865
  <input type="hidden" name="collection_id" value="${data.collection.id}">
@@ -6667,39 +7140,456 @@ function renderContentFormPage(data) {
6667
7140
 
6668
7141
  <!-- Dynamic Field Scripts -->
6669
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
+
6670
7436
  // Field group toggle
6671
- function toggleFieldGroup(groupId) {
6672
- const content = document.getElementById(groupId + '-content');
6673
- const icon = document.getElementById(groupId + '-icon');
6674
-
6675
- if (content.classList.contains('hidden')) {
6676
- content.classList.remove('hidden');
6677
- icon.classList.remove('rotate-[-90deg]');
6678
- } else {
6679
- content.classList.add('hidden');
6680
- 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();
6681
7460
  }
6682
7461
  }
6683
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
+
6684
7510
  // Media field functions
6685
- 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
+ }
6686
7570
 
6687
7571
  function openMediaSelector(fieldId) {
6688
- currentMediaFieldId = fieldId;
7572
+ const existingModal = getActiveMediaModal();
7573
+ if (existingModal) {
7574
+ existingModal.remove();
7575
+ }
7576
+
6689
7577
  // Store the original value in case user cancels
6690
- const originalValue = document.getElementById(fieldId)?.value || '';
7578
+ const originalValue = getMediaFieldElements(fieldId).hiddenInput?.value || '';
6691
7579
 
6692
7580
  // Open media library modal
6693
7581
  const modal = document.createElement('div');
6694
7582
  modal.className = 'fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50';
6695
7583
  modal.id = 'media-selector-modal';
7584
+ modal.dataset.targetFieldId = fieldId;
7585
+ modal.dataset.originalValue = originalValue;
6696
7586
  modal.innerHTML = \`
6697
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">
6698
7588
  <h3 class="text-lg font-semibold text-zinc-950 dark:text-white mb-4">Select Media</h3>
6699
7589
  <div id="media-grid-container" hx-get="/admin/media/selector" hx-trigger="load"></div>
6700
7590
  <div class="mt-4 flex justify-end space-x-2">
6701
7591
  <button
6702
- onclick="cancelMediaSelection('\${fieldId}', '\${originalValue}')"
7592
+ onclick="cancelMediaSelection()"
6703
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">
6704
7594
  Cancel
6705
7595
  </button>
@@ -6719,23 +7609,23 @@ function renderContentFormPage(data) {
6719
7609
  }
6720
7610
 
6721
7611
  function closeMediaSelector() {
6722
- const modal = document.getElementById('media-selector-modal');
7612
+ const modal = getActiveMediaModal();
6723
7613
  if (modal) {
6724
7614
  modal.remove();
6725
7615
  }
6726
- currentMediaFieldId = null;
6727
7616
  }
6728
7617
 
6729
- function cancelMediaSelection(fieldId, originalValue) {
7618
+ function cancelMediaSelection() {
7619
+ const { hiddenInput, preview, originalValue } = getActiveMediaTarget();
7620
+
6730
7621
  // Restore original value
6731
- const hiddenInput = document.getElementById(fieldId);
6732
7622
  if (hiddenInput) {
6733
7623
  hiddenInput.value = originalValue;
7624
+ notifyFieldChange(hiddenInput);
6734
7625
  }
6735
7626
 
6736
7627
  // If original value was empty, hide the preview and show select button
6737
7628
  if (!originalValue) {
6738
- const preview = document.getElementById(fieldId + '-preview');
6739
7629
  if (preview) {
6740
7630
  preview.classList.add('hidden');
6741
7631
  }
@@ -6746,11 +7636,11 @@ function renderContentFormPage(data) {
6746
7636
  }
6747
7637
 
6748
7638
  function clearMediaField(fieldId) {
6749
- const hiddenInput = document.getElementById(fieldId);
6750
- const preview = document.getElementById(fieldId + '-preview');
7639
+ const { hiddenInput, preview, actionsDiv } = getMediaFieldElements(fieldId);
6751
7640
 
6752
7641
  if (hiddenInput) {
6753
7642
  hiddenInput.value = '';
7643
+ notifyFieldChange(hiddenInput);
6754
7644
  }
6755
7645
 
6756
7646
  if (preview) {
@@ -6760,25 +7650,34 @@ function renderContentFormPage(data) {
6760
7650
  }
6761
7651
  preview.classList.add('hidden');
6762
7652
  }
7653
+
7654
+ const removeButton = actionsDiv?.querySelector('[data-media-remove="true"]');
7655
+ if (removeButton) {
7656
+ removeButton.remove();
7657
+ }
6763
7658
  }
6764
7659
 
6765
7660
  // Global function to remove a single media from multiple selection
6766
7661
  window.removeMediaFromMultiple = function(fieldId, urlToRemove) {
6767
- const hiddenInput = document.getElementById(fieldId);
7662
+ const { hiddenInput, preview } = getMediaFieldElements(fieldId);
6768
7663
  if (!hiddenInput) return;
6769
7664
 
6770
7665
  const values = hiddenInput.value.split(',').filter(url => url !== urlToRemove);
6771
7666
  hiddenInput.value = values.join(',');
7667
+ notifyFieldChange(hiddenInput);
6772
7668
 
6773
7669
  // Remove preview item
6774
- 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
+ );
6775
7675
  if (previewItem) {
6776
7676
  previewItem.remove();
6777
7677
  }
6778
7678
 
6779
7679
  // Hide preview grid if empty
6780
7680
  if (values.length === 0) {
6781
- const preview = document.getElementById(fieldId + '-preview');
6782
7681
  if (preview) {
6783
7682
  preview.classList.add('hidden');
6784
7683
  }
@@ -6787,39 +7686,24 @@ function renderContentFormPage(data) {
6787
7686
 
6788
7687
  // Global function called by media selector buttons
6789
7688
  window.selectMediaFile = function(mediaId, mediaUrl, filename) {
6790
- if (!currentMediaFieldId) {
7689
+ const { fieldId, hiddenInput, preview, actionsDiv } = getActiveMediaTarget();
7690
+ if (!fieldId || !hiddenInput) {
6791
7691
  console.error('No field ID set for media selection');
6792
7692
  return;
6793
7693
  }
6794
7694
 
6795
- const fieldId = currentMediaFieldId;
6796
-
6797
7695
  // Set the hidden input value to the media URL (not ID)
6798
- const hiddenInput = document.getElementById(fieldId);
6799
- if (hiddenInput) {
6800
- hiddenInput.value = mediaUrl;
6801
- }
7696
+ hiddenInput.value = mediaUrl;
7697
+ notifyFieldChange(hiddenInput);
6802
7698
 
6803
7699
  // Update the preview
6804
- const preview = document.getElementById(fieldId + '-preview');
6805
7700
  if (preview) {
6806
7701
  preview.innerHTML = \`<img src="\${mediaUrl}" alt="\${filename}" class="w-32 h-32 object-cover rounded-lg border border-white/20">\`;
6807
7702
  preview.classList.remove('hidden');
6808
7703
  }
6809
7704
 
6810
7705
  // Show the remove button by finding the media actions container and updating it
6811
- const mediaField = hiddenInput?.closest('.media-field-container');
6812
- if (mediaField) {
6813
- const actionsDiv = mediaField.querySelector('.media-actions');
6814
- if (actionsDiv && !actionsDiv.querySelector('button:has-text("Remove")')) {
6815
- const removeBtn = document.createElement('button');
6816
- removeBtn.type = 'button';
6817
- removeBtn.onclick = () => clearMediaField(fieldId);
6818
- removeBtn.className = 'inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-all';
6819
- removeBtn.textContent = 'Remove';
6820
- actionsDiv.appendChild(removeBtn);
6821
- }
6822
- }
7706
+ ensureSingleMediaRemoveButton(fieldId, actionsDiv);
6823
7707
 
6824
7708
  // DON'T close the modal - let user click OK button
6825
7709
  // Visual feedback: highlight the selected item
@@ -6833,7 +7717,9 @@ function renderContentFormPage(data) {
6833
7717
  };
6834
7718
 
6835
7719
  function setMediaField(fieldId, mediaUrl) {
6836
- document.getElementById(fieldId).value = mediaUrl;
7720
+ const hiddenInput = document.getElementById(fieldId);
7721
+ hiddenInput.value = mediaUrl;
7722
+ notifyFieldChange(hiddenInput);
6837
7723
  const preview = document.getElementById(fieldId + '-preview');
6838
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">\`;
6839
7725
  preview.classList.remove('hidden');
@@ -8240,6 +9126,40 @@ function renderContentListPage(data) {
8240
9126
  return renderAdminLayoutCatalyst(layoutData);
8241
9127
  }
8242
9128
 
9129
+ // src/routes/admin-content-field-types.ts
9130
+ function resolveSchemaFieldType(fieldConfig) {
9131
+ if (fieldConfig.type === "slug" || fieldConfig.format === "slug") {
9132
+ return "slug";
9133
+ }
9134
+ if (fieldConfig.type && fieldConfig.type !== "string") {
9135
+ return fieldConfig.type;
9136
+ }
9137
+ if (fieldConfig.format === "richtext") {
9138
+ return "richtext";
9139
+ }
9140
+ if (fieldConfig.format === "media") {
9141
+ return "media";
9142
+ }
9143
+ if (fieldConfig.format === "date-time") {
9144
+ return "date";
9145
+ }
9146
+ if (Array.isArray(fieldConfig.enum)) {
9147
+ return "select";
9148
+ }
9149
+ return fieldConfig.type || "string";
9150
+ }
9151
+ function buildSchemaFieldOptions(fieldConfig) {
9152
+ const fieldOptions = { ...fieldConfig };
9153
+ const resolvedFieldType = resolveSchemaFieldType(fieldConfig);
9154
+ if (resolvedFieldType === "select" && Array.isArray(fieldConfig.enum)) {
9155
+ fieldOptions.options = fieldConfig.enum.map((value, index) => ({
9156
+ value,
9157
+ label: fieldConfig.enumLabels?.[index] || value
9158
+ }));
9159
+ }
9160
+ return fieldOptions;
9161
+ }
9162
+
8243
9163
  // src/routes/admin-content.ts
8244
9164
  var adminContentRoutes = new Hono();
8245
9165
  function parseFieldValue(field, formData, options = {}) {
@@ -8372,17 +9292,11 @@ async function getCollectionFields(db, collectionId) {
8372
9292
  if (schema && schema.properties) {
8373
9293
  let fieldOrder = 0;
8374
9294
  return Object.entries(schema.properties).map(([fieldName, fieldConfig]) => {
8375
- let fieldOptions = { ...fieldConfig };
8376
- if (fieldConfig.type === "select" && fieldConfig.enum) {
8377
- fieldOptions.options = fieldConfig.enum.map((value, index) => ({
8378
- value,
8379
- label: fieldConfig.enumLabels?.[index] || value
8380
- }));
8381
- }
9295
+ const fieldOptions = buildSchemaFieldOptions(fieldConfig);
8382
9296
  return {
8383
9297
  id: `schema-${fieldName}`,
8384
9298
  field_name: fieldName,
8385
- field_type: fieldConfig.type || "string",
9299
+ field_type: resolveSchemaFieldType(fieldConfig),
8386
9300
  field_label: fieldConfig.title || fieldName,
8387
9301
  field_options: fieldOptions,
8388
9302
  field_order: fieldOrder++,
@@ -11400,6 +12314,13 @@ function renderUsersListPage(data) {
11400
12314
  // src/routes/admin-users.ts
11401
12315
  var userRoutes = new Hono();
11402
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"]));
11403
12324
  userRoutes.get("/", (c) => {
11404
12325
  return c.redirect("/admin/dashboard");
11405
12326
  });
@@ -11887,7 +12808,9 @@ userRoutes.post("/users/new", async (c) => {
11887
12808
  const email = formData.get("email")?.toString()?.trim().toLowerCase() || "";
11888
12809
  const phone = sanitizeInput(formData.get("phone")?.toString()) || null;
11889
12810
  const bio = sanitizeInput(formData.get("bio")?.toString()) || null;
11890
- 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";
11891
12814
  const password = formData.get("password")?.toString() || "";
11892
12815
  const confirmPassword = formData.get("confirm_password")?.toString() || "";
11893
12816
  const isActive = formData.get("is_active") === "1";
@@ -12107,7 +13030,9 @@ userRoutes.put("/users/:id", async (c) => {
12107
13030
  const username = sanitizeInput(formData.get("username")?.toString());
12108
13031
  const email = formData.get("email")?.toString()?.trim().toLowerCase() || "";
12109
13032
  const phone = sanitizeInput(formData.get("phone")?.toString()) || null;
12110
- 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";
12111
13036
  const isActive = formData.get("is_active") === "1";
12112
13037
  const emailVerified = formData.get("email_verified") === "1";
12113
13038
  const profileDisplayName = sanitizeInput(formData.get("profile_display_name")?.toString()) || null;
@@ -17432,6 +18357,18 @@ adminPluginRoutes.post("/:id/settings", async (c) => {
17432
18357
  const settings = await c.req.json();
17433
18358
  const pluginService = new PluginService(db);
17434
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
+ }
17435
18372
  return c.json({ success: true });
17436
18373
  } catch (error) {
17437
18374
  console.error("Error updating plugin settings:", error);
@@ -20780,6 +21717,20 @@ router.get("/system-status", async (c) => {
20780
21717
  }
20781
21718
  });
20782
21719
 
21720
+ // src/routes/admin-collections-field-types.ts
21721
+ function isMarkdownEditorType(fieldType) {
21722
+ return fieldType === "markdown" || fieldType === "mdxeditor" || fieldType === "easymde";
21723
+ }
21724
+ function normalizeFieldType(fieldType) {
21725
+ if (isMarkdownEditorType(fieldType)) {
21726
+ return "markdown";
21727
+ }
21728
+ if (fieldType === "tinymce") {
21729
+ return "richtext";
21730
+ }
21731
+ return fieldType;
21732
+ }
21733
+
20783
21734
  // src/templates/pages/admin-collections-list.template.ts
20784
21735
  init_admin_layout_catalyst_template();
20785
21736
 
@@ -21266,7 +22217,9 @@ function getFieldTypeBadge(fieldType) {
21266
22217
  "slug": "URL Slug",
21267
22218
  "richtext": "Rich Text (TinyMCE)",
21268
22219
  "quill": "Rich Text (Quill)",
21269
- "mdxeditor": "EasyMDX",
22220
+ "markdown": "Markdown",
22221
+ "mdxeditor": "Markdown",
22222
+ "easymde": "Markdown",
21270
22223
  "number": "Number",
21271
22224
  "boolean": "Boolean",
21272
22225
  "date": "Date",
@@ -21279,7 +22232,9 @@ function getFieldTypeBadge(fieldType) {
21279
22232
  "slug": "bg-sky-500/10 dark:bg-sky-400/10 text-sky-700 dark:text-sky-300 ring-sky-500/20 dark:ring-sky-400/20",
21280
22233
  "richtext": "bg-purple-500/10 dark:bg-purple-400/10 text-purple-700 dark:text-purple-300 ring-purple-500/20 dark:ring-purple-400/20",
21281
22234
  "quill": "bg-purple-500/10 dark:bg-purple-400/10 text-purple-700 dark:text-purple-300 ring-purple-500/20 dark:ring-purple-400/20",
22235
+ "markdown": "bg-purple-500/10 dark:bg-purple-400/10 text-purple-700 dark:text-purple-300 ring-purple-500/20 dark:ring-purple-400/20",
21282
22236
  "mdxeditor": "bg-purple-500/10 dark:bg-purple-400/10 text-purple-700 dark:text-purple-300 ring-purple-500/20 dark:ring-purple-400/20",
22237
+ "easymde": "bg-purple-500/10 dark:bg-purple-400/10 text-purple-700 dark:text-purple-300 ring-purple-500/20 dark:ring-purple-400/20",
21283
22238
  "number": "bg-green-500/10 dark:bg-green-400/10 text-green-700 dark:text-green-300 ring-green-500/20 dark:ring-green-400/20",
21284
22239
  "boolean": "bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-300 ring-amber-500/20 dark:ring-amber-400/20",
21285
22240
  "date": "bg-cyan-500/10 dark:bg-cyan-400/10 text-cyan-700 dark:text-cyan-300 ring-cyan-500/20 dark:ring-cyan-400/20",
@@ -21760,7 +22715,7 @@ function renderCollectionFormPage(data) {
21760
22715
  <option value="slug">URL Slug</option>
21761
22716
  ${data.editorPlugins?.tinymce ? '<option value="richtext">Rich Text (TinyMCE)</option>' : ""}
21762
22717
  ${data.editorPlugins?.quill ? '<option value="quill">Rich Text (Quill)</option>' : ""}
21763
- ${data.editorPlugins?.easyMdx ? '<option value="mdxeditor">EasyMDX</option>' : ""}
22718
+ ${data.editorPlugins?.easyMdx ? '<option value="markdown">Markdown</option>' : ""}
21764
22719
  <option value="number">Number</option>
21765
22720
  <option value="boolean">Boolean</option>
21766
22721
  <option value="date">Date</option>
@@ -21985,7 +22940,7 @@ function renderCollectionFormPage(data) {
21985
22940
  // Check if it's a schema field with field_options that might indicate the actual type
21986
22941
  if (field.field_options && typeof field.field_options === 'object') {
21987
22942
  // Only convert to richtext if type is explicitly 'string' and format is richtext
21988
- // Don't convert if it's already a specific editor type like 'mdxeditor', 'quill', etc.
22943
+ // Don't convert if it's already a specific editor type like 'markdown', 'quill', etc.
21989
22944
  if (field.field_options.format === 'richtext' && uiFieldType === 'string') {
21990
22945
  uiFieldType = 'richtext';
21991
22946
  }
@@ -22006,6 +22961,12 @@ function renderCollectionFormPage(data) {
22006
22961
  uiFieldType = typeMapping[uiFieldType];
22007
22962
  }
22008
22963
 
22964
+ if (uiFieldType === 'mdxeditor' || uiFieldType === 'easymde') {
22965
+ uiFieldType = 'markdown';
22966
+ } else if (uiFieldType === 'tinymce') {
22967
+ uiFieldType = 'richtext';
22968
+ }
22969
+
22009
22970
  // Log all available options
22010
22971
  const availableOptions = Array.from(fieldTypeSelect.options).map(opt => ({ value: opt.value, text: opt.text }));
22011
22972
  console.log('Available dropdown options:', availableOptions);
@@ -22092,7 +23053,7 @@ function renderCollectionFormPage(data) {
22092
23053
 
22093
23054
  console.log('[Edit Field] Showing options for field type:', fieldType, '(original:', field.field_type, ')');
22094
23055
 
22095
- if (['select', 'radio', 'media', 'richtext', 'reference'].includes(fieldType)) {
23056
+ if (['select', 'radio', 'media', 'richtext', 'markdown', 'reference'].includes(fieldType)) {
22096
23057
  optionsContainer.classList.remove('hidden');
22097
23058
 
22098
23059
  // Set help text based on type
@@ -22109,6 +23070,9 @@ function renderCollectionFormPage(data) {
22109
23070
  case 'richtext':
22110
23071
  helpText.textContent = 'Full-featured WYSIWYG text editor with formatting options';
22111
23072
  break;
23073
+ case 'markdown':
23074
+ helpText.textContent = 'Markdown editor with live preview powered by the EasyMDE plugin';
23075
+ break;
22112
23076
  case 'reference':
22113
23077
  helpText.textContent = 'Link to content from other collections';
22114
23078
  break;
@@ -22249,7 +23213,7 @@ function renderCollectionFormPage(data) {
22249
23213
  const fieldNameInput = document.getElementById('modal-field-name');
22250
23214
 
22251
23215
  // Show/hide options based on field type
22252
- if (['select', 'radio', 'media', 'richtext', 'guid', 'reference'].includes(this.value)) {
23216
+ if (['select', 'radio', 'media', 'richtext', 'markdown', 'guid', 'reference'].includes(this.value)) {
22253
23217
  optionsContainer.classList.remove('hidden');
22254
23218
 
22255
23219
  // Set default options and help text based on type
@@ -22270,6 +23234,10 @@ function renderCollectionFormPage(data) {
22270
23234
  fieldOptions.value = '{"toolbar": "full", "height": 400}';
22271
23235
  helpText.textContent = 'Full-featured WYSIWYG text editor with formatting options';
22272
23236
  break;
23237
+ case 'markdown':
23238
+ fieldOptions.value = '{"toolbar": "full", "height": 400}';
23239
+ helpText.textContent = 'Markdown editor with live preview powered by the EasyMDE plugin';
23240
+ break;
22273
23241
  case 'reference':
22274
23242
  fieldOptions.value = '{"collection": ["pages", "posts"]}';
22275
23243
  helpText.textContent = 'Link to content from other collections';
@@ -22345,6 +23313,9 @@ function renderCollectionFormPage(data) {
22345
23313
  // src/routes/admin-collections.ts
22346
23314
  var adminCollectionsRoutes = new Hono();
22347
23315
  adminCollectionsRoutes.use("*", requireAuth());
23316
+ adminCollectionsRoutes.post("*", requireRole(["admin"]));
23317
+ adminCollectionsRoutes.put("*", requireRole(["admin"]));
23318
+ adminCollectionsRoutes.delete("*", requireRole(["admin"]));
22348
23319
  adminCollectionsRoutes.get("/", async (c) => {
22349
23320
  try {
22350
23321
  const user = c.get("user");
@@ -22830,11 +23801,12 @@ adminCollectionsRoutes.post("/:id/fields", async (c) => {
22830
23801
  searchable: isSearchable,
22831
23802
  ...parsedOptions
22832
23803
  };
22833
- if (fieldType === "richtext") {
23804
+ const normalizedFieldType = normalizeFieldType(fieldType);
23805
+ if (normalizedFieldType === "richtext") {
22834
23806
  fieldConfig.format = "richtext";
22835
- } else if (fieldType === "date") {
23807
+ } else if (normalizedFieldType === "date") {
22836
23808
  fieldConfig.format = "date-time";
22837
- } else if (fieldType === "select") {
23809
+ } else if (normalizedFieldType === "select") {
22838
23810
  fieldConfig.enum = parsedOptions.options || [];
22839
23811
  } else if (fieldType === "radio") {
22840
23812
  fieldConfig.type = "radio";
@@ -22843,20 +23815,14 @@ adminCollectionsRoutes.post("/:id/fields", async (c) => {
22843
23815
  }
22844
23816
  } else if (fieldType === "media") {
22845
23817
  fieldConfig.format = "media";
22846
- } else if (fieldType === "slug") {
23818
+ } else if (normalizedFieldType === "slug") {
22847
23819
  fieldConfig.type = "slug";
22848
23820
  fieldConfig.format = "slug";
22849
- } else if (fieldType === "quill") {
23821
+ } else if (normalizedFieldType === "quill") {
22850
23822
  fieldConfig.type = "quill";
22851
- } else if (fieldType === "mdxeditor") {
22852
- fieldConfig.type = "mdxeditor";
22853
- } else if (fieldType === "tinymce") {
22854
- fieldConfig.type = "tinymce";
22855
- } else if (fieldType === "easymde") {
22856
- fieldConfig.type = "easymde";
22857
- } else if (fieldType === "markdown") {
23823
+ } else if (normalizedFieldType === "markdown") {
22858
23824
  fieldConfig.type = "markdown";
22859
- } else if (fieldType === "reference") {
23825
+ } else if (normalizedFieldType === "reference") {
22860
23826
  fieldConfig.type = "reference";
22861
23827
  }
22862
23828
  schema.properties[fieldName] = fieldConfig;
@@ -27461,6 +28427,33 @@ var public_forms_default = publicFormsRoutes;
27461
28427
 
27462
28428
  // src/templates/pages/admin-api-reference.template.ts
27463
28429
  init_admin_layout_catalyst_template();
28430
+ function renderAuthBadge(auth) {
28431
+ if (auth === true) {
28432
+ return `
28433
+ <span class="shrink-0 inline-flex items-center gap-x-1 rounded-md bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-700/10 dark:ring-amber-400/20">
28434
+ <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
28435
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
28436
+ </svg>
28437
+ Auth
28438
+ </span>`;
28439
+ }
28440
+ if (auth === false) {
28441
+ return `
28442
+ <span class="shrink-0 inline-flex items-center gap-x-1 rounded-md bg-lime-50 dark:bg-lime-500/10 px-2 py-1 text-xs font-medium text-lime-700 dark:text-lime-300 ring-1 ring-inset ring-lime-700/10 dark:ring-lime-400/20">
28443
+ <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
28444
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
28445
+ </svg>
28446
+ Public
28447
+ </span>`;
28448
+ }
28449
+ return `
28450
+ <span class="shrink-0 inline-flex items-center gap-x-1 rounded-md bg-zinc-50 dark:bg-zinc-500/10 px-2 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 ring-1 ring-inset ring-zinc-500/10 dark:ring-zinc-400/20">
28451
+ <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
28452
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
28453
+ </svg>
28454
+ Unknown
28455
+ </span>`;
28456
+ }
27464
28457
  function renderAPIReferencePage(data) {
27465
28458
  const endpointsByCategory = data.endpoints.reduce((acc, endpoint) => {
27466
28459
  if (!acc[endpoint.category]) {
@@ -27469,40 +28462,18 @@ function renderAPIReferencePage(data) {
27469
28462
  acc[endpoint.category].push(endpoint);
27470
28463
  return acc;
27471
28464
  }, {});
27472
- const categoryInfo = {
27473
- "Auth": {
27474
- title: "Authentication",
27475
- description: "User authentication and authorization endpoints",
27476
- icon: "\u{1F510}"
27477
- },
27478
- "Content": {
27479
- title: "Content Management",
27480
- description: "Content creation, retrieval, and management",
27481
- icon: "\u{1F4DD}"
27482
- },
27483
- "Media": {
27484
- title: "Media Management",
27485
- description: "File upload, storage, and media operations",
27486
- icon: "\u{1F5BC}\uFE0F"
27487
- },
27488
- "Admin": {
27489
- title: "Admin Interface",
27490
- description: "Administrative panel and management features",
27491
- icon: "\u2699\uFE0F"
27492
- },
27493
- "System": {
27494
- title: "System",
27495
- description: "Health checks and system information",
27496
- icon: "\u{1F527}"
27497
- }
27498
- };
28465
+ const categories = Object.keys(endpointsByCategory);
28466
+ const totalEndpoints = data.endpoints.length;
28467
+ const publicEndpoints = data.endpoints.filter((e) => e.authentication === false).length;
28468
+ const protectedEndpoints = data.endpoints.filter((e) => e.authentication === true).length;
28469
+ const undocumentedCount = data.endpoints.filter((e) => e.documented === false).length;
27499
28470
  const pageContent = `
27500
28471
  <div class="space-y-6">
27501
28472
  <!-- Header -->
27502
28473
  <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
27503
28474
  <div>
27504
28475
  <h1 class="text-2xl/8 font-semibold text-zinc-950 dark:text-white sm:text-xl/8">API Reference</h1>
27505
- <p class="mt-2 text-sm/6 text-zinc-500 dark:text-zinc-400">Complete documentation of all available API endpoints</p>
28476
+ <p class="mt-2 text-sm/6 text-zinc-500 dark:text-zinc-400">Auto-discovered documentation of all registered API endpoints</p>
27506
28477
  </div>
27507
28478
  <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
27508
28479
  <a href="/api" target="_blank" class="inline-flex items-center justify-center gap-x-1.5 rounded-lg bg-zinc-950 dark:bg-white px-3.5 py-2.5 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors shadow-sm">
@@ -27515,29 +28486,35 @@ function renderAPIReferencePage(data) {
27515
28486
  </div>
27516
28487
 
27517
28488
  <!-- Stats -->
27518
- <dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
28489
+ <dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
27519
28490
  <div class="rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 px-6 py-5">
27520
28491
  <dt class="text-sm/6 font-medium text-zinc-500 dark:text-zinc-400">Total Endpoints</dt>
27521
28492
  <dd class="mt-2 flex items-baseline gap-x-2">
27522
- <span class="text-4xl font-semibold tracking-tight text-zinc-950 dark:text-white">${data.endpoints.length}</span>
28493
+ <span class="text-4xl font-semibold tracking-tight text-zinc-950 dark:text-white">${totalEndpoints}</span>
27523
28494
  </dd>
27524
28495
  </div>
27525
28496
  <div class="rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 px-6 py-5">
27526
28497
  <dt class="text-sm/6 font-medium text-zinc-500 dark:text-zinc-400">Public Endpoints</dt>
27527
28498
  <dd class="mt-2 flex items-baseline gap-x-2">
27528
- <span class="text-4xl font-semibold tracking-tight text-lime-600 dark:text-lime-400">${data.endpoints.filter((e) => !e.authentication).length}</span>
28499
+ <span class="text-4xl font-semibold tracking-tight text-lime-600 dark:text-lime-400">${publicEndpoints}</span>
27529
28500
  </dd>
27530
28501
  </div>
27531
28502
  <div class="rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 px-6 py-5">
27532
28503
  <dt class="text-sm/6 font-medium text-zinc-500 dark:text-zinc-400">Protected Endpoints</dt>
27533
28504
  <dd class="mt-2 flex items-baseline gap-x-2">
27534
- <span class="text-4xl font-semibold tracking-tight text-amber-600 dark:text-amber-400">${data.endpoints.filter((e) => e.authentication).length}</span>
28505
+ <span class="text-4xl font-semibold tracking-tight text-amber-600 dark:text-amber-400">${protectedEndpoints}</span>
27535
28506
  </dd>
27536
28507
  </div>
27537
28508
  <div class="rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 px-6 py-5">
27538
28509
  <dt class="text-sm/6 font-medium text-zinc-500 dark:text-zinc-400">Categories</dt>
27539
28510
  <dd class="mt-2 flex items-baseline gap-x-2">
27540
- <span class="text-4xl font-semibold tracking-tight text-cyan-600 dark:text-cyan-400">${Object.keys(endpointsByCategory).length}</span>
28511
+ <span class="text-4xl font-semibold tracking-tight text-cyan-600 dark:text-cyan-400">${categories.length}</span>
28512
+ </dd>
28513
+ </div>
28514
+ <div class="rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 px-6 py-5">
28515
+ <dt class="text-sm/6 font-medium text-zinc-500 dark:text-zinc-400">Undocumented</dt>
28516
+ <dd class="mt-2 flex items-baseline gap-x-2">
28517
+ <span class="text-4xl font-semibold tracking-tight ${undocumentedCount > 0 ? "text-zinc-400 dark:text-zinc-500" : "text-lime-600 dark:text-lime-400"}">${undocumentedCount}</span>
27541
28518
  </dd>
27542
28519
  </div>
27543
28520
  </dl>
@@ -27589,9 +28566,11 @@ function renderAPIReferencePage(data) {
27589
28566
  class="col-start-1 row-start-1 w-full appearance-none rounded-lg bg-white dark:bg-zinc-800 py-2 pl-3 pr-8 text-sm text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-zinc-950/10 dark:outline-white/10 *:bg-white dark:*:bg-zinc-800 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-zinc-950 dark:focus:outline-white min-w-[200px]"
27590
28567
  >
27591
28568
  <option value="">All Categories</option>
27592
- ${Object.keys(categoryInfo).map((category) => `
27593
- <option value="${category}">${categoryInfo[category].title}</option>
27594
- `).join("")}
28569
+ ${categories.map((category) => {
28570
+ const info = CATEGORY_INFO[category];
28571
+ const title = info ? info.title : category;
28572
+ return `<option value="${category}">${title}</option>`;
28573
+ }).join("\n ")}
27595
28574
  </select>
27596
28575
  <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-zinc-500 dark:text-zinc-400 sm:size-4">
27597
28576
  <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
@@ -27605,7 +28584,7 @@ function renderAPIReferencePage(data) {
27605
28584
  <!-- API Categories -->
27606
28585
  <div class="space-y-6">
27607
28586
  ${Object.entries(endpointsByCategory).map(([category, endpoints]) => {
27608
- const info = categoryInfo[category] || { title: category, description: "", icon: "\u{1F4CB}" };
28587
+ const info = CATEGORY_INFO[category] || { title: category, description: "", icon: "&#x1f4cb;" };
27609
28588
  return `
27610
28589
  <div class="api-category" data-category="${category}">
27611
28590
  <div class="rounded-lg bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 overflow-hidden">
@@ -27639,23 +28618,14 @@ function renderAPIReferencePage(data) {
27639
28618
  <div class="flex-1 min-w-0">
27640
28619
  <div class="flex items-center gap-x-2 mb-2">
27641
28620
  <code class="text-zinc-950 dark:text-white text-sm font-mono font-medium break-all">${endpoint.path}</code>
27642
- ${endpoint.authentication ? `
27643
- <span class="shrink-0 inline-flex items-center gap-x-1 rounded-md bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-700/10 dark:ring-amber-400/20">
27644
- <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
27645
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
27646
- </svg>
27647
- Auth
27648
- </span>
27649
- ` : `
27650
- <span class="shrink-0 inline-flex items-center gap-x-1 rounded-md bg-lime-50 dark:bg-lime-500/10 px-2 py-1 text-xs font-medium text-lime-700 dark:text-lime-300 ring-1 ring-inset ring-lime-700/10 dark:ring-lime-400/20">
27651
- <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
27652
- <path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
27653
- </svg>
27654
- Public
28621
+ ${renderAuthBadge(endpoint.authentication)}
28622
+ ${endpoint.documented === false ? `
28623
+ <span class="shrink-0 inline-flex items-center rounded-md bg-zinc-50 dark:bg-zinc-800 px-2 py-1 text-xs font-medium text-zinc-400 dark:text-zinc-500 ring-1 ring-inset ring-zinc-200 dark:ring-zinc-700">
28624
+ Auto-discovered
27655
28625
  </span>
27656
- `}
28626
+ ` : ""}
27657
28627
  </div>
27658
- <p class="text-zinc-600 dark:text-zinc-400 text-sm leading-6">${endpoint.description}</p>
28628
+ <p class="text-zinc-600 dark:text-zinc-400 text-sm leading-6">${endpoint.description || '<em class="text-zinc-400 dark:text-zinc-500">No description available</em>'}</p>
27659
28629
  </div>
27660
28630
  </div>
27661
28631
  </div>
@@ -27733,8 +28703,8 @@ function renderAPIReferencePage(data) {
27733
28703
  const path = endpoint.dataset.path.toLowerCase();
27734
28704
  const description = endpoint.dataset.description.toLowerCase();
27735
28705
 
27736
- const matchesSearch = !searchTerm ||
27737
- path.includes(searchTerm) ||
28706
+ const matchesSearch = !searchTerm ||
28707
+ path.includes(searchTerm) ||
27738
28708
  description.includes(searchTerm);
27739
28709
  const matchesMethod = !selectedMethod || method === selectedMethod;
27740
28710
 
@@ -27794,207 +28764,13 @@ function renderAPIReferencePage(data) {
27794
28764
  var VERSION2 = getCoreVersion();
27795
28765
  var router2 = new Hono();
27796
28766
  router2.use("*", requireAuth());
27797
- var apiEndpoints = [
27798
- // Auth endpoints
27799
- {
27800
- method: "POST",
27801
- path: "/auth/login",
27802
- description: "Authenticate user with email and password",
27803
- authentication: false,
27804
- category: "Auth"
27805
- },
27806
- {
27807
- method: "POST",
27808
- path: "/auth/register",
27809
- description: "Register a new user account",
27810
- authentication: false,
27811
- category: "Auth"
27812
- },
27813
- {
27814
- method: "POST",
27815
- path: "/auth/logout",
27816
- description: "Log out the current user and invalidate session",
27817
- authentication: true,
27818
- category: "Auth"
27819
- },
27820
- {
27821
- method: "GET",
27822
- path: "/auth/me",
27823
- description: "Get current authenticated user information",
27824
- authentication: true,
27825
- category: "Auth"
27826
- },
27827
- {
27828
- method: "POST",
27829
- path: "/auth/refresh",
27830
- description: "Refresh authentication token",
27831
- authentication: true,
27832
- category: "Auth"
27833
- },
27834
- // Content endpoints
27835
- {
27836
- method: "GET",
27837
- path: "/api/collections",
27838
- description: "List all available collections",
27839
- authentication: false,
27840
- category: "Content"
27841
- },
27842
- {
27843
- method: "GET",
27844
- path: "/api/collections/:collection/content",
27845
- description: "Get all content items from a specific collection",
27846
- authentication: false,
27847
- category: "Content"
27848
- },
27849
- {
27850
- method: "GET",
27851
- path: "/api/content/:id",
27852
- description: "Get a specific content item by ID",
27853
- authentication: false,
27854
- category: "Content"
27855
- },
27856
- {
27857
- method: "POST",
27858
- path: "/api/content",
27859
- description: "Create a new content item",
27860
- authentication: true,
27861
- category: "Content"
27862
- },
27863
- {
27864
- method: "PUT",
27865
- path: "/api/content/:id",
27866
- description: "Update an existing content item",
27867
- authentication: true,
27868
- category: "Content"
27869
- },
27870
- {
27871
- method: "DELETE",
27872
- path: "/api/content/:id",
27873
- description: "Delete a content item",
27874
- authentication: true,
27875
- category: "Content"
27876
- },
27877
- // Media endpoints
27878
- {
27879
- method: "GET",
27880
- path: "/api/media",
27881
- description: "List all media files with pagination",
27882
- authentication: false,
27883
- category: "Media"
27884
- },
27885
- {
27886
- method: "GET",
27887
- path: "/api/media/:id",
27888
- description: "Get a specific media file by ID",
27889
- authentication: false,
27890
- category: "Media"
27891
- },
27892
- {
27893
- method: "POST",
27894
- path: "/api/media/upload",
27895
- description: "Upload a new media file to R2 storage",
27896
- authentication: true,
27897
- category: "Media"
27898
- },
27899
- {
27900
- method: "DELETE",
27901
- path: "/api/media/:id",
27902
- description: "Delete a media file from storage",
27903
- authentication: true,
27904
- category: "Media"
27905
- },
27906
- // Admin endpoints
27907
- {
27908
- method: "GET",
27909
- path: "/admin/api/stats",
27910
- description: "Get dashboard statistics (collections, content, media, users)",
27911
- authentication: true,
27912
- category: "Admin"
27913
- },
27914
- {
27915
- method: "GET",
27916
- path: "/admin/api/storage",
27917
- description: "Get storage usage information",
27918
- authentication: true,
27919
- category: "Admin"
27920
- },
27921
- {
27922
- method: "GET",
27923
- path: "/admin/api/activity",
27924
- description: "Get recent activity logs",
27925
- authentication: true,
27926
- category: "Admin"
27927
- },
27928
- {
27929
- method: "GET",
27930
- path: "/admin/api/collections",
27931
- description: "List all collections with field counts",
27932
- authentication: true,
27933
- category: "Admin"
27934
- },
27935
- {
27936
- method: "POST",
27937
- path: "/admin/api/collections",
27938
- description: "Create a new collection",
27939
- authentication: true,
27940
- category: "Admin"
27941
- },
27942
- {
27943
- method: "PATCH",
27944
- path: "/admin/api/collections/:id",
27945
- description: "Update an existing collection",
27946
- authentication: true,
27947
- category: "Admin"
27948
- },
27949
- {
27950
- method: "DELETE",
27951
- path: "/admin/api/collections/:id",
27952
- description: "Delete a collection (must be empty)",
27953
- authentication: true,
27954
- category: "Admin"
27955
- },
27956
- {
27957
- method: "GET",
27958
- path: "/admin/api/migrations/status",
27959
- description: "Get database migration status",
27960
- authentication: true,
27961
- category: "Admin"
27962
- },
27963
- {
27964
- method: "POST",
27965
- path: "/admin/api/migrations/run",
27966
- description: "Run pending database migrations",
27967
- authentication: true,
27968
- category: "Admin"
27969
- },
27970
- // System endpoints
27971
- {
27972
- method: "GET",
27973
- path: "/health",
27974
- description: "Health check endpoint for monitoring",
27975
- authentication: false,
27976
- category: "System"
27977
- },
27978
- {
27979
- method: "GET",
27980
- path: "/api/health",
27981
- description: "API health check with schema information",
27982
- authentication: false,
27983
- category: "System"
27984
- },
27985
- {
27986
- method: "GET",
27987
- path: "/api",
27988
- description: "API root - returns API information and OpenAPI spec",
27989
- authentication: false,
27990
- category: "System"
27991
- }
27992
- ];
27993
28767
  router2.get("/", async (c) => {
27994
28768
  const user = c.get("user");
27995
28769
  try {
28770
+ const app2 = getAppInstance();
28771
+ const endpoints = buildRouteList(app2);
27996
28772
  const pageData = {
27997
- endpoints: apiEndpoints,
28773
+ endpoints,
27998
28774
  user: user ? {
27999
28775
  name: user.email.split("@")[0] || user.email,
28000
28776
  email: user.email,
@@ -28050,5 +28826,5 @@ var ROUTES_INFO = {
28050
28826
  };
28051
28827
 
28052
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 };
28053
- //# sourceMappingURL=chunk-CH5UHZVM.js.map
28054
- //# sourceMappingURL=chunk-CH5UHZVM.js.map
28829
+ //# sourceMappingURL=chunk-JTNUM7JE.js.map
28830
+ //# sourceMappingURL=chunk-JTNUM7JE.js.map