@caretcms/core 0.2.0 → 0.4.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 (256) hide show
  1. package/README.md +195 -12
  2. package/dist/index.d.ts +26 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +113 -12
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +35 -3
  8. package/dist/loader.js.map +1 -1
  9. package/dist/markdown/paragraphs.d.ts +14 -0
  10. package/dist/markdown/paragraphs.d.ts.map +1 -0
  11. package/dist/markdown/paragraphs.js +61 -0
  12. package/dist/markdown/paragraphs.js.map +1 -0
  13. package/dist/markdown/remark.d.ts.map +1 -1
  14. package/dist/markdown/remark.js +1 -0
  15. package/dist/markdown/remark.js.map +1 -1
  16. package/dist/markdown/satteri.d.ts.map +1 -1
  17. package/dist/markdown/satteri.js +35 -9
  18. package/dist/markdown/satteri.js.map +1 -1
  19. package/dist/markdown/stamp.d.ts +0 -9
  20. package/dist/markdown/stamp.d.ts.map +1 -1
  21. package/dist/markdown/stamp.js +6 -0
  22. package/dist/markdown/stamp.js.map +1 -1
  23. package/dist/providers/deployment/github.d.ts +27 -0
  24. package/dist/providers/deployment/github.d.ts.map +1 -0
  25. package/dist/providers/deployment/github.js +180 -0
  26. package/dist/providers/deployment/github.js.map +1 -0
  27. package/dist/providers/deployment/simulated.d.ts +10 -0
  28. package/dist/providers/deployment/simulated.d.ts.map +1 -0
  29. package/dist/providers/deployment/simulated.js +29 -0
  30. package/dist/providers/deployment/simulated.js.map +1 -0
  31. package/dist/runtime/auth/session.d.ts +1 -0
  32. package/dist/runtime/auth/session.d.ts.map +1 -1
  33. package/dist/runtime/auth/session.js +16 -0
  34. package/dist/runtime/auth/session.js.map +1 -1
  35. package/dist/runtime/authorization.d.ts +11 -0
  36. package/dist/runtime/authorization.d.ts.map +1 -0
  37. package/dist/runtime/authorization.js +29 -0
  38. package/dist/runtime/authorization.js.map +1 -0
  39. package/dist/runtime/collection-policy.d.ts +11 -0
  40. package/dist/runtime/collection-policy.d.ts.map +1 -0
  41. package/dist/runtime/collection-policy.js +44 -0
  42. package/dist/runtime/collection-policy.js.map +1 -0
  43. package/dist/runtime/config.d.ts +3 -0
  44. package/dist/runtime/config.d.ts.map +1 -1
  45. package/dist/runtime/config.js +4 -1
  46. package/dist/runtime/config.js.map +1 -1
  47. package/dist/runtime/content-errors.d.ts +9 -0
  48. package/dist/runtime/content-errors.d.ts.map +1 -0
  49. package/dist/runtime/content-errors.js +17 -0
  50. package/dist/runtime/content-errors.js.map +1 -0
  51. package/dist/runtime/content.d.ts.map +1 -1
  52. package/dist/runtime/content.js +13 -4
  53. package/dist/runtime/content.js.map +1 -1
  54. package/dist/runtime/deployment-status.d.ts +14 -0
  55. package/dist/runtime/deployment-status.d.ts.map +1 -0
  56. package/dist/runtime/deployment-status.js +57 -0
  57. package/dist/runtime/deployment-status.js.map +1 -0
  58. package/dist/runtime/draft-state.d.ts +16 -0
  59. package/dist/runtime/draft-state.d.ts.map +1 -0
  60. package/dist/runtime/draft-state.js +29 -0
  61. package/dist/runtime/draft-state.js.map +1 -0
  62. package/dist/runtime/i18n.d.ts +97 -0
  63. package/dist/runtime/i18n.d.ts.map +1 -0
  64. package/dist/runtime/i18n.js +190 -0
  65. package/dist/runtime/i18n.js.map +1 -0
  66. package/dist/runtime/index.d.ts +1 -0
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/runtime/index.js +1 -0
  69. package/dist/runtime/index.js.map +1 -1
  70. package/dist/runtime/middleware.d.ts.map +1 -1
  71. package/dist/runtime/middleware.js +61 -4
  72. package/dist/runtime/middleware.js.map +1 -1
  73. package/dist/runtime/mutations/contracts.d.ts +12 -0
  74. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  75. package/dist/runtime/mutations/contracts.js +54 -4
  76. package/dist/runtime/mutations/contracts.js.map +1 -1
  77. package/dist/runtime/mutations/engine.d.ts +2 -0
  78. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  79. package/dist/runtime/mutations/engine.js +220 -57
  80. package/dist/runtime/mutations/engine.js.map +1 -1
  81. package/dist/runtime/providers.d.ts +4 -1
  82. package/dist/runtime/providers.d.ts.map +1 -1
  83. package/dist/runtime/providers.js +18 -1
  84. package/dist/runtime/providers.js.map +1 -1
  85. package/dist/runtime/publish-recovery.d.ts +32 -0
  86. package/dist/runtime/publish-recovery.d.ts.map +1 -0
  87. package/dist/runtime/publish-recovery.js +98 -0
  88. package/dist/runtime/publish-recovery.js.map +1 -0
  89. package/dist/runtime/publish.d.ts +10 -4
  90. package/dist/runtime/publish.d.ts.map +1 -1
  91. package/dist/runtime/publish.js +105 -63
  92. package/dist/runtime/publish.js.map +1 -1
  93. package/dist/runtime/rebuild-webhook.d.ts +4 -0
  94. package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
  95. package/dist/runtime/rebuild-webhook.js +12 -2
  96. package/dist/runtime/rebuild-webhook.js.map +1 -1
  97. package/dist/runtime/request-context.d.ts +7 -1
  98. package/dist/runtime/request-context.d.ts.map +1 -1
  99. package/dist/runtime/request-context.js +7 -1
  100. package/dist/runtime/request-context.js.map +1 -1
  101. package/dist/runtime/rewrite.d.ts.map +1 -1
  102. package/dist/runtime/rewrite.js +28 -2
  103. package/dist/runtime/rewrite.js.map +1 -1
  104. package/dist/runtime/rich-allowlist.js +1 -1
  105. package/dist/runtime/rich-allowlist.js.map +1 -1
  106. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  107. package/dist/runtime/routes/admin-entry.js +129 -21
  108. package/dist/runtime/routes/admin-entry.js.map +1 -1
  109. package/dist/runtime/routes/admin.d.ts.map +1 -1
  110. package/dist/runtime/routes/admin.js +28 -14
  111. package/dist/runtime/routes/admin.js.map +1 -1
  112. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  113. package/dist/runtime/routes/auth-login.js +4 -0
  114. package/dist/runtime/routes/auth-login.js.map +1 -1
  115. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  116. package/dist/runtime/routes/auth-logout.js +7 -2
  117. package/dist/runtime/routes/auth-logout.js.map +1 -1
  118. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  119. package/dist/runtime/routes/auth-session.js +10 -2
  120. package/dist/runtime/routes/auth-session.js.map +1 -1
  121. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  122. package/dist/runtime/routes/collection-list.js +213 -66
  123. package/dist/runtime/routes/collection-list.js.map +1 -1
  124. package/dist/runtime/routes/deployment.d.ts +5 -0
  125. package/dist/runtime/routes/deployment.d.ts.map +1 -0
  126. package/dist/runtime/routes/deployment.js +38 -0
  127. package/dist/runtime/routes/deployment.js.map +1 -0
  128. package/dist/runtime/routes/draft.d.ts.map +1 -1
  129. package/dist/runtime/routes/draft.js +10 -3
  130. package/dist/runtime/routes/draft.js.map +1 -1
  131. package/dist/runtime/routes/entries.d.ts.map +1 -1
  132. package/dist/runtime/routes/entries.js +87 -43
  133. package/dist/runtime/routes/entries.js.map +1 -1
  134. package/dist/runtime/routes/history.d.ts.map +1 -1
  135. package/dist/runtime/routes/history.js +40 -21
  136. package/dist/runtime/routes/history.js.map +1 -1
  137. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  138. package/dist/runtime/routes/mutate.js +4 -1
  139. package/dist/runtime/routes/mutate.js.map +1 -1
  140. package/dist/runtime/routes/publish.d.ts.map +1 -1
  141. package/dist/runtime/routes/publish.js +127 -33
  142. package/dist/runtime/routes/publish.js.map +1 -1
  143. package/dist/runtime/routes/schema.d.ts.map +1 -1
  144. package/dist/runtime/routes/schema.js +11 -2
  145. package/dist/runtime/routes/schema.js.map +1 -1
  146. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  147. package/dist/runtime/routes/studio-home.js +41 -20
  148. package/dist/runtime/routes/studio-home.js.map +1 -1
  149. package/dist/runtime/routes/upload.d.ts.map +1 -1
  150. package/dist/runtime/routes/upload.js +3 -0
  151. package/dist/runtime/routes/upload.js.map +1 -1
  152. package/dist/runtime/schema-registry.d.ts +8 -1
  153. package/dist/runtime/schema-registry.d.ts.map +1 -1
  154. package/dist/runtime/schema-registry.js +49 -3
  155. package/dist/runtime/schema-registry.js.map +1 -1
  156. package/dist/runtime/storage/entry-commit.d.ts +8 -0
  157. package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
  158. package/dist/runtime/storage/entry-commit.js +45 -0
  159. package/dist/runtime/storage/entry-commit.js.map +1 -0
  160. package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
  161. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  162. package/dist/runtime/storage/filesystem-adapter.js +12 -3
  163. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  164. package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
  165. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
  166. package/dist/runtime/storage/frontmatter-codec.js +156 -13
  167. package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
  168. package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
  169. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  170. package/dist/runtime/storage/in-memory-adapter.js +6 -0
  171. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  172. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  173. package/dist/runtime/storage/markdown-adapter.js +18 -20
  174. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  175. package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
  176. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  177. package/dist/runtime/storage/session-overlay-adapter.js +100 -13
  178. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  179. package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
  180. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
  181. package/dist/runtime/storage/sidecar-meta-store.js +48 -5
  182. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
  183. package/dist/runtime/themes/preset-tokens.js +4 -4
  184. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  185. package/dist/runtime/utils.d.ts.map +1 -1
  186. package/dist/runtime/utils.js +3 -4
  187. package/dist/runtime/utils.js.map +1 -1
  188. package/dist/runtime/views/studio-layout.d.ts +3 -0
  189. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  190. package/dist/runtime/views/studio-layout.js +36 -13
  191. package/dist/runtime/views/studio-layout.js.map +1 -1
  192. package/dist/schema-utils.d.ts +7 -0
  193. package/dist/schema-utils.d.ts.map +1 -1
  194. package/dist/schema-utils.js +143 -0
  195. package/dist/schema-utils.js.map +1 -1
  196. package/dist/types.d.ts +165 -1
  197. package/dist/types.d.ts.map +1 -1
  198. package/package.json +12 -3
  199. package/static/cms/admin-entry.css +144 -13
  200. package/static/cms/admin-entry.js +539 -586
  201. package/static/cms/dev-toolbar/app.js +78 -19
  202. package/static/cms/editor/config.js +35 -15
  203. package/static/cms/editor/content-map.css +8 -1
  204. package/static/cms/editor/content-map.js +62 -23
  205. package/static/cms/editor/deployment-status.js +66 -0
  206. package/static/cms/editor/draft-client.js +87 -0
  207. package/static/cms/editor/guards.js +20 -0
  208. package/static/cms/editor/helpers.js +30 -1
  209. package/static/cms/editor/highlight.js +1 -0
  210. package/static/cms/editor/image-edit.js +44 -1
  211. package/static/cms/editor/image-utils.js +23 -7
  212. package/static/cms/editor/link-follow.js +122 -41
  213. package/static/cms/editor/link-ui.css +10 -1
  214. package/static/cms/editor/linkify.js +2 -0
  215. package/static/cms/editor/md-block-edit.js +140 -37
  216. package/static/cms/editor/md-paragraphs.js +98 -0
  217. package/static/cms/editor/md-serialize.js +1 -1
  218. package/static/cms/editor/panel.js +53 -7
  219. package/static/cms/editor/preview-mode.js +44 -0
  220. package/static/cms/editor/publish-result.js +100 -0
  221. package/static/cms/editor/rich-toolbar.js +58 -15
  222. package/static/cms/editor/sanitize.js +6 -2
  223. package/static/cms/editor/save-queue.js +75 -17
  224. package/static/cms/editor/section-controls/activation.js +3 -0
  225. package/static/cms/editor/section-controls/api.js +25 -7
  226. package/static/cms/editor/section-controls/index.js +60 -8
  227. package/static/cms/editor/section-controls/model.js +39 -4
  228. package/static/cms/editor/section-controls/mutations.js +15 -0
  229. package/static/cms/editor/section-controls/page-context.js +9 -0
  230. package/static/cms/editor/section-controls/pickers.js +53 -2
  231. package/static/cms/editor/section-controls/reorder.js +23 -0
  232. package/static/cms/editor/section-controls/selection.js +6 -0
  233. package/static/cms/editor/section-controls/spacing-drag.js +16 -0
  234. package/static/cms/editor/section-controls/ui-elements.js +7 -0
  235. package/static/cms/editor/section-controls/utils.js +7 -0
  236. package/static/cms/editor/section-controls.css +18 -3
  237. package/static/cms/editor/security.js +13 -2
  238. package/static/cms/editor/stega-hydrate.js +17 -4
  239. package/static/cms/editor/sync.js +475 -56
  240. package/static/cms/editor/text-edit.js +37 -3
  241. package/static/cms/editor/text-link-interactions.js +59 -17
  242. package/static/cms/editor/toast.js +23 -9
  243. package/static/cms/editor/tokens-and-text.css +9 -0
  244. package/static/cms/editor/toolbar.css +5 -0
  245. package/static/cms/editor/toolbar.js +91 -101
  246. package/static/cms/editor.css +1 -1
  247. package/static/cms/editor.js +1 -0
  248. package/static/cms/studio/entry-loader.js +102 -0
  249. package/static/cms/studio/field-model.js +110 -0
  250. package/static/cms/studio/fields.js +319 -0
  251. package/static/cms/studio/history-client.js +74 -0
  252. package/static/cms/studio/mutation-client.js +74 -0
  253. package/static/cms/studio/sync-client.js +107 -0
  254. package/static/cms/studio/upload-client.js +109 -0
  255. package/static/cms/studio-tokens.css +4 -4
  256. package/static/cms/studio.css +24 -0
@@ -1,3 +1,15 @@
1
+ import { deepClone, templateFromSchema, generatedId, friendlyFileTitle, singularItemLabel, getTitle, humanizeKey, fieldIdFromPath, getNestedValue, setNestedValue } from './studio/field-model.js';
2
+ import { createFieldGroup, createFieldRenderer } from './studio/fields.js';
3
+ import { createStudioMutationClient } from './studio/mutation-client.js';
4
+ import { createStudioHistoryClient } from './studio/history-client.js';
5
+ import { createStudioUploadClient, isSupportedImage } from './studio/upload-client.js';
6
+ import { createStudioEntryLoader } from './studio/entry-loader.js';
7
+ import { createStudioSync } from './studio/sync-client.js';
8
+
9
+ /** @typedef {import('../../src/schema-utils.js').JsonSchemaNode} Schema */
10
+ /** @typedef {{ path: string, message: string }} ValidationIssue */
11
+ /** @typedef {{ apiBasePath?: string, mountPath?: string, collection?: string, id?: string, isNew?: boolean, initializeIfMissing?: boolean, saveTarget?: string, previewPath?: string, editable?: boolean, messages?: Record<string, string> }} EntryConfig */
12
+
1
13
  /**
2
14
  * Studio entry editor — runtime.
3
15
  *
@@ -16,6 +28,11 @@
16
28
  * POST ${api}/upload FormData(file)
17
29
  * GET ${api}/history?collection=&id=
18
30
  * POST ${api}/history { collection, id, ts } // restore snapshot
31
+ *
32
+ * Successful mutations are announced on the same-origin
33
+ * `caretcms:content` BroadcastChannel so an open site preview can refresh
34
+ * automatically. Embedded Studio panels keep using postMessage for their
35
+ * faster in-page field patching.
19
36
  */
20
37
  (function () {
21
38
  "use strict";
@@ -24,290 +41,262 @@
24
41
  var pageRoot = document.getElementById("cms-entry-page");
25
42
  if (!configEl || !pageRoot) return;
26
43
 
44
+ /** @type {EntryConfig} */
27
45
  var CFG;
28
46
  try {
29
- CFG = JSON.parse(configEl.textContent || "{}");
47
+ CFG = /** @type {EntryConfig} */ (JSON.parse(configEl.textContent || "{}"));
30
48
  } catch (e) {
31
49
  console.error("[caret] invalid entry config", e);
32
50
  return;
33
51
  }
34
52
 
35
- var API = CFG.apiBasePath;
36
- var MOUNT = CFG.mountPath;
37
- var COLLECTION = CFG.collection;
38
- var ID = CFG.id;
53
+ var API = typeof CFG.apiBasePath === "string" ? CFG.apiBasePath : "";
54
+ var MOUNT = typeof CFG.mountPath === "string" ? CFG.mountPath : "";
55
+ var COLLECTION = typeof CFG.collection === "string" ? CFG.collection : "";
56
+ var ID = typeof CFG.id === "string" ? CFG.id : "";
57
+ var IS_NEW = CFG.isNew === true;
58
+ var INITIALIZE_IF_MISSING = CFG.initializeIfMissing === true;
59
+ var SAVE_TARGET = CFG.saveTarget === "preview" ? "preview" : "live";
60
+ var PREVIEW_PATH = typeof CFG.previewPath === "string" ? CFG.previewPath : null;
61
+ /** @type {Record<string, string>} */
62
+ var MSG = CFG.messages || {};
63
+ /** @param {string} key @param {string} fallback */
64
+ function msg(key, fallback) { return typeof MSG[key] === "string" ? MSG[key] : fallback; }
65
+ /** @param {unknown} value */
66
+ function htmlEscape(value) {
67
+ /** @type {Record<string, string>} */
68
+ var entities = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
69
+ return String(value).replace(/[&<>"']/g, function (char) {
70
+ return entities[char] || char;
71
+ });
72
+ }
39
73
  if (!API || !COLLECTION || !ID) return;
74
+ var mutationClient = createStudioMutationClient({ apiBasePath: API });
75
+ var historyClient = createStudioHistoryClient({ apiBasePath: API });
76
+ var uploadClient = createStudioUploadClient({ apiBasePath: API, onUnauthorized: loginRedirect });
77
+ var entryLoader = createStudioEntryLoader({ apiBasePath: API });
40
78
 
41
79
  /* ─── State ─────────────────────────────────────────────────────── */
80
+ /** @type {Record<string, unknown> | null} */
42
81
  var entryData = null;
82
+ /** @type {Schema | null} */
43
83
  var entrySchema = null;
84
+ /** @type {number | undefined} */
44
85
  var entryRevision = undefined;
45
86
  var originalJson = "";
46
87
  var saving = false;
47
88
  var dirtyTimer = 0;
89
+ var linkedSelectionTimer = 0;
90
+ /** @type {Record<string, unknown> | null} */
91
+ var pendingRemoteSelection = null;
92
+ /** @type {string | null} */
93
+ var publicationFieldName = null;
94
+ var studioSync = createStudioSync({
95
+ collection: COLLECTION,
96
+ id: ID,
97
+ previewPath: PREVIEW_PATH,
98
+ origin: window.location.origin,
99
+ isEmbedded: window.parent !== window,
100
+ studioPath: function () { return window.location.pathname + window.location.search; },
101
+ postToParent: function (message) { window.parent.postMessage(message, window.location.origin); },
102
+ onSelection: applyRemoteSelection,
103
+ addWindowMessageListener: function (listener) { window.addEventListener("message", listener); },
104
+ addPagehideListener: function (listener) { window.addEventListener("pagehide", listener, { once: true }); },
105
+ openChannel: "BroadcastChannel" in window ? function (name) { return new BroadcastChannel(name); } : undefined,
106
+ });
48
107
 
49
108
  /* ─── DOM refs ──────────────────────────────────────────────────── */
50
- var loadingEl = document.getElementById("loading");
51
- var notFoundEl = document.getElementById("not-found");
52
- var editorEl = document.getElementById("editor");
53
- var fieldsEl = document.getElementById("fields");
54
- var titleEl = document.getElementById("entry-title");
55
- var statusEl = document.getElementById("status-msg");
56
- var saveBtn = document.getElementById("btn-save");
57
- var deleteBtn = document.getElementById("btn-delete");
58
- var historyBtn = document.getElementById("btn-history");
59
- var historyPanel = document.getElementById("history-panel");
60
- var historyList = document.getElementById("history-list");
61
- var historyCloseBtn = document.getElementById("btn-history-close");
62
- var deleteDialog = document.getElementById("delete-dialog");
63
- var deleteNameEl = document.getElementById("delete-entry-name");
64
- var deleteCancelBtn = document.getElementById("btn-delete-cancel");
65
- var deleteConfirmBtn = document.getElementById("btn-delete-confirm");
109
+ var loadingEl = /** @type {HTMLElement} */ (document.getElementById("loading"));
110
+ var notFoundEl = /** @type {HTMLElement} */ (document.getElementById("not-found"));
111
+ var editorEl = /** @type {HTMLElement} */ (document.getElementById("editor"));
112
+ var fieldsEl = /** @type {HTMLElement} */ (document.getElementById("fields"));
113
+ var titleEl = /** @type {HTMLElement} */ (document.getElementById("entry-title"));
114
+ var statusEl = /** @type {HTMLElement} */ (document.getElementById("status-msg"));
115
+ var validationWarningEl = /** @type {HTMLElement | null} */ (document.getElementById("validation-warning"));
116
+ var saveBtn = /** @type {HTMLButtonElement} */ (document.getElementById("btn-save"));
117
+ var deleteBtn = /** @type {HTMLButtonElement | null} */ (document.getElementById("btn-delete"));
118
+ var historyBtn = /** @type {HTMLButtonElement} */ (document.getElementById("btn-history"));
119
+ var historyPanel = /** @type {HTMLElement} */ (document.getElementById("history-panel"));
120
+ var historyList = /** @type {HTMLElement} */ (document.getElementById("history-list"));
121
+ var historyCloseBtn = /** @type {HTMLButtonElement} */ (document.getElementById("btn-history-close"));
122
+ var deleteDialog = /** @type {HTMLElement} */ (document.getElementById("delete-dialog"));
123
+ var deleteNameEl = /** @type {HTMLElement} */ (document.getElementById("delete-entry-name"));
124
+ var deleteCancelBtn = /** @type {HTMLButtonElement} */ (document.getElementById("btn-delete-cancel"));
125
+ var deleteConfirmBtn = /** @type {HTMLButtonElement} */ (document.getElementById("btn-delete-confirm"));
126
+ /** @type {HTMLElement | null} */
127
+ var deleteRestoreFocus = null;
66
128
 
67
129
  /* ─── Helpers ───────────────────────────────────────────────────── */
68
- function deepClone(v) { return JSON.parse(JSON.stringify(v)); }
69
-
70
- function getTitle(data) {
71
- if (!data) return "Untitled";
72
- var keys = ["name", "title", "question", "company_name", "headline", "label"];
73
- for (var i = 0; i < keys.length; i++) {
74
- if (typeof data[keys[i]] === "string" && data[keys[i]]) return data[keys[i]];
75
- }
76
- return "Untitled";
77
- }
78
-
79
- function humanizeKey(key) {
80
- return key.replace(/_/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
81
- }
82
-
83
- // Coerce a text-input value back to the field's declared (or original) type so
84
- // editing an object-array's numeric/boolean/nested field doesn't turn it into a
85
- // string. Unparseable input returns the raw string (server validation reports it).
86
- function coerceScalarValue(raw, propSchema, originalVal) {
87
- var type = (propSchema && propSchema.type) ||
88
- (typeof originalVal === "number" ? "number" :
89
- typeof originalVal === "boolean" ? "boolean" :
90
- (originalVal && typeof originalVal === "object" ? "object" : "string"));
91
- if (type === "number" || type === "integer") {
92
- if (raw.trim() === "") return null;
93
- var n = Number(raw);
94
- return Number.isFinite(n) ? n : raw;
95
- }
96
- if (type === "boolean") {
97
- var t = raw.trim().toLowerCase();
98
- if (t === "true") return true;
99
- if (t === "false") return false;
100
- return raw;
101
- }
102
- if (type === "object" || type === "array") {
103
- try { return JSON.parse(raw); } catch (e) { return raw; }
104
- }
105
- return raw;
106
- }
107
130
 
108
131
  function isDirty() {
109
132
  return entryData !== null && JSON.stringify(entryData) !== originalJson;
110
133
  }
111
134
 
112
- function updateSaveButton() {
135
+ /** @param {boolean} [preserveStatus] */
136
+ function updateSaveButton(preserveStatus) {
113
137
  var dirty = isDirty();
114
- saveBtn.disabled = !dirty || saving;
138
+ saveBtn.disabled = CFG.editable === false || !dirty || saving;
115
139
  saveBtn.classList.remove("studio-save-dirty", "studio-save-clean", "studio-save-saving");
116
140
  saveBtn.classList.add(saving ? "studio-save-saving" : (dirty ? "studio-save-dirty" : "studio-save-clean"));
117
- saveBtn.textContent = saving ? "Saving…" : "Save";
141
+ saveBtn.textContent = saving ? msg("common.saving", "Saving…") : (SAVE_TARGET === "preview" ? msg("entry.saveDraft", "Save draft") : msg("entry.saveLive", "Save live"));
118
142
  titleEl.textContent = getTitle(entryData);
119
- }
120
-
121
- function showStatus(type, msg) {
122
- statusEl.textContent = msg;
123
- statusEl.hidden = false;
124
- statusEl.style.color = type === "saved" ? "var(--studio-green)" : "var(--studio-red)";
125
- if (type === "saved") {
126
- setTimeout(function () { statusEl.hidden = true; }, 2000);
143
+ if (entryData !== null && !preserveStatus) {
144
+ if (saving) showStatus("saving", msg("entry.savingChanges", "Saving changes…"));
145
+ else if (dirty) showStatus("dirty", msg("entry.unsaved", "Unsaved changes"));
146
+ else showStatus("saved", msg("entry.allSaved", "All changes saved"));
127
147
  }
128
148
  }
129
149
 
130
- function setNestedValue(obj, path, value) {
131
- var keys = path.split(".");
132
- var cur = obj;
133
- for (var i = 0; i < keys.length - 1; i++) {
134
- var k = keys[i];
135
- if (typeof cur[k] !== "object" || cur[k] === null) {
136
- cur[k] = /^\d+$/.test(keys[i + 1]) ? [] : {};
137
- }
138
- cur = cur[k];
139
- }
140
- cur[keys[keys.length - 1]] = value;
141
- }
142
-
143
- function getNestedValue(obj, path) {
144
- return path.split(".").reduce(function (o, k) { return o == null ? undefined : o[k]; }, obj);
150
+ /** @param {string} type @param {string} message */
151
+ function showStatus(type, message) {
152
+ statusEl.textContent = message;
153
+ statusEl.hidden = false;
154
+ statusEl.dataset.status = type;
155
+ statusEl.style.color = type === "saved"
156
+ ? "var(--studio-green)"
157
+ : (type === "dirty" || type === "saving" ? "var(--studio-text-muted)" : "var(--studio-red)");
145
158
  }
146
159
 
160
+ /** @param {string} path @param {unknown} value */
147
161
  function updateField(path, value) {
162
+ if (!entryData) return;
148
163
  setNestedValue(entryData, path, value);
149
164
  if (!saving) {
150
- saveBtn.disabled = false;
165
+ saveBtn.disabled = CFG.editable === false;
151
166
  saveBtn.classList.remove("studio-save-clean", "studio-save-saving");
152
167
  saveBtn.classList.add("studio-save-dirty");
153
168
  }
154
169
  clearTimeout(dirtyTimer);
155
170
  dirtyTimer = setTimeout(updateSaveButton, 300);
171
+ studioSync.queuePreview(entryData);
156
172
  }
157
173
 
158
174
  function loginRedirect() {
159
175
  window.location.href = MOUNT + "?redirect=" + encodeURIComponent(window.location.pathname);
160
176
  }
161
177
 
178
+ /** @param {Response} res */
162
179
  function handleAuth(res) {
163
180
  if (res.status === 401) { loginRedirect(); return true; }
164
181
  return false;
165
182
  }
166
183
 
167
- /* ─── Image compression + upload ────────────────────────────────── */
168
- function compressImage(file, maxWidth, quality) {
169
- maxWidth = maxWidth || 1600;
170
- quality = quality || 0.82;
171
- return Promise.resolve().then(function () {
172
- if (file.size < 200000 && file.type === "image/webp") return file;
173
- return createImageBitmap(file).then(function (bitmap) {
174
- var width = bitmap.width;
175
- var height = bitmap.height;
176
- var needsResize = width > maxWidth;
177
- if (file.type === "image/png" && !needsResize) {
178
- bitmap.close();
179
- return file;
180
- }
181
- if (needsResize) {
182
- height = Math.round((height * maxWidth) / width);
183
- width = maxWidth;
184
- }
185
- var canvas = document.createElement("canvas");
186
- canvas.width = width;
187
- canvas.height = height;
188
- canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height);
189
- bitmap.close();
190
- var isPng = file.type === "image/png";
191
- var outType = isPng ? "image/png" : "image/webp";
192
- return new Promise(function (resolve) {
193
- canvas.toBlob(function (blob) {
194
- if (!blob || blob.size >= file.size) return resolve(file);
195
- var ext = isPng ? ".png" : ".webp";
196
- resolve(new File([blob], file.name.replace(/\.[^.]+$/, ext), { type: outType }));
197
- }, outType, isPng ? undefined : quality);
198
- });
199
- });
184
+ /** @param {string} path @param {File} file @param {{ width: number, height: number } | null} dimensions */
185
+ function populateImageObject(path, file, dimensions) {
186
+ var parts = path.split(".");
187
+ if (parts.length < 2 || parts[parts.length - 1] !== "src") return;
188
+ var parentPath = parts.slice(0, -1).join(".");
189
+ var current = getNestedValue(entryData, parentPath);
190
+ if (!current || typeof current !== "object" || Array.isArray(current)) return;
191
+
192
+ var next = /** @type {Record<string, unknown>} */ (deepClone(current));
193
+ var title = friendlyFileTitle(file.name);
194
+ if (Object.prototype.hasOwnProperty.call(next, "id") && !next.id) {
195
+ next.id = generatedId(parentPath, Number(parts[parts.length - 2]) || 0);
196
+ }
197
+ if (Object.prototype.hasOwnProperty.call(next, "title") && !next.title) next.title = title;
198
+ if (Object.prototype.hasOwnProperty.call(next, "alt") && !next.alt) next.alt = title;
199
+ if (dimensions && Object.prototype.hasOwnProperty.call(next, "width")) next.width = dimensions.width;
200
+ if (dimensions && Object.prototype.hasOwnProperty.call(next, "height")) next.height = dimensions.height;
201
+ updateField(parentPath, next);
202
+
203
+ ["id", "title", "alt", "width", "height"].forEach(function (key) {
204
+ if (!Object.prototype.hasOwnProperty.call(next, key)) return;
205
+ var control = document.getElementById(fieldIdFromPath(parentPath + "." + key));
206
+ if (control && "value" in control) control.value = String(next[key]);
200
207
  });
201
208
  }
202
209
 
203
- function uploadFile(file) {
204
- return compressImage(file).then(function (compressed) {
205
- var fd = new FormData();
206
- fd.append("file", compressed);
207
- return fetch(API + "/upload", {
208
- method: "POST",
209
- body: fd,
210
- headers: { "x-caret-request": "1" },
211
- credentials: "same-origin",
212
- });
213
- }).then(function (res) {
214
- if (handleAuth(res)) throw new Error("Unauthorized");
215
- if (!res.ok) throw new Error("Upload failed");
216
- return res.json();
217
- }).then(function (json) { return json.url; });
218
- }
219
-
220
210
  /* ─── Save / Delete ─────────────────────────────────────────────── */
221
211
  function save() {
222
212
  if (!entryData || saving) return;
213
+ if (SAVE_TARGET === "live") {
214
+ var liveConfirmationKey = "caretcms:confirmed-live-save";
215
+ var confirmed = false;
216
+ try { confirmed = sessionStorage.getItem(liveConfirmationKey) === "1"; } catch (e) { /* storage may be disabled */ }
217
+ if (!confirmed && !window.confirm(msg("entry.liveConfirm", "Save these changes directly to the live site?"))) return;
218
+ try { sessionStorage.setItem(liveConfirmationKey, "1"); } catch (e) { /* confirmation still applies to this save */ }
219
+ }
223
220
  saving = true;
224
221
  updateSaveButton();
222
+ var preserveSaveStatus = false;
223
+ /** @type {{ collection: string, id: string, data: Record<string, unknown>, expectedRevision?: number }} */
225
224
  var payload = {
226
- type: "put_entry",
227
225
  collection: COLLECTION,
228
226
  id: ID,
229
227
  data: entryData,
230
228
  };
231
229
  if (typeof entryRevision === "number") payload.expectedRevision = entryRevision;
232
230
 
233
- fetch(API + "/mutate", {
234
- method: "POST",
235
- headers: { "Content-Type": "application/json", "x-caret-request": "1" },
236
- credentials: "same-origin",
237
- body: JSON.stringify(payload),
238
- }).then(function (res) {
239
- if (handleAuth(res)) return null;
240
- return res.json().catch(function () { return null; }).then(function (body) {
241
- return { ok: res.ok, status: res.status, body: body };
242
- });
243
- }).then(function (result) {
244
- if (!result) return;
245
- if (!result.ok) {
246
- if (result.body && result.body.issues) {
247
- showValidationErrors(result.body.issues);
248
- showStatus("error", "Validation failed (" + result.body.issues.length + ")");
231
+ mutationClient.save(payload).then(function (result) {
232
+ if (result.kind === "unauthorized") { loginRedirect(); return; }
233
+ if (result.kind === "validation") {
234
+ preserveSaveStatus = true;
235
+ showValidationErrors(result.issues);
236
+ showStatus("error", msg("entry.validationFailed", "Validation failed") + " (" + result.issues.length + ")");
249
237
  var firstErr = fieldsEl.querySelector(".studio-field-error");
250
238
  if (firstErr) firstErr.scrollIntoView({ behavior: "smooth", block: "center" });
251
239
  return;
252
- }
253
- if (result.status === 409 && result.body && typeof result.body.currentRevision === "number") {
240
+ }
241
+ if (result.kind === "conflict") {
242
+ preserveSaveStatus = true;
254
243
  // Your edits are still in the form; we've refreshed to the server's
255
244
  // revision, so saving again overwrites. Don't tell the user to reload
256
245
  // (that would throw their edits away).
257
- entryRevision = result.body.currentRevision;
258
- showStatus("error", "Changed elsewhere — Save again to overwrite");
246
+ entryRevision = result.currentRevision;
247
+ showStatus("error", msg("entry.changedElsewhere", "Changed elsewhere — Save again to overwrite"));
259
248
  return;
260
- }
261
- throw new Error("save failed");
262
249
  }
250
+ if (result.kind === "error") throw new Error("save failed");
263
251
  clearFieldErrors();
264
- if (result.body && typeof result.body.revision === "number") entryRevision = result.body.revision;
252
+ if (typeof result.revision === "number") entryRevision = result.revision;
265
253
  originalJson = JSON.stringify(entryData);
266
- if (window.parent !== window) {
267
- try {
268
- window.parent.postMessage(
269
- { type: "cms:saved", collection: COLLECTION, id: ID, data: deepClone(entryData) },
270
- window.location.origin,
271
- );
272
- } catch (e) { /* noop */ }
273
- }
274
- showStatus("saved", "Saved");
254
+ studioSync.announceChange("cms:saved", entryData);
255
+ showStatus("saved", msg("entry.saved", "Saved"));
275
256
  }).catch(function () {
276
- showStatus("error", "Error");
257
+ preserveSaveStatus = true;
258
+ showStatus("error", msg("common.error", "Error"));
277
259
  }).then(function () {
278
260
  saving = false;
279
- updateSaveButton();
261
+ updateSaveButton(preserveSaveStatus);
280
262
  });
281
263
  }
282
264
 
283
265
  function deleteEntry() {
284
266
  deleteNameEl.textContent = ID;
267
+ deleteRestoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : deleteBtn;
285
268
  deleteDialog.hidden = false;
269
+ deleteCancelBtn.focus();
270
+ }
271
+
272
+ function closeDeleteDialog() {
273
+ deleteDialog.hidden = true;
274
+ var restore = deleteRestoreFocus || deleteBtn;
275
+ deleteRestoreFocus = null;
276
+ if (restore instanceof HTMLElement && restore.isConnected) restore.focus();
286
277
  }
287
278
 
288
279
  function confirmDelete() {
289
280
  deleteConfirmBtn.disabled = true;
290
- deleteConfirmBtn.textContent = "Deleting…";
291
- var payload = { type: "delete_entry", collection: COLLECTION, id: ID };
281
+ deleteConfirmBtn.textContent = msg("entry.deleting", "Deleting…");
282
+ /** @type {{ collection: string, id: string, expectedRevision?: number }} */
283
+ var payload = { collection: COLLECTION, id: ID };
292
284
  if (typeof entryRevision === "number") payload.expectedRevision = entryRevision;
293
- fetch(API + "/mutate", {
294
- method: "POST",
295
- headers: { "Content-Type": "application/json", "x-caret-request": "1" },
296
- credentials: "same-origin",
297
- body: JSON.stringify(payload),
298
- }).then(function (res) {
299
- if (handleAuth(res)) return;
300
- if (!res.ok) throw new Error("delete failed");
285
+ mutationClient.remove(payload).then(function (result) {
286
+ if (result === "unauthorized") { loginRedirect(); return; }
287
+ if (result !== "deleted") throw new Error("delete failed");
288
+ studioSync.announceChange("cms:deleted", null);
301
289
  window.location.href = MOUNT + "/cms/" + encodeURIComponent(COLLECTION);
302
290
  }).catch(function () {
303
- deleteDialog.hidden = true;
291
+ closeDeleteDialog();
304
292
  deleteConfirmBtn.disabled = false;
305
- deleteConfirmBtn.textContent = "Delete";
306
- showStatus("error", "Delete failed");
293
+ deleteConfirmBtn.textContent = msg("entry.delete", "Delete");
294
+ showStatus("error", msg("entry.deleteFailed", "Delete failed"));
307
295
  });
308
296
  }
309
297
 
310
298
  /* ─── Validation rendering ──────────────────────────────────────── */
299
+ /** @param {Element} group @param {string} message */
311
300
  function showFieldError(group, message) {
312
301
  var errEl = group.querySelector(".studio-error-text");
313
302
  if (message) {
@@ -317,7 +306,7 @@
317
306
  group.appendChild(errEl);
318
307
  }
319
308
  errEl.textContent = message;
320
- errEl.hidden = false;
309
+ /** @type {HTMLElement} */ (errEl).hidden = false;
321
310
  group.classList.add("studio-field-error");
322
311
  } else if (errEl) {
323
312
  errEl.remove();
@@ -330,6 +319,7 @@
330
319
  fieldsEl.querySelectorAll(".caret-field-group").forEach(function (el) { el.classList.remove("studio-field-error"); });
331
320
  }
332
321
 
322
+ /** @param {ValidationIssue[]} issues */
333
323
  function showValidationErrors(issues) {
334
324
  clearFieldErrors();
335
325
  issues.forEach(function (issue) {
@@ -338,303 +328,77 @@
338
328
  });
339
329
  }
340
330
 
331
+ /** @param {string} value */
341
332
  function cssEscape(value) {
342
333
  if (window.CSS && CSS.escape) return CSS.escape(value);
343
334
  return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
344
335
  }
345
336
 
346
- /* ─── Field group ───────────────────────────────────────────────── */
347
- function fieldIdFromPath(path) {
348
- return "caret-field-" + String(path || "").replace(/[^a-zA-Z0-9_-]/g, "-");
337
+ /** @param {string} path */
338
+ function fieldControl(path) {
339
+ return fieldsEl.querySelector("#" + cssEscape(fieldIdFromPath(path)));
349
340
  }
350
341
 
351
- function createFieldGroup(label, isRequired, inline) {
352
- var wrapper = document.createElement("div");
353
- wrapper.className = "caret-field-group" + (inline ? " caret-field-inline" : "");
354
-
355
- var labelEl = document.createElement("label");
356
- labelEl.className = "studio-label";
357
- labelEl.textContent = label;
358
- if (isRequired === false) {
359
- var opt = document.createElement("span");
360
- opt.textContent = " (optional)";
361
- opt.style.cssText = "color:var(--studio-text-dim);font-weight:400;text-transform:none;letter-spacing:normal;";
362
- labelEl.appendChild(opt);
363
- }
364
- wrapper.appendChild(labelEl);
365
-
366
- // Once children are populated, link the label to the first form control
367
- // for screen-reader accessibility. Path is read from data-fieldPath
368
- // (set by the caller after createFieldGroup returns).
369
- queueMicrotask(function () {
370
- var path = wrapper.dataset.fieldPath;
371
- if (!path) return;
372
- var control = wrapper.querySelector("input, select, textarea");
373
- if (!control) return;
374
- var fid = fieldIdFromPath(path);
375
- if (!control.id) control.id = fid;
376
- if (!control.name) control.name = path;
377
- labelEl.htmlFor = control.id;
342
+ /** @param {Element} group */
343
+ function showLinkedSelection(group) {
344
+ window.clearTimeout(linkedSelectionTimer);
345
+ fieldsEl.querySelectorAll(".caret-field-selected").forEach(function (selected) {
346
+ selected.classList.remove("caret-field-selected");
378
347
  });
379
-
380
- return wrapper;
348
+ group.classList.add("caret-field-selected");
349
+ linkedSelectionTimer = window.setTimeout(function () {
350
+ group.classList.remove("caret-field-selected");
351
+ }, 1500);
381
352
  }
382
353
 
383
- /* ─── Schema-driven rendering ───────────────────────────────────── */
384
- function renderFields(container, data, schema, basePath) {
385
- if (!schema || !schema.properties) {
386
- renderFieldsFallback(container, data, basePath);
354
+ /** @param {Record<string, unknown>} message */
355
+ function applyRemoteSelection(message) {
356
+ if (
357
+ !message
358
+ || message.collection !== COLLECTION
359
+ || message.id !== ID
360
+ || typeof message.field !== "string"
361
+ ) return;
362
+
363
+ if (!entryData) {
364
+ pendingRemoteSelection = message;
387
365
  return;
388
366
  }
389
367
 
390
- var required = new Set(schema.required || []);
391
-
392
- Object.keys(schema.properties).forEach(function (key) {
393
- var prop = schema.properties[key];
394
- var path = basePath ? basePath + "." + key : key;
395
- var label = prop.title || humanizeKey(key);
396
- var isRequired = required.has(key);
397
- var value = data ? data[key] : undefined;
398
-
399
- // Backfill missing keys with sensible defaults
400
- if (value === undefined && data) {
401
- if (prop.default !== undefined) data[key] = deepClone(prop.default);
402
- else if (prop.type === "object") data[key] = {};
403
- else if (prop.type === "array") data[key] = [];
404
- else if (prop.type === "boolean") data[key] = false;
405
- else if (prop.type === "number") data[key] = 0;
406
- else if (prop.type === "string" && prop.enum) data[key] = prop.enum[0];
407
- else if (prop.type === "string") data[key] = "";
408
- }
409
- var current = data ? data[key] : undefined;
410
-
411
- var group;
412
-
413
- if (prop.type === "string" && prop.enum) {
414
- group = createFieldGroup(label, isRequired);
415
- group.dataset.fieldPath = path;
416
- var select = document.createElement("select");
417
- select.className = "studio-input";
418
- prop.enum.forEach(function (opt) {
419
- var option = document.createElement("option");
420
- option.value = opt;
421
- option.textContent = String(opt).charAt(0).toUpperCase() + String(opt).slice(1);
422
- if (opt === current) option.selected = true;
423
- select.appendChild(option);
424
- });
425
- select.addEventListener("change", function () { updateField(path, select.value); });
426
- group.appendChild(select);
427
- } else if (prop.type === "string" && prop.format === "image") {
428
- group = createFieldGroup(label, isRequired);
429
- group.dataset.fieldPath = path;
430
- group.appendChild(buildSingleImage(current || "", path));
431
- } else if (prop.type === "string" && prop.format === "textarea") {
432
- group = createFieldGroup(label, isRequired);
433
- group.dataset.fieldPath = path;
434
- var ta = document.createElement("textarea");
435
- ta.className = "studio-input";
436
- ta.rows = 4;
437
- ta.value = current || "";
438
- ta.addEventListener("input", function () { updateField(path, ta.value); });
439
- group.appendChild(ta);
440
- } else if (prop.type === "string" && (prop.format === "email" || prop.format === "uri")) {
441
- group = createFieldGroup(label, isRequired);
442
- group.dataset.fieldPath = path;
443
- var emailInput = document.createElement("input");
444
- emailInput.type = prop.format === "email" ? "email" : "url";
445
- emailInput.className = "studio-input";
446
- emailInput.value = current || "";
447
- emailInput.addEventListener("input", function () { updateField(path, emailInput.value); });
448
- group.appendChild(emailInput);
449
- } else if (prop.type === "string") {
450
- group = createFieldGroup(label, isRequired);
451
- group.dataset.fieldPath = path;
452
- var input;
453
- if (typeof current === "string" && current.length > 100) {
454
- input = document.createElement("textarea");
455
- input.rows = 4;
456
- } else {
457
- input = document.createElement("input");
458
- input.type = "text";
459
- }
460
- input.className = "studio-input";
461
- input.value = current || "";
462
- input.addEventListener("input", function () { updateField(path, input.value); });
463
- group.appendChild(input);
464
- } else if (prop.type === "number" || prop.type === "integer") {
465
- group = createFieldGroup(label, isRequired);
466
- group.dataset.fieldPath = path;
467
- var num = document.createElement("input");
468
- num.type = "number";
469
- num.className = "studio-input";
470
- num.value = (current == null ? 0 : current);
471
- if (prop.minimum !== undefined) num.min = prop.minimum;
472
- if (prop.maximum !== undefined) num.max = prop.maximum;
473
- num.addEventListener("input", function () { updateField(path, Number(num.value) || 0); });
474
- group.appendChild(num);
475
- } else if (prop.type === "boolean") {
476
- group = createFieldGroup(label, isRequired, true);
477
- group.dataset.fieldPath = path;
478
- var toggle = document.createElement("button");
479
- toggle.type = "button";
480
- toggle.className = "studio-toggle" + (current ? " active" : "");
481
- toggle.addEventListener("click", function () {
482
- var next = !toggle.classList.contains("active");
483
- toggle.classList.toggle("active");
484
- updateField(path, next);
485
- });
486
- group.appendChild(toggle);
487
- } else if (prop.type === "array" && prop.format === "image-gallery") {
488
- group = createFieldGroup(label, isRequired);
489
- group.dataset.fieldPath = path;
490
- group.appendChild(buildImageGallery(current || [], path));
491
- } else if (prop.type === "array" && prop.items && prop.items.type === "object") {
492
- group = createFieldGroup(label, isRequired);
493
- group.dataset.fieldPath = path;
494
- group.appendChild(buildObjectArray(current || [], path, prop.items));
495
- } else if (prop.type === "array" && prop.items && prop.items.type === "string") {
496
- group = createFieldGroup(label, isRequired);
497
- group.dataset.fieldPath = path;
498
- group.appendChild(buildTagList(current || [], path));
499
- } else if (prop.type === "object" && prop.properties) {
500
- group = createFieldGroup(label, isRequired);
501
- group.dataset.fieldPath = path;
502
- var nested = document.createElement("div");
503
- nested.className = "caret-nested";
504
- renderFields(nested, current || {}, prop, path);
505
- group.appendChild(nested);
506
- } else {
507
- group = createFieldGroup(label, isRequired);
508
- group.dataset.fieldPath = path;
509
- var jsonTa = document.createElement("textarea");
510
- jsonTa.className = "studio-input";
511
- jsonTa.style.fontFamily = "var(--studio-font-mono)";
512
- jsonTa.style.fontSize = "11px";
513
- jsonTa.rows = 4;
514
- jsonTa.value = JSON.stringify(current, null, 2);
515
- jsonTa.addEventListener("input", function () {
516
- try { updateField(path, JSON.parse(jsonTa.value)); } catch (e) { /* ignore until valid */ }
517
- });
518
- group.appendChild(jsonTa);
519
- }
520
-
521
- container.appendChild(group);
522
- });
368
+ var control = fieldControl(message.field);
369
+ var group = control && control.closest(".caret-field-group");
370
+ if (!group) return;
371
+ showLinkedSelection(group);
372
+ group.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
523
373
  }
524
374
 
525
- function renderFieldsFallback(container, data, basePath) {
526
- if (!data || typeof data !== "object") return;
527
- Object.keys(data).forEach(function (key) {
528
- var value = data[key];
529
- var path = basePath ? basePath + "." + key : key;
530
- var label = humanizeKey(key);
531
-
532
- if (key === "images" && Array.isArray(value) && value.every(function (v) { return typeof v === "string"; })) {
533
- var g = createFieldGroup(label, true);
534
- g.dataset.fieldPath = path;
535
- g.appendChild(buildImageGallery(value, path));
536
- container.appendChild(g);
537
- return;
538
- }
539
- if ((key === "image" || key === "thumbnail" || key === "bg_image") && typeof value === "string") {
540
- var g2 = createFieldGroup(label, true);
541
- g2.dataset.fieldPath = path;
542
- g2.appendChild(buildSingleImage(value, path));
543
- container.appendChild(g2);
544
- return;
545
- }
546
- if (Array.isArray(value) && value.every(function (v) { return typeof v === "string"; })) {
547
- var g3 = createFieldGroup(label, true);
548
- g3.dataset.fieldPath = path;
549
- g3.appendChild(buildTagList(value, path));
550
- container.appendChild(g3);
551
- return;
552
- }
553
- if (Array.isArray(value) && value.every(function (v) { return typeof v === "object" && v !== null && !Array.isArray(v); })) {
554
- var g4 = createFieldGroup(label, true);
555
- g4.dataset.fieldPath = path;
556
- g4.appendChild(buildObjectArray(value, path, null));
557
- container.appendChild(g4);
558
- return;
559
- }
560
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
561
- var g5 = createFieldGroup(label, true);
562
- g5.dataset.fieldPath = path;
563
- var nested = document.createElement("div");
564
- nested.className = "caret-nested";
565
- renderFieldsFallback(nested, value, path);
566
- g5.appendChild(nested);
567
- container.appendChild(g5);
568
- return;
569
- }
570
- if (typeof value === "boolean") {
571
- var g6 = createFieldGroup(label, true, true);
572
- g6.dataset.fieldPath = path;
573
- var t = document.createElement("button");
574
- t.type = "button";
575
- t.className = "studio-toggle" + (value ? " active" : "");
576
- t.addEventListener("click", function () {
577
- var next = !t.classList.contains("active");
578
- t.classList.toggle("active");
579
- updateField(path, next);
580
- });
581
- g6.appendChild(t);
582
- container.appendChild(g6);
583
- return;
584
- }
585
- if (typeof value === "number") {
586
- var g7 = createFieldGroup(label, true);
587
- g7.dataset.fieldPath = path;
588
- var n = document.createElement("input");
589
- n.type = "number";
590
- n.className = "studio-input";
591
- n.value = value;
592
- n.addEventListener("input", function () { updateField(path, Number(n.value) || 0); });
593
- g7.appendChild(n);
594
- container.appendChild(g7);
595
- return;
596
- }
597
- if (typeof value === "string") {
598
- var g8 = createFieldGroup(label, true);
599
- g8.dataset.fieldPath = path;
600
- var i;
601
- if (value.length > 100) { i = document.createElement("textarea"); i.rows = 4; }
602
- else { i = document.createElement("input"); i.type = "text"; }
603
- i.className = "studio-input";
604
- i.value = value;
605
- i.addEventListener("input", function () { updateField(path, i.value); });
606
- g8.appendChild(i);
607
- container.appendChild(g8);
608
- return;
609
- }
610
- // Fallback to JSON
611
- var g9 = createFieldGroup(label, true);
612
- g9.dataset.fieldPath = path;
613
- var jt = document.createElement("textarea");
614
- jt.className = "studio-input";
615
- jt.style.fontFamily = "var(--studio-font-mono)";
616
- jt.style.fontSize = "11px";
617
- jt.rows = 4;
618
- jt.value = JSON.stringify(value, null, 2);
619
- jt.addEventListener("input", function () {
620
- try { updateField(path, JSON.parse(jt.value)); } catch (e) { /* ignore */ }
621
- });
622
- g9.appendChild(jt);
623
- container.appendChild(g9);
624
- });
625
- }
375
+ /* ─── Field rendering ───────────────────────────────────────────── */
376
+
377
+ const { renderFields, renderFieldsFallback } = createFieldRenderer({
378
+ updateField,
379
+ buildSingleImage,
380
+ buildImageGallery,
381
+ buildObjectArray,
382
+ buildTagList,
383
+ getPublicationField: () => publicationFieldName,
384
+ });
626
385
 
627
386
  /* ─── Image gallery ─────────────────────────────────────────────── */
387
+ /** @param {unknown} images @param {string} path */
628
388
  function buildImageGallery(images, path) {
629
389
  var wrapper = document.createElement("div");
630
390
 
631
391
  function rerender() {
632
392
  wrapper.innerHTML = "";
633
- var current = getNestedValue(entryData, path) || [];
393
+ var galleryValue = getNestedValue(entryData, path);
394
+ var current = Array.isArray(galleryValue)
395
+ ? galleryValue.filter(function (value) { return typeof value === "string"; })
396
+ : [];
634
397
 
635
398
  if (current.length > 0) {
636
399
  var grid = document.createElement("div");
637
400
  grid.className = "caret-gallery-grid";
401
+ /** @type {number | null} */
638
402
  var dragIdx = null;
639
403
 
640
404
  current.forEach(function (url, idx) {
@@ -643,9 +407,9 @@
643
407
  item.draggable = true;
644
408
  item.innerHTML =
645
409
  '<img src="' + url + '" alt="Image ' + (idx + 1) + '" loading="lazy" />' +
646
- '<button class="caret-gallery-remove" type="button" title="Remove">×</button>' +
410
+ '<button class="caret-gallery-remove" type="button" title="' + htmlEscape(msg("field.remove", "Remove")) + '">×</button>' +
647
411
  '<span class="caret-gallery-index">' + (idx + 1) + "</span>";
648
- item.querySelector(".caret-gallery-remove").addEventListener("click", function (e) {
412
+ item.querySelector(".caret-gallery-remove")?.addEventListener("click", function (e) {
649
413
  e.stopPropagation();
650
414
  var imgs = current.slice();
651
415
  imgs.splice(idx, 1);
@@ -688,11 +452,13 @@
688
452
  fileInput.type = "file";
689
453
  fileInput.multiple = true;
690
454
  fileInput.accept = "image/jpeg,image/png,image/webp,image/avif";
455
+ fileInput.setAttribute("aria-label", "Upload images for " + humanizeKey(path.split(".").pop() || "images"));
691
456
  fileInput.style.display = "none";
692
457
 
458
+ /** @param {ArrayLike<File>} files */
693
459
  function handleFiles(files) {
694
- var valid = Array.prototype.filter.call(files, function (f) {
695
- return ["image/jpeg", "image/png", "image/webp", "image/avif"].indexOf(f.type) >= 0;
460
+ var valid = Array.from(files).filter(function (f) {
461
+ return isSupportedImage(f);
696
462
  });
697
463
  if (!valid.length) return;
698
464
  dropzone.innerHTML =
@@ -700,8 +466,11 @@
700
466
  '<p style="margin:0;font-size:0.75rem;color:var(--studio-text-muted);">Uploading ' + valid.length + " image" + (valid.length > 1 ? "s" : "") + "…</p>";
701
467
  dropzone.style.pointerEvents = "none";
702
468
 
703
- var imgs = (getNestedValue(entryData, path) || []).slice();
704
- Promise.allSettled(valid.map(function (f) { return uploadFile(f); })).then(function (results) {
469
+ var existingImages = getNestedValue(entryData, path);
470
+ var imgs = Array.isArray(existingImages)
471
+ ? existingImages.filter(function (value) { return typeof value === "string"; })
472
+ : [];
473
+ Promise.allSettled(valid.map(function (f) { return uploadClient.upload(f); })).then(function (results) {
705
474
  results.forEach(function (r) { if (r.status === "fulfilled") imgs.push(r.value); });
706
475
  updateField(path, imgs);
707
476
  rerender();
@@ -714,10 +483,11 @@
714
483
  dropzone.addEventListener("drop", function (e) {
715
484
  e.preventDefault();
716
485
  dropzone.classList.remove("drag-over");
717
- if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
486
+ var files = e.dataTransfer && e.dataTransfer.files;
487
+ if (files && files.length) handleFiles(files);
718
488
  });
719
489
  fileInput.addEventListener("change", function () {
720
- if (fileInput.files.length) handleFiles(fileInput.files);
490
+ if (fileInput.files && fileInput.files.length) handleFiles(fileInput.files);
721
491
  fileInput.value = "";
722
492
  });
723
493
 
@@ -730,18 +500,45 @@
730
500
  }
731
501
 
732
502
  /* ─── Single image ──────────────────────────────────────────────── */
503
+ /** @param {unknown} url @param {string} path */
733
504
  function buildSingleImage(url, path) {
734
505
  var wrapper = document.createElement("div");
506
+ wrapper.className = "caret-single-image";
735
507
 
736
508
  function rerender() {
737
509
  wrapper.innerHTML = "";
738
- var current = getNestedValue(entryData, path) || "";
510
+ var imageValue = getNestedValue(entryData, path);
511
+ var current = typeof imageValue === "string" ? imageValue : "";
512
+
513
+ var fileInput = document.createElement("input");
514
+ fileInput.type = "file";
515
+ fileInput.accept = "image/jpeg,image/png,image/webp,image/avif";
516
+ fileInput.setAttribute("aria-label", msg("field.uploadOrReplaceImage", "Upload or replace image"));
517
+ fileInput.style.display = "none";
739
518
 
740
519
  if (current) {
741
- var preview = document.createElement("div");
520
+ var preview = document.createElement("button");
521
+ preview.type = "button";
742
522
  preview.className = "caret-single-image-preview";
743
- preview.innerHTML = '<img src="' + current + '" alt="" />';
523
+ preview.title = msg("field.replaceImage", "Replace image");
524
+ preview.setAttribute("aria-label", msg("field.replaceImage", "Replace image"));
525
+ var image = document.createElement("img");
526
+ image.src = current;
527
+ image.alt = "Current image preview";
528
+ var overlay = document.createElement("span");
529
+ overlay.className = "caret-single-image-overlay";
530
+ overlay.textContent = msg("field.replaceImage", "Replace image");
531
+ preview.appendChild(image);
532
+ preview.appendChild(overlay);
533
+ preview.addEventListener("click", function () { fileInput.click(); });
744
534
  wrapper.appendChild(preview);
535
+ } else {
536
+ var emptyPreview = document.createElement("button");
537
+ emptyPreview.type = "button";
538
+ emptyPreview.className = "caret-single-image-empty";
539
+ emptyPreview.textContent = "+ Choose an image";
540
+ emptyPreview.addEventListener("click", function () { fileInput.click(); });
541
+ wrapper.appendChild(emptyPreview);
745
542
  }
746
543
 
747
544
  var row = document.createElement("div");
@@ -750,6 +547,8 @@
750
547
  var input = document.createElement("input");
751
548
  input.type = "text";
752
549
  input.className = "studio-input";
550
+ input.id = fieldIdFromPath(path);
551
+ input.name = path;
753
552
  input.style.flex = "1";
754
553
  input.value = current;
755
554
  input.placeholder = "Image URL";
@@ -759,24 +558,29 @@
759
558
  var uploadBtn = document.createElement("button");
760
559
  uploadBtn.type = "button";
761
560
  uploadBtn.className = "studio-btn-ghost";
762
- uploadBtn.textContent = "Upload";
561
+ uploadBtn.textContent = current ? "Replace" : "Upload";
763
562
 
764
- var fileInput = document.createElement("input");
765
- fileInput.type = "file";
766
- fileInput.accept = "image/jpeg,image/png,image/webp,image/avif";
767
- fileInput.style.display = "none";
563
+ var help = document.createElement("p");
564
+ help.className = "caret-image-help";
565
+ help.textContent = current
566
+ ? "Click the preview or Replace to upload a new image. You can also paste a URL. For gallery images, blank title and alt fields are filled from the filename—review them before saving."
567
+ : "Upload an image or paste its URL.";
768
568
 
769
569
  uploadBtn.addEventListener("click", function () { fileInput.click(); });
770
570
  fileInput.addEventListener("change", function () {
771
571
  var file = fileInput.files && fileInput.files[0];
772
572
  if (!file) return;
573
+ var selectedFile = file;
773
574
  uploadBtn.textContent = "Uploading…";
774
575
  uploadBtn.disabled = true;
775
- uploadFile(file).then(function (newUrl) {
776
- updateField(path, newUrl);
576
+ Promise.all([uploadClient.upload(selectedFile), uploadClient.readDimensions(selectedFile)]).then(function (results) {
577
+ updateField(path, results[0]);
578
+ populateImageObject(path, selectedFile, results[1]);
777
579
  rerender();
778
580
  }).catch(function () {
779
- uploadBtn.textContent = "Upload";
581
+ help.textContent = "Upload failed. Check the image type and try again.";
582
+ help.classList.add("is-error");
583
+ uploadBtn.textContent = current ? "Replace" : "Upload";
780
584
  uploadBtn.disabled = false;
781
585
  });
782
586
  fileInput.value = "";
@@ -786,6 +590,7 @@
786
590
  row.appendChild(uploadBtn);
787
591
  row.appendChild(fileInput);
788
592
  wrapper.appendChild(row);
593
+ wrapper.appendChild(help);
789
594
  }
790
595
 
791
596
  rerender();
@@ -793,12 +598,16 @@
793
598
  }
794
599
 
795
600
  /* ─── Tag list ──────────────────────────────────────────────────── */
601
+ /** @param {unknown} items @param {string} path */
796
602
  function buildTagList(items, path) {
797
603
  var wrapper = document.createElement("div");
798
604
 
799
605
  function rerender() {
800
606
  wrapper.innerHTML = "";
801
- var current = getNestedValue(entryData, path) || [];
607
+ var tagValue = getNestedValue(entryData, path);
608
+ var current = Array.isArray(tagValue)
609
+ ? tagValue.filter(function (value) { return typeof value === "string"; })
610
+ : [];
802
611
 
803
612
  if (current.length > 0) {
804
613
  var tagsEl = document.createElement("div");
@@ -809,7 +618,7 @@
809
618
  tag.textContent = item;
810
619
  var btn = document.createElement("button");
811
620
  btn.type = "button";
812
- btn.title = "Remove";
621
+ btn.title = msg("field.remove", "Remove");
813
622
  btn.textContent = "×";
814
623
  btn.addEventListener("click", function () {
815
624
  var next = current.slice();
@@ -858,22 +667,125 @@
858
667
  }
859
668
 
860
669
  /* ─── Object array ──────────────────────────────────────────────── */
670
+ /** @param {unknown} items @param {string} path @param {Schema | null | undefined} itemSchema */
861
671
  function buildObjectArray(items, path, itemSchema) {
862
672
  var wrapper = document.createElement("div");
863
673
  wrapper.className = "caret-object-array";
674
+ /** @type {number | null} */
675
+ var draggedIndex = null;
676
+
677
+ /** @param {Record<string, unknown>} item @param {number} idx */
678
+ function itemSummary(item, idx) {
679
+ var summaryKeys = ["title", "label", "name", "alt", "src"];
680
+ for (var i = 0; i < summaryKeys.length; i++) {
681
+ var value = item && item[summaryKeys[i]];
682
+ if (typeof value === "string" && value.trim()) {
683
+ if (summaryKeys[i] === "src") {
684
+ var clean = value.split("?")[0].split("/").pop();
685
+ return clean || (singularItemLabel(path) + " " + (idx + 1));
686
+ }
687
+ return value;
688
+ }
689
+ }
690
+ return singularItemLabel(path).replace(/^./, function (c) { return c.toUpperCase(); }) + " " + (idx + 1);
691
+ }
692
+
693
+ /** @param {Record<string, unknown>[]} current @param {number} from @param {number} to */
694
+ function moveItem(current, from, to) {
695
+ if (to < 0 || to >= current.length || from === to) return;
696
+ var next = current.slice();
697
+ var moved = next.splice(from, 1)[0];
698
+ next.splice(to, 0, moved);
699
+ updateField(path, next);
700
+ rerender();
701
+ }
864
702
 
865
703
  function rerender() {
866
704
  wrapper.innerHTML = "";
867
- var current = getNestedValue(entryData, path) || [];
705
+ var arrayValue = getNestedValue(entryData, path);
706
+ var current = Array.isArray(arrayValue)
707
+ ? arrayValue.filter(function (value) { return value !== null && typeof value === "object" && !Array.isArray(value); })
708
+ : [];
868
709
 
869
710
  current.forEach(function (item, idx) {
870
711
  var card = document.createElement("div");
871
712
  card.className = "caret-object-array-item";
872
713
 
714
+ var header = document.createElement("div");
715
+ header.className = "caret-object-array-header";
716
+
717
+ var dragHandle = document.createElement("button");
718
+ dragHandle.type = "button";
719
+ dragHandle.className = "caret-object-array-drag";
720
+ dragHandle.draggable = true;
721
+ dragHandle.title = msg("field.drag", "Drag to reorder");
722
+ dragHandle.setAttribute("aria-label", msg("field.drag", "Drag to reorder") + " " + itemSummary(item, idx));
723
+ dragHandle.textContent = "⠿";
724
+ dragHandle.addEventListener("dragstart", function (event) {
725
+ draggedIndex = idx;
726
+ card.classList.add("caret-object-array-dragging");
727
+ if (event.dataTransfer) {
728
+ event.dataTransfer.effectAllowed = "move";
729
+ event.dataTransfer.setData("text/plain", String(idx));
730
+ }
731
+ });
732
+ dragHandle.addEventListener("dragend", function () {
733
+ draggedIndex = null;
734
+ wrapper.querySelectorAll(".caret-object-array-item").forEach(function (row) {
735
+ row.classList.remove("caret-object-array-dragging", "caret-object-array-drag-over");
736
+ });
737
+ });
738
+ header.appendChild(dragHandle);
739
+
740
+ var idxLabel = document.createElement("span");
741
+ idxLabel.className = "caret-object-array-index";
742
+ idxLabel.textContent = itemSummary(item, idx);
743
+ header.appendChild(idxLabel);
744
+
745
+ card.addEventListener("dragover", function (event) {
746
+ if (draggedIndex === null || draggedIndex === idx) return;
747
+ event.preventDefault();
748
+ card.classList.add("caret-object-array-drag-over");
749
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
750
+ });
751
+ card.addEventListener("dragleave", function () {
752
+ card.classList.remove("caret-object-array-drag-over");
753
+ });
754
+ card.addEventListener("drop", function (event) {
755
+ event.preventDefault();
756
+ card.classList.remove("caret-object-array-drag-over");
757
+ if (draggedIndex === null) return;
758
+ var from = draggedIndex;
759
+ draggedIndex = null;
760
+ moveItem(current, from, idx);
761
+ });
762
+
763
+ var actions = document.createElement("div");
764
+ actions.className = "caret-object-array-actions";
765
+
766
+ var upBtn = document.createElement("button");
767
+ upBtn.type = "button";
768
+ upBtn.className = "caret-object-array-move";
769
+ upBtn.title = msg("field.moveUp", "Move up");
770
+ upBtn.setAttribute("aria-label", msg("field.moveUp", "Move up") + " " + itemSummary(item, idx));
771
+ upBtn.textContent = "↑";
772
+ upBtn.disabled = idx === 0;
773
+ upBtn.addEventListener("click", function () { moveItem(current, idx, idx - 1); });
774
+
775
+ var downBtn = document.createElement("button");
776
+ downBtn.type = "button";
777
+ downBtn.className = "caret-object-array-move";
778
+ downBtn.title = msg("field.moveDown", "Move down");
779
+ downBtn.setAttribute("aria-label", msg("field.moveDown", "Move down") + " " + itemSummary(item, idx));
780
+ downBtn.textContent = "↓";
781
+ downBtn.disabled = idx === current.length - 1;
782
+ downBtn.addEventListener("click", function () { moveItem(current, idx, idx + 1); });
783
+
873
784
  var removeBtn = document.createElement("button");
874
785
  removeBtn.type = "button";
875
786
  removeBtn.className = "caret-object-array-remove";
876
- removeBtn.title = "Remove item";
787
+ removeBtn.title = msg("field.remove", "Remove") + " " + singularItemLabel(path);
788
+ removeBtn.setAttribute("aria-label", msg("field.remove", "Remove") + " " + itemSummary(item, idx));
877
789
  removeBtn.textContent = "×";
878
790
  removeBtn.addEventListener("click", function () {
879
791
  var next = current.slice();
@@ -881,46 +793,21 @@
881
793
  updateField(path, next);
882
794
  rerender();
883
795
  });
884
- card.appendChild(removeBtn);
885
796
 
886
- var idxLabel = document.createElement("span");
887
- idxLabel.className = "caret-object-array-index";
888
- idxLabel.textContent = "[" + idx + "]";
889
- card.appendChild(idxLabel);
797
+ actions.appendChild(upBtn);
798
+ actions.appendChild(downBtn);
799
+ actions.appendChild(removeBtn);
800
+ header.appendChild(actions);
801
+ card.appendChild(header);
890
802
 
891
803
  var fieldsCont = document.createElement("div");
892
804
  fieldsCont.className = "caret-object-array-fields";
893
805
 
894
- var keys = (itemSchema && itemSchema.properties) ? Object.keys(itemSchema.properties) : Object.keys(item);
895
- keys.forEach(function (key) {
896
- var itemPath = path + "." + idx + "." + key;
897
- var val = item[key];
898
- var propSchema = itemSchema && itemSchema.properties && itemSchema.properties[key];
899
- var fieldLabel = (propSchema && propSchema.title) || humanizeKey(key);
900
-
901
- var row = document.createElement("div");
902
- row.className = "caret-object-array-row";
903
-
904
- var keyLabel = document.createElement("span");
905
- keyLabel.className = "caret-object-array-key";
906
- keyLabel.textContent = fieldLabel;
907
-
908
- var input = document.createElement("input");
909
- input.type = "text";
910
- input.className = "studio-input";
911
- input.value = typeof val === "string" ? val : (val == null ? "" : JSON.stringify(val));
912
- // Write back in the field's declared (or original) type, not always a
913
- // string — otherwise editing a number/boolean/nested field silently
914
- // rewrites it as text. Unparseable input falls back to the raw string
915
- // so server-side validation can report it.
916
- input.addEventListener("input", function () {
917
- updateField(itemPath, coerceScalarValue(input.value, propSchema, val));
918
- });
919
-
920
- row.appendChild(keyLabel);
921
- row.appendChild(input);
922
- fieldsCont.appendChild(row);
923
- });
806
+ if (itemSchema && itemSchema.properties) {
807
+ renderFields(fieldsCont, item, itemSchema, path + "." + idx);
808
+ } else {
809
+ renderFieldsFallback(fieldsCont, item, path + "." + idx);
810
+ }
924
811
 
925
812
  card.appendChild(fieldsCont);
926
813
  wrapper.appendChild(card);
@@ -929,22 +816,38 @@
929
816
  var addBtn = document.createElement("button");
930
817
  addBtn.type = "button";
931
818
  addBtn.className = "caret-object-array-add";
932
- addBtn.textContent = "+ Add item";
819
+ addBtn.textContent = "+ " + msg("field.add", "Add") + " " + singularItemLabel(path);
933
820
  addBtn.addEventListener("click", function () {
821
+ /** @type {Record<string, unknown>} */
934
822
  var template;
935
823
  if (itemSchema && itemSchema.properties) {
936
- template = {};
937
- Object.keys(itemSchema.properties).forEach(function (k) {
938
- var s = itemSchema.properties[k];
939
- template[k] = s.type === "number" || s.type === "integer" ? 0 : (s.type === "boolean" ? false : "");
940
- });
824
+ var templateValue = templateFromSchema(itemSchema);
825
+ template = templateValue && typeof templateValue === "object" && !Array.isArray(templateValue)
826
+ ? /** @type {Record<string, unknown>} */ (templateValue)
827
+ : {};
828
+ if (Object.prototype.hasOwnProperty.call(template, "id") && !template.id) {
829
+ template.id = generatedId(path, current.length);
830
+ }
941
831
  } else {
942
832
  template = current.length > 0
943
- ? Object.fromEntries(Object.keys(current[0]).map(function (k) { return [k, ""]; }))
833
+ ? Object.fromEntries(Object.keys(current[0]).map(function (k) {
834
+ var sample = current[0][k];
835
+ return [k, typeof sample === "number" ? 0 : (typeof sample === "boolean" ? false : (Array.isArray(sample) ? [] : (sample && typeof sample === "object" ? {} : "")))];
836
+ }))
944
837
  : {};
945
838
  }
946
839
  updateField(path, current.concat([template]));
947
840
  rerender();
841
+ var cards = wrapper.querySelectorAll(".caret-object-array-item");
842
+ var newest = cards[cards.length - 1];
843
+ var firstControl = newest && newest.querySelector("input, textarea, select, button.caret-single-image-empty");
844
+ // Focus immediately. Deferring focus to the next animation frame can
845
+ // steal it back after a fast user (or assistive automation) has already
846
+ // moved to another control in the new row.
847
+ if (firstControl instanceof HTMLElement) firstControl.focus();
848
+ requestAnimationFrame(function () {
849
+ if (newest) newest.scrollIntoView({ behavior: "smooth", block: "nearest" });
850
+ });
948
851
  });
949
852
  wrapper.appendChild(addBtn);
950
853
  }
@@ -957,16 +860,13 @@
957
860
  function loadHistory() {
958
861
  historyList.innerHTML = '<p style="font-size:0.75rem;color:var(--studio-text-dim);margin:0;">Loading…</p>';
959
862
  historyPanel.hidden = false;
863
+ historyBtn.setAttribute("aria-expanded", "true");
864
+ historyCloseBtn.focus();
960
865
 
961
- fetch(API + "/history?collection=" + encodeURIComponent(COLLECTION) + "&id=" + encodeURIComponent(ID), {
962
- credentials: "same-origin",
963
- }).then(function (res) {
964
- if (handleAuth(res)) return null;
965
- if (!res.ok) throw new Error("history failed");
966
- return res.json();
967
- }).then(function (json) {
968
- if (!json) return;
969
- var items = json.history || [];
866
+ historyClient.list({ collection: COLLECTION, id: ID }).then(function (result) {
867
+ if (result.kind === "unauthorized") { loginRedirect(); return; }
868
+ if (result.kind === "error") throw new Error("history failed");
869
+ var items = result.items;
970
870
  if (items.length === 0) {
971
871
  historyList.innerHTML = '<p style="font-size:0.75rem;color:var(--studio-text-dim);margin:0;">No history yet. Edits create snapshots automatically.</p>';
972
872
  return;
@@ -992,6 +892,12 @@
992
892
 
993
893
  info.appendChild(actionSpan);
994
894
  info.appendChild(timeSpan);
895
+ if (item.editor && typeof item.editor.id === "string") {
896
+ var editorSpan = document.createElement("span");
897
+ editorSpan.style.cssText = "font-size:0.7rem;color:var(--studio-text-dim);";
898
+ editorSpan.textContent = item.editor.name || item.editor.email || item.editor.id;
899
+ info.appendChild(editorSpan);
900
+ }
995
901
  row.appendChild(info);
996
902
 
997
903
  var restoreBtn = document.createElement("button");
@@ -1008,25 +914,28 @@
1008
914
  });
1009
915
  }
1010
916
 
917
+ function closeHistoryPanel() {
918
+ historyPanel.hidden = true;
919
+ historyBtn.setAttribute("aria-expanded", "false");
920
+ historyList.innerHTML = "";
921
+ if (historyBtn.isConnected) historyBtn.focus();
922
+ }
923
+
924
+ /** @param {number} ts */
1011
925
  function restoreSnapshot(ts) {
1012
926
  if (!window.confirm("Restore this version? Current changes will be saved to history first.")) return;
1013
- fetch(API + "/history", {
1014
- method: "POST",
1015
- headers: { "Content-Type": "application/json", "x-caret-request": "1" },
1016
- credentials: "same-origin",
1017
- body: JSON.stringify({ collection: COLLECTION, id: ID, ts: ts }),
1018
- }).then(function (res) {
1019
- if (handleAuth(res)) return null;
1020
- if (!res.ok) throw new Error("restore failed");
1021
- return res.json();
1022
- }).then(function (json) {
1023
- if (!json) return;
927
+ historyClient.restore({ collection: COLLECTION, id: ID, ts: ts }).then(function (result) {
928
+ if (result.kind === "unauthorized") { loginRedirect(); return; }
929
+ if (result.kind === "error") throw new Error("restore failed");
1024
930
  var prevData = entryData;
1025
931
  var prevJson = originalJson;
1026
932
  try {
1027
- entryData = deepClone(json.data);
1028
- originalJson = JSON.stringify(json.data);
1029
- if (typeof json.revision === "number") entryRevision = json.revision;
933
+ if (!result.data || typeof result.data !== "object" || Array.isArray(result.data)) {
934
+ throw new Error("invalid restored entry data");
935
+ }
936
+ entryData = deepClone(/** @type {Record<string, unknown>} */ (result.data));
937
+ originalJson = JSON.stringify(result.data);
938
+ if (typeof result.revision === "number") entryRevision = result.revision;
1030
939
  fieldsEl.innerHTML = "";
1031
940
  renderFields(fieldsEl, entryData, entrySchema, "");
1032
941
  updateSaveButton();
@@ -1039,7 +948,8 @@
1039
948
  showStatus("error", "Restore failed — invalid data");
1040
949
  return;
1041
950
  }
1042
- historyPanel.hidden = true;
951
+ closeHistoryPanel();
952
+ studioSync.announceChange("cms:saved", entryData);
1043
953
  showStatus("saved", "Restored");
1044
954
  }).catch(function () {
1045
955
  showStatus("error", "Restore failed");
@@ -1048,70 +958,113 @@
1048
958
 
1049
959
  /* ─── Wire events ───────────────────────────────────────────────── */
1050
960
  saveBtn.addEventListener("click", save);
1051
- deleteBtn.addEventListener("click", deleteEntry);
961
+ if (deleteBtn) deleteBtn.addEventListener("click", deleteEntry);
1052
962
  historyBtn.addEventListener("click", loadHistory);
1053
- historyCloseBtn.addEventListener("click", function () {
1054
- historyPanel.hidden = true;
1055
- historyList.innerHTML = "";
1056
- });
1057
- deleteCancelBtn.addEventListener("click", function () { deleteDialog.hidden = true; });
963
+ historyCloseBtn.addEventListener("click", closeHistoryPanel);
964
+ deleteCancelBtn.addEventListener("click", closeDeleteDialog);
1058
965
  deleteConfirmBtn.addEventListener("click", confirmDelete);
1059
966
  deleteDialog.addEventListener("click", function (e) {
1060
- if (e.target === deleteDialog) deleteDialog.hidden = true;
967
+ if (e.target === deleteDialog) closeDeleteDialog();
968
+ });
969
+ deleteDialog.addEventListener("keydown", function (e) {
970
+ if (deleteDialog.hidden) return;
971
+ if (e.key !== "Tab") return;
972
+ var controls = Array.from(deleteDialog.querySelectorAll("button:not([disabled])"));
973
+ var first = controls[0];
974
+ var last = controls[controls.length - 1];
975
+ if (!(first instanceof HTMLElement) || !(last instanceof HTMLElement)) return;
976
+ var active = document.activeElement;
977
+ if (e.shiftKey && (active === first || !deleteDialog.contains(active))) {
978
+ e.preventDefault();
979
+ last.focus();
980
+ } else if (!e.shiftKey && active === last) {
981
+ e.preventDefault();
982
+ first.focus();
983
+ }
984
+ });
985
+
986
+ // Focusing a Studio control locates its live-page counterpart without
987
+ // stealing focus from the editor. The temporary blue cue is distinct from
988
+ // green save confirmation.
989
+ fieldsEl.addEventListener("focusin", function (event) {
990
+ var control = event.target instanceof Element
991
+ ? event.target.closest("input, select, textarea, button")
992
+ : null;
993
+ var group = control && control.closest("[data-field-path]");
994
+ var path = group instanceof HTMLElement ? group.dataset.fieldPath : undefined;
995
+ if (!path) return;
996
+ if (!group) return;
997
+ showLinkedSelection(group);
998
+ studioSync.announceFieldSelection(path);
1061
999
  });
1062
1000
 
1063
1001
  window.addEventListener("keydown", function (e) {
1002
+ if (e.key === "Escape" && (!deleteDialog.hidden || !historyPanel.hidden)) {
1003
+ e.preventDefault();
1004
+ e.stopPropagation();
1005
+ if (!deleteDialog.hidden) closeDeleteDialog();
1006
+ else closeHistoryPanel();
1007
+ return;
1008
+ }
1064
1009
  if ((e.metaKey || e.ctrlKey) && e.key === "s") {
1065
1010
  e.preventDefault();
1066
1011
  if (isDirty()) save();
1067
1012
  }
1068
- if (e.key === "Escape") {
1069
- if (!deleteDialog.hidden) deleteDialog.hidden = true;
1070
- if (!historyPanel.hidden) {
1071
- historyPanel.hidden = true;
1072
- historyList.innerHTML = "";
1073
- }
1074
- }
1075
- });
1013
+ }, true);
1076
1014
 
1077
1015
  window.addEventListener("beforeunload", function (e) {
1078
1016
  if (isDirty()) { e.preventDefault(); e.returnValue = ""; }
1079
1017
  });
1080
1018
 
1081
1019
  /* ─── Load entry + schema ───────────────────────────────────────── */
1082
- Promise.all([
1083
- fetch(API + "/entries?collection=" + encodeURIComponent(COLLECTION) + "&id=" + encodeURIComponent(ID), { credentials: "same-origin" }),
1084
- fetch(API + "/schema?collection=" + encodeURIComponent(COLLECTION), { credentials: "same-origin" }),
1085
- ]).then(function (results) {
1086
- var entryRes = results[0];
1087
- var schemaRes = results[1];
1088
-
1089
- if (handleAuth(entryRes)) return;
1090
- if (!entryRes.ok) throw new Error("load failed");
1091
-
1092
- return entryRes.json().then(function (entryJson) {
1093
- var entry = (entryJson && entryJson.entries) ? entryJson.entries[0] : null;
1094
-
1095
- var schemaPromise = schemaRes.ok ? schemaRes.json() : Promise.resolve(null);
1096
- return schemaPromise.then(function (schemaJson) {
1097
- if (schemaJson && schemaJson.schema) entrySchema = schemaJson.schema;
1098
-
1020
+ entryLoader.load({
1021
+ collection: COLLECTION,
1022
+ id: ID,
1023
+ isNew: IS_NEW,
1024
+ initializeIfMissing: INITIALIZE_IF_MISSING,
1025
+ }).then(function (result) {
1026
+ if (result.kind === "unauthorized") { loginRedirect(); return; }
1027
+ if (result.kind === "error") {
1028
+ throw new Error(msg("entry." + result.code, msg("entry.loadFailed", "Could not load this entry. Retry after checking its source or storage.")));
1029
+ }
1030
+ entrySchema = result.schema;
1031
+ publicationFieldName = result.publicationFieldName;
1099
1032
  loadingEl.hidden = true;
1100
- if (!entry) {
1033
+ if (result.kind === "missing") {
1101
1034
  notFoundEl.hidden = false;
1102
1035
  return;
1103
1036
  }
1104
- entryData = deepClone(entry.data);
1105
- entryRevision = typeof entry.revision === "number" ? entry.revision : undefined;
1106
- originalJson = JSON.stringify(entry.data);
1037
+ var initialData = result.data;
1038
+ entryData = deepClone(initialData);
1039
+ entryRevision = result.revision;
1040
+ originalJson = JSON.stringify(initialData);
1107
1041
  titleEl.textContent = getTitle(entryData);
1108
1042
  renderFields(fieldsEl, entryData, entrySchema, "");
1043
+ if (validationWarningEl && result.validationIssues.length > 0) {
1044
+ validationWarningEl.textContent = "This stored entry has invalid fields: " + result.validationIssues.map(function (issue) {
1045
+ return (issue.path || "entry") + " — " + issue.message;
1046
+ }).join("; ") + ". Correct them and Save to restore public delivery.";
1047
+ validationWarningEl.hidden = false;
1048
+ }
1109
1049
  updateSaveButton();
1050
+ if (IS_NEW || result.initialized) {
1051
+ var guideTitle = document.getElementById("editor-guide-title");
1052
+ var guideCopy = document.getElementById("editor-guide-copy");
1053
+ if (guideTitle) guideTitle.textContent = msg("entry.newGuideTitle", "Entry created with safe defaults");
1054
+ if (guideCopy) guideCopy.textContent = msg("entry.newGuideCopy", "Fill in the fields below. Save becomes available after your first change. In the sidebar Studio, text and image edits preview on the page as you work; Save confirms them and refreshes the visual preview for structural changes. When publication is enabled, new entries stay private until Published is turned on.");
1055
+ }
1110
1056
  editorEl.hidden = false;
1111
- });
1112
- });
1113
- }).catch(function () {
1057
+ studioSync.announceReady();
1058
+ if (pendingRemoteSelection) {
1059
+ var pending = pendingRemoteSelection;
1060
+ pendingRemoteSelection = null;
1061
+ applyRemoteSelection(pending);
1062
+ }
1063
+ }).catch(function (error) {
1114
1064
  loadingEl.hidden = true;
1115
- notFoundEl.hidden = false;
1065
+ notFoundEl.hidden = true;
1066
+ /** @type {HTMLElement} */ (document.getElementById("load-error")).hidden = false;
1067
+ /** @type {HTMLElement} */ (document.getElementById("load-error-message")).textContent = error instanceof Error ? error.message : msg("entry.loadFailed", "Could not load this entry.");
1116
1068
  });
1069
+ /** @type {HTMLElement} */ (document.getElementById("load-error-retry")).addEventListener("click", function () { window.location.reload(); });
1117
1070
  })();