@caretcms/core 0.3.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.
- package/README.md +116 -11
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -5
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +8 -0
- package/dist/loader.js.map +1 -1
- package/dist/markdown/paragraphs.d.ts +14 -0
- package/dist/markdown/paragraphs.d.ts.map +1 -0
- package/dist/markdown/paragraphs.js +61 -0
- package/dist/markdown/paragraphs.js.map +1 -0
- package/dist/markdown/remark.d.ts.map +1 -1
- package/dist/markdown/remark.js +1 -0
- package/dist/markdown/remark.js.map +1 -1
- package/dist/markdown/satteri.d.ts.map +1 -1
- package/dist/markdown/satteri.js +3 -1
- package/dist/markdown/satteri.js.map +1 -1
- package/dist/markdown/stamp.d.ts +0 -9
- package/dist/markdown/stamp.d.ts.map +1 -1
- package/dist/markdown/stamp.js +6 -0
- package/dist/markdown/stamp.js.map +1 -1
- package/dist/providers/deployment/github.d.ts +27 -0
- package/dist/providers/deployment/github.d.ts.map +1 -0
- package/dist/providers/deployment/github.js +180 -0
- package/dist/providers/deployment/github.js.map +1 -0
- package/dist/providers/deployment/simulated.d.ts +10 -0
- package/dist/providers/deployment/simulated.d.ts.map +1 -0
- package/dist/providers/deployment/simulated.js +29 -0
- package/dist/providers/deployment/simulated.js.map +1 -0
- package/dist/runtime/authorization.d.ts +11 -0
- package/dist/runtime/authorization.d.ts.map +1 -0
- package/dist/runtime/authorization.js +29 -0
- package/dist/runtime/authorization.js.map +1 -0
- package/dist/runtime/collection-policy.d.ts +11 -0
- package/dist/runtime/collection-policy.d.ts.map +1 -0
- package/dist/runtime/collection-policy.js +44 -0
- package/dist/runtime/collection-policy.js.map +1 -0
- package/dist/runtime/content-errors.d.ts +9 -0
- package/dist/runtime/content-errors.d.ts.map +1 -0
- package/dist/runtime/content-errors.js +17 -0
- package/dist/runtime/content-errors.js.map +1 -0
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +13 -4
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/deployment-status.d.ts +14 -0
- package/dist/runtime/deployment-status.d.ts.map +1 -0
- package/dist/runtime/deployment-status.js +57 -0
- package/dist/runtime/deployment-status.js.map +1 -0
- package/dist/runtime/draft-state.d.ts +16 -0
- package/dist/runtime/draft-state.d.ts.map +1 -0
- package/dist/runtime/draft-state.js +29 -0
- package/dist/runtime/draft-state.js.map +1 -0
- package/dist/runtime/i18n.d.ts +13 -2
- package/dist/runtime/i18n.d.ts.map +1 -1
- package/dist/runtime/i18n.js +26 -4
- package/dist/runtime/i18n.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +21 -2
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +9 -0
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +45 -4
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +2 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +115 -63
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +3 -1
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +12 -1
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish-recovery.d.ts +32 -0
- package/dist/runtime/publish-recovery.d.ts.map +1 -0
- package/dist/runtime/publish-recovery.js +98 -0
- package/dist/runtime/publish-recovery.js.map +1 -0
- package/dist/runtime/publish.d.ts +10 -4
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +104 -67
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/rebuild-webhook.d.ts +4 -0
- package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
- package/dist/runtime/rebuild-webhook.js +12 -2
- package/dist/runtime/rebuild-webhook.js.map +1 -1
- package/dist/runtime/request-context.d.ts +3 -1
- package/dist/runtime/request-context.d.ts.map +1 -1
- package/dist/runtime/request-context.js.map +1 -1
- package/dist/runtime/rewrite.d.ts.map +1 -1
- package/dist/runtime/rewrite.js +28 -2
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.js +1 -1
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
- package/dist/runtime/routes/admin-entry.js +24 -12
- package/dist/runtime/routes/admin-entry.js.map +1 -1
- package/dist/runtime/routes/auth-session.d.ts.map +1 -1
- package/dist/runtime/routes/auth-session.js +6 -0
- package/dist/runtime/routes/auth-session.js.map +1 -1
- package/dist/runtime/routes/collection-list.d.ts.map +1 -1
- package/dist/runtime/routes/collection-list.js +164 -40
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/deployment.d.ts +5 -0
- package/dist/runtime/routes/deployment.d.ts.map +1 -0
- package/dist/runtime/routes/deployment.js +38 -0
- package/dist/runtime/routes/deployment.js.map +1 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -1
- package/dist/runtime/routes/draft.js +10 -3
- package/dist/runtime/routes/draft.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +74 -39
- package/dist/runtime/routes/entries.js.map +1 -1
- package/dist/runtime/routes/history.d.ts.map +1 -1
- package/dist/runtime/routes/history.js +37 -21
- package/dist/runtime/routes/history.js.map +1 -1
- package/dist/runtime/routes/mutate.d.ts.map +1 -1
- package/dist/runtime/routes/mutate.js +4 -1
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts.map +1 -1
- package/dist/runtime/routes/publish.js +127 -33
- package/dist/runtime/routes/publish.js.map +1 -1
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +4 -0
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/studio-home.d.ts.map +1 -1
- package/dist/runtime/routes/studio-home.js +3 -1
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +3 -0
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/schema-registry.d.ts.map +1 -1
- package/dist/runtime/schema-registry.js +2 -0
- package/dist/runtime/schema-registry.js.map +1 -1
- package/dist/runtime/storage/entry-commit.d.ts +8 -0
- package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
- package/dist/runtime/storage/entry-commit.js +45 -0
- package/dist/runtime/storage/entry-commit.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/filesystem-adapter.js +12 -3
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
- package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.js +156 -13
- package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.js +6 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +18 -20
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +100 -13
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
- package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.js +48 -5
- package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +3 -4
- package/dist/runtime/utils.js.map +1 -1
- package/dist/types.d.ts +123 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -3
- package/static/cms/admin-entry.css +9 -0
- package/static/cms/admin-entry.js +251 -687
- package/static/cms/dev-toolbar/app.js +78 -19
- package/static/cms/editor/config.js +35 -15
- package/static/cms/editor/content-map.css +8 -1
- package/static/cms/editor/content-map.js +62 -23
- package/static/cms/editor/deployment-status.js +66 -0
- package/static/cms/editor/draft-client.js +87 -0
- package/static/cms/editor/guards.js +20 -0
- package/static/cms/editor/helpers.js +30 -1
- package/static/cms/editor/highlight.js +1 -0
- package/static/cms/editor/image-edit.js +43 -1
- package/static/cms/editor/image-utils.js +23 -7
- package/static/cms/editor/link-follow.js +122 -41
- package/static/cms/editor/link-ui.css +10 -1
- package/static/cms/editor/linkify.js +2 -0
- package/static/cms/editor/md-block-edit.js +140 -37
- package/static/cms/editor/md-paragraphs.js +98 -0
- package/static/cms/editor/md-serialize.js +1 -1
- package/static/cms/editor/panel.js +43 -15
- package/static/cms/editor/preview-mode.js +44 -0
- package/static/cms/editor/publish-result.js +100 -0
- package/static/cms/editor/rich-toolbar.js +58 -15
- package/static/cms/editor/sanitize.js +6 -2
- package/static/cms/editor/save-queue.js +75 -17
- package/static/cms/editor/section-controls/activation.js +3 -0
- package/static/cms/editor/section-controls/api.js +25 -7
- package/static/cms/editor/section-controls/index.js +60 -8
- package/static/cms/editor/section-controls/model.js +39 -4
- package/static/cms/editor/section-controls/mutations.js +15 -0
- package/static/cms/editor/section-controls/page-context.js +9 -0
- package/static/cms/editor/section-controls/pickers.js +53 -2
- package/static/cms/editor/section-controls/reorder.js +23 -0
- package/static/cms/editor/section-controls/selection.js +6 -0
- package/static/cms/editor/section-controls/spacing-drag.js +16 -0
- package/static/cms/editor/section-controls/ui-elements.js +7 -0
- package/static/cms/editor/section-controls/utils.js +7 -0
- package/static/cms/editor/section-controls.css +18 -3
- package/static/cms/editor/security.js +13 -2
- package/static/cms/editor/stega-hydrate.js +17 -4
- package/static/cms/editor/sync.js +208 -10
- package/static/cms/editor/text-edit.js +37 -3
- package/static/cms/editor/text-link-interactions.js +59 -17
- package/static/cms/editor/toast.js +23 -9
- package/static/cms/editor/toolbar.css +5 -0
- package/static/cms/editor/toolbar.js +91 -101
- package/static/cms/editor.js +2 -1
- package/static/cms/studio/entry-loader.js +102 -0
- package/static/cms/studio/field-model.js +110 -0
- package/static/cms/studio/fields.js +319 -0
- package/static/cms/studio/history-client.js +74 -0
- package/static/cms/studio/mutation-client.js +74 -0
- package/static/cms/studio/sync-client.js +107 -0
- package/static/cms/studio/upload-client.js +109 -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
|
*
|
|
@@ -29,148 +41,115 @@
|
|
|
29
41
|
var pageRoot = document.getElementById("cms-entry-page");
|
|
30
42
|
if (!configEl || !pageRoot) return;
|
|
31
43
|
|
|
44
|
+
/** @type {EntryConfig} */
|
|
32
45
|
var CFG;
|
|
33
46
|
try {
|
|
34
|
-
CFG = JSON.parse(configEl.textContent || "{}");
|
|
47
|
+
CFG = /** @type {EntryConfig} */ (JSON.parse(configEl.textContent || "{}"));
|
|
35
48
|
} catch (e) {
|
|
36
49
|
console.error("[caret] invalid entry config", e);
|
|
37
50
|
return;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
var API = CFG.apiBasePath;
|
|
41
|
-
var MOUNT = CFG.mountPath;
|
|
42
|
-
var COLLECTION = CFG.collection;
|
|
43
|
-
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 : "";
|
|
44
57
|
var IS_NEW = CFG.isNew === true;
|
|
45
58
|
var INITIALIZE_IF_MISSING = CFG.initializeIfMissing === true;
|
|
46
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>} */
|
|
47
62
|
var MSG = CFG.messages || {};
|
|
63
|
+
/** @param {string} key @param {string} fallback */
|
|
48
64
|
function msg(key, fallback) { return typeof MSG[key] === "string" ? MSG[key] : fallback; }
|
|
65
|
+
/** @param {unknown} value */
|
|
49
66
|
function htmlEscape(value) {
|
|
67
|
+
/** @type {Record<string, string>} */
|
|
68
|
+
var entities = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
50
69
|
return String(value).replace(/[&<>"']/g, function (char) {
|
|
51
|
-
return
|
|
70
|
+
return entities[char] || char;
|
|
52
71
|
});
|
|
53
72
|
}
|
|
54
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 });
|
|
55
78
|
|
|
56
79
|
/* ─── State ─────────────────────────────────────────────────────── */
|
|
80
|
+
/** @type {Record<string, unknown> | null} */
|
|
57
81
|
var entryData = null;
|
|
82
|
+
/** @type {Schema | null} */
|
|
58
83
|
var entrySchema = null;
|
|
84
|
+
/** @type {number | undefined} */
|
|
59
85
|
var entryRevision = undefined;
|
|
60
86
|
var originalJson = "";
|
|
61
87
|
var saving = false;
|
|
62
88
|
var dirtyTimer = 0;
|
|
63
|
-
var previewTimer = 0;
|
|
64
89
|
var linkedSelectionTimer = 0;
|
|
90
|
+
/** @type {Record<string, unknown> | null} */
|
|
65
91
|
var pendingRemoteSelection = null;
|
|
66
|
-
|
|
67
|
-
var
|
|
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
|
+
});
|
|
68
107
|
|
|
69
108
|
/* ─── DOM refs ──────────────────────────────────────────────────── */
|
|
70
|
-
var loadingEl = document.getElementById("loading");
|
|
71
|
-
var notFoundEl = document.getElementById("not-found");
|
|
72
|
-
var editorEl = document.getElementById("editor");
|
|
73
|
-
var fieldsEl = document.getElementById("fields");
|
|
74
|
-
var titleEl = document.getElementById("entry-title");
|
|
75
|
-
var statusEl = document.getElementById("status-msg");
|
|
76
|
-
var validationWarningEl = document.getElementById("validation-warning");
|
|
77
|
-
var saveBtn = document.getElementById("btn-save");
|
|
78
|
-
var deleteBtn = document.getElementById("btn-delete");
|
|
79
|
-
var historyBtn = document.getElementById("btn-history");
|
|
80
|
-
var historyPanel = document.getElementById("history-panel");
|
|
81
|
-
var historyList = document.getElementById("history-list");
|
|
82
|
-
var historyCloseBtn = document.getElementById("btn-history-close");
|
|
83
|
-
var deleteDialog = document.getElementById("delete-dialog");
|
|
84
|
-
var deleteNameEl = document.getElementById("delete-entry-name");
|
|
85
|
-
var deleteCancelBtn = document.getElementById("btn-delete-cancel");
|
|
86
|
-
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;
|
|
87
128
|
|
|
88
129
|
/* ─── Helpers ───────────────────────────────────────────────────── */
|
|
89
|
-
function deepClone(v) { return JSON.parse(JSON.stringify(v)); }
|
|
90
|
-
|
|
91
|
-
function templateFromSchema(schema) {
|
|
92
|
-
if (!schema || typeof schema !== "object") return "";
|
|
93
|
-
if (schema.default !== undefined) return deepClone(schema.default);
|
|
94
|
-
if (Array.isArray(schema.enum) && schema.enum.length > 0) return deepClone(schema.enum[0]);
|
|
95
|
-
if (schema.type === "object") {
|
|
96
|
-
var objectTemplate = {};
|
|
97
|
-
Object.keys(schema.properties || {}).forEach(function (key) {
|
|
98
|
-
objectTemplate[key] = templateFromSchema(schema.properties[key]);
|
|
99
|
-
});
|
|
100
|
-
return objectTemplate;
|
|
101
|
-
}
|
|
102
|
-
if (schema.type === "array") return [];
|
|
103
|
-
if (schema.type === "boolean") return false;
|
|
104
|
-
if (schema.type === "number" || schema.type === "integer") {
|
|
105
|
-
return typeof schema.minimum === "number" ? schema.minimum : 0;
|
|
106
|
-
}
|
|
107
|
-
return "";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function generatedId(path, index) {
|
|
111
|
-
var parts = path.split(".").filter(Boolean);
|
|
112
|
-
var key = parts.pop() || "item";
|
|
113
|
-
if (/^\d+$/.test(key)) key = parts.pop() || "item";
|
|
114
|
-
key = key.replace(/ies$/, "y").replace(/s$/, "");
|
|
115
|
-
return (key + "-" + (index + 1) + "-" + Date.now().toString(36))
|
|
116
|
-
.toLowerCase()
|
|
117
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
118
|
-
.replace(/^-|-$/g, "");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function friendlyFileTitle(filename) {
|
|
122
|
-
return String(filename || "Image")
|
|
123
|
-
.replace(/\.[^.]+$/, "")
|
|
124
|
-
.replace(/[-_]+/g, " ")
|
|
125
|
-
.replace(/\s+/g, " ")
|
|
126
|
-
.trim()
|
|
127
|
-
.replace(/\b\w/g, function (c) { return c.toUpperCase(); }) || "Image";
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function singularItemLabel(path) {
|
|
131
|
-
var key = (path.split(".").filter(Boolean).pop() || "item").toLowerCase();
|
|
132
|
-
if (key === "images") return "image";
|
|
133
|
-
if (key === "details") return "detail";
|
|
134
|
-
if (key === "documents") return "document";
|
|
135
|
-
if (key === "videos") return "video";
|
|
136
|
-
if (key === "links") return "link";
|
|
137
|
-
if (key === "resume") return "résumé item";
|
|
138
|
-
return key.replace(/ies$/, "y").replace(/s$/, "") || "item";
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function getTitle(data) {
|
|
142
|
-
if (!data) return "Untitled";
|
|
143
|
-
var keys = ["name", "title", "question", "company_name", "headline", "label"];
|
|
144
|
-
for (var i = 0; i < keys.length; i++) {
|
|
145
|
-
if (typeof data[keys[i]] === "string" && data[keys[i]]) return data[keys[i]];
|
|
146
|
-
}
|
|
147
|
-
return "Untitled";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function humanizeKey(key) {
|
|
151
|
-
return key.replace(/_/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
152
|
-
}
|
|
153
130
|
|
|
154
131
|
function isDirty() {
|
|
155
132
|
return entryData !== null && JSON.stringify(entryData) !== originalJson;
|
|
156
133
|
}
|
|
157
134
|
|
|
158
|
-
|
|
135
|
+
/** @param {boolean} [preserveStatus] */
|
|
136
|
+
function updateSaveButton(preserveStatus) {
|
|
159
137
|
var dirty = isDirty();
|
|
160
|
-
saveBtn.disabled = !dirty || saving;
|
|
138
|
+
saveBtn.disabled = CFG.editable === false || !dirty || saving;
|
|
161
139
|
saveBtn.classList.remove("studio-save-dirty", "studio-save-clean", "studio-save-saving");
|
|
162
140
|
saveBtn.classList.add(saving ? "studio-save-saving" : (dirty ? "studio-save-dirty" : "studio-save-clean"));
|
|
163
141
|
saveBtn.textContent = saving ? msg("common.saving", "Saving…") : (SAVE_TARGET === "preview" ? msg("entry.saveDraft", "Save draft") : msg("entry.saveLive", "Save live"));
|
|
164
142
|
titleEl.textContent = getTitle(entryData);
|
|
165
|
-
if (entryData !== null) {
|
|
143
|
+
if (entryData !== null && !preserveStatus) {
|
|
166
144
|
if (saving) showStatus("saving", msg("entry.savingChanges", "Saving changes…"));
|
|
167
145
|
else if (dirty) showStatus("dirty", msg("entry.unsaved", "Unsaved changes"));
|
|
168
146
|
else showStatus("saved", msg("entry.allSaved", "All changes saved"));
|
|
169
147
|
}
|
|
170
148
|
}
|
|
171
149
|
|
|
172
|
-
|
|
173
|
-
|
|
150
|
+
/** @param {string} type @param {string} message */
|
|
151
|
+
function showStatus(type, message) {
|
|
152
|
+
statusEl.textContent = message;
|
|
174
153
|
statusEl.hidden = false;
|
|
175
154
|
statusEl.dataset.status = type;
|
|
176
155
|
statusEl.style.color = type === "saved"
|
|
@@ -178,151 +157,31 @@
|
|
|
178
157
|
: (type === "dirty" || type === "saving" ? "var(--studio-text-muted)" : "var(--studio-red)");
|
|
179
158
|
}
|
|
180
159
|
|
|
181
|
-
|
|
182
|
-
var keys = path.split(".");
|
|
183
|
-
var cur = obj;
|
|
184
|
-
for (var i = 0; i < keys.length - 1; i++) {
|
|
185
|
-
var k = keys[i];
|
|
186
|
-
if (typeof cur[k] !== "object" || cur[k] === null) {
|
|
187
|
-
cur[k] = /^\d+$/.test(keys[i + 1]) ? [] : {};
|
|
188
|
-
}
|
|
189
|
-
cur = cur[k];
|
|
190
|
-
}
|
|
191
|
-
cur[keys[keys.length - 1]] = value;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function getNestedValue(obj, path) {
|
|
195
|
-
return path.split(".").reduce(function (o, k) { return o == null ? undefined : o[k]; }, obj);
|
|
196
|
-
}
|
|
197
|
-
|
|
160
|
+
/** @param {string} path @param {unknown} value */
|
|
198
161
|
function updateField(path, value) {
|
|
162
|
+
if (!entryData) return;
|
|
199
163
|
setNestedValue(entryData, path, value);
|
|
200
164
|
if (!saving) {
|
|
201
|
-
saveBtn.disabled = false;
|
|
165
|
+
saveBtn.disabled = CFG.editable === false;
|
|
202
166
|
saveBtn.classList.remove("studio-save-clean", "studio-save-saving");
|
|
203
167
|
saveBtn.classList.add("studio-save-dirty");
|
|
204
168
|
}
|
|
205
169
|
clearTimeout(dirtyTimer);
|
|
206
170
|
dirtyTimer = setTimeout(updateSaveButton, 300);
|
|
207
|
-
|
|
171
|
+
studioSync.queuePreview(entryData);
|
|
208
172
|
}
|
|
209
173
|
|
|
210
174
|
function loginRedirect() {
|
|
211
175
|
window.location.href = MOUNT + "?redirect=" + encodeURIComponent(window.location.pathname);
|
|
212
176
|
}
|
|
213
177
|
|
|
178
|
+
/** @param {Response} res */
|
|
214
179
|
function handleAuth(res) {
|
|
215
180
|
if (res.status === 401) { loginRedirect(); return true; }
|
|
216
181
|
return false;
|
|
217
182
|
}
|
|
218
183
|
|
|
219
|
-
|
|
220
|
-
if (window.parent === window || !entryData) return;
|
|
221
|
-
clearTimeout(previewTimer);
|
|
222
|
-
previewTimer = setTimeout(function () {
|
|
223
|
-
try {
|
|
224
|
-
window.parent.postMessage({
|
|
225
|
-
type: "cms:preview",
|
|
226
|
-
collection: COLLECTION,
|
|
227
|
-
id: ID,
|
|
228
|
-
data: deepClone(entryData),
|
|
229
|
-
embedded: true,
|
|
230
|
-
studioPath: window.location.pathname + window.location.search,
|
|
231
|
-
}, window.location.origin);
|
|
232
|
-
} catch (e) { /* visual preview is a progressive enhancement */ }
|
|
233
|
-
}, 80);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function announceChange(type, data) {
|
|
237
|
-
var message = {
|
|
238
|
-
type: type,
|
|
239
|
-
collection: COLLECTION,
|
|
240
|
-
id: ID,
|
|
241
|
-
data: data ? deepClone(data) : null,
|
|
242
|
-
embedded: window.parent !== window,
|
|
243
|
-
studioPath: window.location.pathname + window.location.search,
|
|
244
|
-
savedAt: Date.now(),
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
if (window.parent !== window) {
|
|
248
|
-
try { window.parent.postMessage(message, window.location.origin); } catch (e) { /* noop */ }
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if ("BroadcastChannel" in window) {
|
|
252
|
-
try {
|
|
253
|
-
var channel = new BroadcastChannel(SYNC_CHANNEL);
|
|
254
|
-
channel.postMessage(message);
|
|
255
|
-
setTimeout(function () { channel.close(); }, 0);
|
|
256
|
-
} catch (e) { /* automatic preview sync is a progressive enhancement */ }
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/* ─── Image compression + upload ────────────────────────────────── */
|
|
261
|
-
function compressImage(file, maxWidth, quality) {
|
|
262
|
-
maxWidth = maxWidth || 1600;
|
|
263
|
-
quality = quality || 0.82;
|
|
264
|
-
return Promise.resolve().then(function () {
|
|
265
|
-
if (file.size < 200000 && file.type === "image/webp") return file;
|
|
266
|
-
return createImageBitmap(file).then(function (bitmap) {
|
|
267
|
-
var width = bitmap.width;
|
|
268
|
-
var height = bitmap.height;
|
|
269
|
-
var needsResize = width > maxWidth;
|
|
270
|
-
if (file.type === "image/png" && !needsResize) {
|
|
271
|
-
bitmap.close();
|
|
272
|
-
return file;
|
|
273
|
-
}
|
|
274
|
-
if (needsResize) {
|
|
275
|
-
height = Math.round((height * maxWidth) / width);
|
|
276
|
-
width = maxWidth;
|
|
277
|
-
}
|
|
278
|
-
var canvas = document.createElement("canvas");
|
|
279
|
-
canvas.width = width;
|
|
280
|
-
canvas.height = height;
|
|
281
|
-
canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height);
|
|
282
|
-
bitmap.close();
|
|
283
|
-
var isPng = file.type === "image/png";
|
|
284
|
-
var outType = isPng ? "image/png" : "image/webp";
|
|
285
|
-
return new Promise(function (resolve) {
|
|
286
|
-
canvas.toBlob(function (blob) {
|
|
287
|
-
if (!blob || blob.size >= file.size) return resolve(file);
|
|
288
|
-
var ext = isPng ? ".png" : ".webp";
|
|
289
|
-
resolve(new File([blob], file.name.replace(/\.[^.]+$/, ext), { type: outType }));
|
|
290
|
-
}, outType, isPng ? undefined : quality);
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function uploadFile(file) {
|
|
297
|
-
return compressImage(file).then(function (compressed) {
|
|
298
|
-
var fd = new FormData();
|
|
299
|
-
fd.append("file", compressed);
|
|
300
|
-
return fetch(API + "/upload", {
|
|
301
|
-
method: "POST",
|
|
302
|
-
body: fd,
|
|
303
|
-
headers: { "x-caret-request": "1" },
|
|
304
|
-
credentials: "same-origin",
|
|
305
|
-
});
|
|
306
|
-
}).then(function (res) {
|
|
307
|
-
if (handleAuth(res)) throw new Error("Unauthorized");
|
|
308
|
-
if (!res.ok) throw new Error("Upload failed");
|
|
309
|
-
return res.json();
|
|
310
|
-
}).then(function (json) { return json.url; });
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function readImageDimensions(file) {
|
|
314
|
-
return createImageBitmap(file).then(function (bitmap) {
|
|
315
|
-
var width = bitmap.width;
|
|
316
|
-
var height = bitmap.height;
|
|
317
|
-
bitmap.close();
|
|
318
|
-
if (width > 1600) {
|
|
319
|
-
height = Math.round((height * 1600) / width);
|
|
320
|
-
width = 1600;
|
|
321
|
-
}
|
|
322
|
-
return { width: width, height: height };
|
|
323
|
-
}).catch(function () { return null; });
|
|
324
|
-
}
|
|
325
|
-
|
|
184
|
+
/** @param {string} path @param {File} file @param {{ width: number, height: number } | null} dimensions */
|
|
326
185
|
function populateImageObject(path, file, dimensions) {
|
|
327
186
|
var parts = path.split(".");
|
|
328
187
|
if (parts.length < 2 || parts[parts.length - 1] !== "src") return;
|
|
@@ -330,7 +189,7 @@
|
|
|
330
189
|
var current = getNestedValue(entryData, parentPath);
|
|
331
190
|
if (!current || typeof current !== "object" || Array.isArray(current)) return;
|
|
332
191
|
|
|
333
|
-
var next = deepClone(current);
|
|
192
|
+
var next = /** @type {Record<string, unknown>} */ (deepClone(current));
|
|
334
193
|
var title = friendlyFileTitle(file.name);
|
|
335
194
|
if (Object.prototype.hasOwnProperty.call(next, "id") && !next.id) {
|
|
336
195
|
next.id = generatedId(parentPath, Number(parts[parts.length - 2]) || 0);
|
|
@@ -360,79 +219,76 @@
|
|
|
360
219
|
}
|
|
361
220
|
saving = true;
|
|
362
221
|
updateSaveButton();
|
|
222
|
+
var preserveSaveStatus = false;
|
|
223
|
+
/** @type {{ collection: string, id: string, data: Record<string, unknown>, expectedRevision?: number }} */
|
|
363
224
|
var payload = {
|
|
364
|
-
type: "put_entry",
|
|
365
225
|
collection: COLLECTION,
|
|
366
226
|
id: ID,
|
|
367
227
|
data: entryData,
|
|
368
228
|
};
|
|
369
229
|
if (typeof entryRevision === "number") payload.expectedRevision = entryRevision;
|
|
370
230
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (handleAuth(res)) return null;
|
|
378
|
-
return res.json().catch(function () { return null; }).then(function (body) {
|
|
379
|
-
return { ok: res.ok, status: res.status, body: body };
|
|
380
|
-
});
|
|
381
|
-
}).then(function (result) {
|
|
382
|
-
if (!result) return;
|
|
383
|
-
if (!result.ok) {
|
|
384
|
-
if (result.body && result.body.issues) {
|
|
385
|
-
showValidationErrors(result.body.issues);
|
|
386
|
-
showStatus("error", msg("entry.validationFailed", "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 + ")");
|
|
387
237
|
var firstErr = fieldsEl.querySelector(".studio-field-error");
|
|
388
238
|
if (firstErr) firstErr.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
389
239
|
return;
|
|
390
|
-
|
|
391
|
-
|
|
240
|
+
}
|
|
241
|
+
if (result.kind === "conflict") {
|
|
242
|
+
preserveSaveStatus = true;
|
|
392
243
|
// Your edits are still in the form; we've refreshed to the server's
|
|
393
244
|
// revision, so saving again overwrites. Don't tell the user to reload
|
|
394
245
|
// (that would throw their edits away).
|
|
395
|
-
entryRevision = result.
|
|
246
|
+
entryRevision = result.currentRevision;
|
|
396
247
|
showStatus("error", msg("entry.changedElsewhere", "Changed elsewhere — Save again to overwrite"));
|
|
397
248
|
return;
|
|
398
|
-
}
|
|
399
|
-
throw new Error("save failed");
|
|
400
249
|
}
|
|
250
|
+
if (result.kind === "error") throw new Error("save failed");
|
|
401
251
|
clearFieldErrors();
|
|
402
|
-
if (
|
|
252
|
+
if (typeof result.revision === "number") entryRevision = result.revision;
|
|
403
253
|
originalJson = JSON.stringify(entryData);
|
|
404
|
-
announceChange("cms:saved", entryData);
|
|
254
|
+
studioSync.announceChange("cms:saved", entryData);
|
|
405
255
|
showStatus("saved", msg("entry.saved", "Saved"));
|
|
406
256
|
}).catch(function () {
|
|
257
|
+
preserveSaveStatus = true;
|
|
407
258
|
showStatus("error", msg("common.error", "Error"));
|
|
408
259
|
}).then(function () {
|
|
409
260
|
saving = false;
|
|
410
|
-
updateSaveButton();
|
|
261
|
+
updateSaveButton(preserveSaveStatus);
|
|
411
262
|
});
|
|
412
263
|
}
|
|
413
264
|
|
|
414
265
|
function deleteEntry() {
|
|
415
266
|
deleteNameEl.textContent = ID;
|
|
267
|
+
deleteRestoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : deleteBtn;
|
|
416
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();
|
|
417
277
|
}
|
|
418
278
|
|
|
419
279
|
function confirmDelete() {
|
|
420
280
|
deleteConfirmBtn.disabled = true;
|
|
421
281
|
deleteConfirmBtn.textContent = msg("entry.deleting", "Deleting…");
|
|
422
|
-
|
|
282
|
+
/** @type {{ collection: string, id: string, expectedRevision?: number }} */
|
|
283
|
+
var payload = { collection: COLLECTION, id: ID };
|
|
423
284
|
if (typeof entryRevision === "number") payload.expectedRevision = entryRevision;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
body: JSON.stringify(payload),
|
|
429
|
-
}).then(function (res) {
|
|
430
|
-
if (handleAuth(res)) return;
|
|
431
|
-
if (!res.ok) throw new Error("delete failed");
|
|
432
|
-
announceChange("cms:deleted", null);
|
|
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);
|
|
433
289
|
window.location.href = MOUNT + "/cms/" + encodeURIComponent(COLLECTION);
|
|
434
290
|
}).catch(function () {
|
|
435
|
-
|
|
291
|
+
closeDeleteDialog();
|
|
436
292
|
deleteConfirmBtn.disabled = false;
|
|
437
293
|
deleteConfirmBtn.textContent = msg("entry.delete", "Delete");
|
|
438
294
|
showStatus("error", msg("entry.deleteFailed", "Delete failed"));
|
|
@@ -440,6 +296,7 @@
|
|
|
440
296
|
}
|
|
441
297
|
|
|
442
298
|
/* ─── Validation rendering ──────────────────────────────────────── */
|
|
299
|
+
/** @param {Element} group @param {string} message */
|
|
443
300
|
function showFieldError(group, message) {
|
|
444
301
|
var errEl = group.querySelector(".studio-error-text");
|
|
445
302
|
if (message) {
|
|
@@ -449,7 +306,7 @@
|
|
|
449
306
|
group.appendChild(errEl);
|
|
450
307
|
}
|
|
451
308
|
errEl.textContent = message;
|
|
452
|
-
errEl.hidden = false;
|
|
309
|
+
/** @type {HTMLElement} */ (errEl).hidden = false;
|
|
453
310
|
group.classList.add("studio-field-error");
|
|
454
311
|
} else if (errEl) {
|
|
455
312
|
errEl.remove();
|
|
@@ -462,6 +319,7 @@
|
|
|
462
319
|
fieldsEl.querySelectorAll(".caret-field-group").forEach(function (el) { el.classList.remove("studio-field-error"); });
|
|
463
320
|
}
|
|
464
321
|
|
|
322
|
+
/** @param {ValidationIssue[]} issues */
|
|
465
323
|
function showValidationErrors(issues) {
|
|
466
324
|
clearFieldErrors();
|
|
467
325
|
issues.forEach(function (issue) {
|
|
@@ -470,15 +328,18 @@
|
|
|
470
328
|
});
|
|
471
329
|
}
|
|
472
330
|
|
|
331
|
+
/** @param {string} value */
|
|
473
332
|
function cssEscape(value) {
|
|
474
333
|
if (window.CSS && CSS.escape) return CSS.escape(value);
|
|
475
334
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
|
476
335
|
}
|
|
477
336
|
|
|
337
|
+
/** @param {string} path */
|
|
478
338
|
function fieldControl(path) {
|
|
479
339
|
return fieldsEl.querySelector("#" + cssEscape(fieldIdFromPath(path)));
|
|
480
340
|
}
|
|
481
341
|
|
|
342
|
+
/** @param {Element} group */
|
|
482
343
|
function showLinkedSelection(group) {
|
|
483
344
|
window.clearTimeout(linkedSelectionTimer);
|
|
484
345
|
fieldsEl.querySelectorAll(".caret-field-selected").forEach(function (selected) {
|
|
@@ -490,6 +351,7 @@
|
|
|
490
351
|
}, 1500);
|
|
491
352
|
}
|
|
492
353
|
|
|
354
|
+
/** @param {Record<string, unknown>} message */
|
|
493
355
|
function applyRemoteSelection(message) {
|
|
494
356
|
if (
|
|
495
357
|
!message
|
|
@@ -510,331 +372,33 @@
|
|
|
510
372
|
group.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
|
|
511
373
|
}
|
|
512
374
|
|
|
513
|
-
|
|
514
|
-
var message = {
|
|
515
|
-
type: "cms:field-selected",
|
|
516
|
-
collection: COLLECTION,
|
|
517
|
-
id: ID,
|
|
518
|
-
field: path,
|
|
519
|
-
source: "studio",
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
if (window.parent !== window) {
|
|
523
|
-
try {
|
|
524
|
-
window.parent.postMessage(Object.assign({ embedded: true }, message), window.location.origin);
|
|
525
|
-
} catch (e) { /* embedded selection linking is a progressive enhancement */ }
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
try {
|
|
530
|
-
if (syncChannel) syncChannel.postMessage(message);
|
|
531
|
-
} catch (e) { /* cross-tab selection linking is a progressive enhancement */ }
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/* ─── Field group ───────────────────────────────────────────────── */
|
|
535
|
-
function fieldIdFromPath(path) {
|
|
536
|
-
return "caret-field-" + String(path || "").replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function createFieldGroup(label, isRequired, inline) {
|
|
540
|
-
var wrapper = document.createElement("div");
|
|
541
|
-
wrapper.className = "caret-field-group" + (inline ? " caret-field-inline" : "");
|
|
542
|
-
|
|
543
|
-
var labelEl = document.createElement("label");
|
|
544
|
-
labelEl.className = "studio-label";
|
|
545
|
-
labelEl.textContent = label;
|
|
546
|
-
if (isRequired === false) {
|
|
547
|
-
var opt = document.createElement("span");
|
|
548
|
-
opt.textContent = " (optional)";
|
|
549
|
-
opt.style.cssText = "color:var(--studio-text-dim);font-weight:400;text-transform:none;letter-spacing:normal;";
|
|
550
|
-
labelEl.appendChild(opt);
|
|
551
|
-
}
|
|
552
|
-
wrapper.appendChild(labelEl);
|
|
553
|
-
|
|
554
|
-
// Once children are populated, link the label to the first form control
|
|
555
|
-
// for screen-reader accessibility. Path is read from data-fieldPath
|
|
556
|
-
// (set by the caller after createFieldGroup returns).
|
|
557
|
-
queueMicrotask(function () {
|
|
558
|
-
var path = wrapper.dataset.fieldPath;
|
|
559
|
-
if (!path) return;
|
|
560
|
-
// Nested object/array groups contain other field groups. Only claim a
|
|
561
|
-
// control whose nearest field-group owner is this wrapper; otherwise an
|
|
562
|
-
// outer `hero` group can overwrite `hero.headline`'s stable identity.
|
|
563
|
-
var control = Array.from(wrapper.querySelectorAll("input, select, textarea")).find(function (candidate) {
|
|
564
|
-
return candidate.closest(".caret-field-group") === wrapper;
|
|
565
|
-
});
|
|
566
|
-
if (!control) return;
|
|
567
|
-
var fid = fieldIdFromPath(path);
|
|
568
|
-
control.id = fid;
|
|
569
|
-
control.name = path;
|
|
570
|
-
labelEl.htmlFor = control.id;
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
return wrapper;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/* ─── Schema-driven rendering ───────────────────────────────────── */
|
|
577
|
-
function renderFields(container, data, schema, basePath) {
|
|
578
|
-
if (!schema || !schema.properties) {
|
|
579
|
-
renderFieldsFallback(container, data, basePath);
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
var required = new Set(schema.required || []);
|
|
584
|
-
|
|
585
|
-
Object.keys(schema.properties).forEach(function (key) {
|
|
586
|
-
var prop = schema.properties[key];
|
|
587
|
-
var path = basePath ? basePath + "." + key : key;
|
|
588
|
-
var label = prop.title || humanizeKey(key);
|
|
589
|
-
var isRequired = required.has(key);
|
|
590
|
-
var value = data ? data[key] : undefined;
|
|
375
|
+
/* ─── Field rendering ───────────────────────────────────────────── */
|
|
591
376
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (prop.type === "string" && prop.enum) {
|
|
601
|
-
group = createFieldGroup(label, isRequired);
|
|
602
|
-
group.dataset.fieldPath = path;
|
|
603
|
-
var select = document.createElement("select");
|
|
604
|
-
select.className = "studio-input";
|
|
605
|
-
prop.enum.forEach(function (opt) {
|
|
606
|
-
var option = document.createElement("option");
|
|
607
|
-
option.value = opt;
|
|
608
|
-
option.textContent = String(opt).charAt(0).toUpperCase() + String(opt).slice(1);
|
|
609
|
-
if (opt === current) option.selected = true;
|
|
610
|
-
select.appendChild(option);
|
|
611
|
-
});
|
|
612
|
-
select.addEventListener("change", function () { updateField(path, select.value); });
|
|
613
|
-
group.appendChild(select);
|
|
614
|
-
} else if (prop.type === "string" && prop.format === "image") {
|
|
615
|
-
group = createFieldGroup(label, isRequired);
|
|
616
|
-
group.dataset.fieldPath = path;
|
|
617
|
-
group.appendChild(buildSingleImage(current || "", path));
|
|
618
|
-
} else if (prop.type === "string" && (prop.format === "textarea" || prop.format === "html")) {
|
|
619
|
-
group = createFieldGroup(label, isRequired);
|
|
620
|
-
group.dataset.fieldPath = path;
|
|
621
|
-
var ta = document.createElement("textarea");
|
|
622
|
-
ta.className = "studio-input";
|
|
623
|
-
ta.rows = 4;
|
|
624
|
-
ta.value = current || "";
|
|
625
|
-
ta.addEventListener("input", function () { updateField(path, ta.value); });
|
|
626
|
-
group.appendChild(ta);
|
|
627
|
-
} else if (prop.type === "string" && (prop.format === "email" || prop.format === "uri" || prop.format === "url")) {
|
|
628
|
-
group = createFieldGroup(label, isRequired);
|
|
629
|
-
group.dataset.fieldPath = path;
|
|
630
|
-
var emailInput = document.createElement("input");
|
|
631
|
-
emailInput.type = prop.format === "email" ? "email" : "url";
|
|
632
|
-
emailInput.className = "studio-input";
|
|
633
|
-
emailInput.value = current || "";
|
|
634
|
-
emailInput.addEventListener("input", function () { updateField(path, emailInput.value); });
|
|
635
|
-
group.appendChild(emailInput);
|
|
636
|
-
} else if (prop.type === "string") {
|
|
637
|
-
group = createFieldGroup(label, isRequired);
|
|
638
|
-
group.dataset.fieldPath = path;
|
|
639
|
-
var input;
|
|
640
|
-
if (typeof current === "string" && current.length > 100) {
|
|
641
|
-
input = document.createElement("textarea");
|
|
642
|
-
input.rows = 4;
|
|
643
|
-
} else {
|
|
644
|
-
input = document.createElement("input");
|
|
645
|
-
input.type = "text";
|
|
646
|
-
}
|
|
647
|
-
input.className = "studio-input";
|
|
648
|
-
input.value = current || "";
|
|
649
|
-
input.addEventListener("input", function () { updateField(path, input.value); });
|
|
650
|
-
group.appendChild(input);
|
|
651
|
-
} else if (prop.type === "number" || prop.type === "integer") {
|
|
652
|
-
group = createFieldGroup(label, isRequired);
|
|
653
|
-
group.dataset.fieldPath = path;
|
|
654
|
-
var num = document.createElement("input");
|
|
655
|
-
num.type = "number";
|
|
656
|
-
num.className = "studio-input";
|
|
657
|
-
num.value = (current == null ? 0 : current);
|
|
658
|
-
if (prop.minimum !== undefined) num.min = prop.minimum;
|
|
659
|
-
if (prop.maximum !== undefined) num.max = prop.maximum;
|
|
660
|
-
num.addEventListener("input", function () { updateField(path, Number(num.value) || 0); });
|
|
661
|
-
group.appendChild(num);
|
|
662
|
-
} else if (prop.type === "boolean") {
|
|
663
|
-
group = createFieldGroup(label, isRequired, true);
|
|
664
|
-
group.dataset.fieldPath = path;
|
|
665
|
-
var toggle = document.createElement("button");
|
|
666
|
-
toggle.type = "button";
|
|
667
|
-
toggle.className = "studio-toggle" + (current ? " active" : "");
|
|
668
|
-
toggle.addEventListener("click", function () {
|
|
669
|
-
var next = !toggle.classList.contains("active");
|
|
670
|
-
toggle.classList.toggle("active");
|
|
671
|
-
updateField(path, next);
|
|
672
|
-
});
|
|
673
|
-
toggle.setAttribute("role", "switch");
|
|
674
|
-
toggle.setAttribute("aria-label", label);
|
|
675
|
-
toggle.setAttribute("aria-checked", current ? "true" : "false");
|
|
676
|
-
toggle.addEventListener("click", function () {
|
|
677
|
-
toggle.setAttribute("aria-checked", toggle.classList.contains("active") ? "true" : "false");
|
|
678
|
-
});
|
|
679
|
-
group.appendChild(toggle);
|
|
680
|
-
if (key === "published") {
|
|
681
|
-
group.classList.add("caret-field-with-help");
|
|
682
|
-
var publishHelp = document.createElement("p");
|
|
683
|
-
publishHelp.className = "caret-field-help";
|
|
684
|
-
publishHelp.textContent = "Turn this on and Save when the entry is ready to appear on the live site.";
|
|
685
|
-
group.appendChild(publishHelp);
|
|
686
|
-
}
|
|
687
|
-
} else if (prop.type === "array" && prop.format === "image-gallery") {
|
|
688
|
-
group = createFieldGroup(label, isRequired);
|
|
689
|
-
group.dataset.fieldPath = path;
|
|
690
|
-
group.appendChild(buildImageGallery(current || [], path));
|
|
691
|
-
} else if (prop.type === "array" && prop.items && prop.items.type === "object") {
|
|
692
|
-
group = createFieldGroup(label, isRequired);
|
|
693
|
-
group.dataset.fieldPath = path;
|
|
694
|
-
group.appendChild(buildObjectArray(current || [], path, prop.items));
|
|
695
|
-
} else if (prop.type === "array" && prop.items && prop.items.type === "string") {
|
|
696
|
-
group = createFieldGroup(label, isRequired);
|
|
697
|
-
group.dataset.fieldPath = path;
|
|
698
|
-
group.appendChild(buildTagList(current || [], path));
|
|
699
|
-
} else if (prop.type === "object" && prop.properties) {
|
|
700
|
-
group = createFieldGroup(label, isRequired);
|
|
701
|
-
group.dataset.fieldPath = path;
|
|
702
|
-
var nested = document.createElement("div");
|
|
703
|
-
nested.className = "caret-nested";
|
|
704
|
-
renderFields(nested, current || {}, prop, path);
|
|
705
|
-
group.appendChild(nested);
|
|
706
|
-
} else {
|
|
707
|
-
group = createFieldGroup(label, isRequired);
|
|
708
|
-
group.dataset.fieldPath = path;
|
|
709
|
-
var jsonTa = document.createElement("textarea");
|
|
710
|
-
jsonTa.className = "studio-input";
|
|
711
|
-
jsonTa.style.fontFamily = "var(--studio-font-mono)";
|
|
712
|
-
jsonTa.style.fontSize = "11px";
|
|
713
|
-
jsonTa.rows = 4;
|
|
714
|
-
jsonTa.value = JSON.stringify(current, null, 2);
|
|
715
|
-
jsonTa.addEventListener("input", function () {
|
|
716
|
-
try { updateField(path, JSON.parse(jsonTa.value)); } catch (e) { /* ignore until valid */ }
|
|
717
|
-
});
|
|
718
|
-
group.appendChild(jsonTa);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
container.appendChild(group);
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
function renderFieldsFallback(container, data, basePath) {
|
|
726
|
-
if (!data || typeof data !== "object") return;
|
|
727
|
-
Object.keys(data).forEach(function (key) {
|
|
728
|
-
var value = data[key];
|
|
729
|
-
var path = basePath ? basePath + "." + key : key;
|
|
730
|
-
var label = humanizeKey(key);
|
|
731
|
-
|
|
732
|
-
if (key === "images" && Array.isArray(value) && value.every(function (v) { return typeof v === "string"; })) {
|
|
733
|
-
var g = createFieldGroup(label, true);
|
|
734
|
-
g.dataset.fieldPath = path;
|
|
735
|
-
g.appendChild(buildImageGallery(value, path));
|
|
736
|
-
container.appendChild(g);
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
if ((key === "image" || key === "thumbnail" || key === "bg_image") && typeof value === "string") {
|
|
740
|
-
var g2 = createFieldGroup(label, true);
|
|
741
|
-
g2.dataset.fieldPath = path;
|
|
742
|
-
g2.appendChild(buildSingleImage(value, path));
|
|
743
|
-
container.appendChild(g2);
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
if (Array.isArray(value) && value.every(function (v) { return typeof v === "string"; })) {
|
|
747
|
-
var g3 = createFieldGroup(label, true);
|
|
748
|
-
g3.dataset.fieldPath = path;
|
|
749
|
-
g3.appendChild(buildTagList(value, path));
|
|
750
|
-
container.appendChild(g3);
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
if (Array.isArray(value) && value.every(function (v) { return typeof v === "object" && v !== null && !Array.isArray(v); })) {
|
|
754
|
-
var g4 = createFieldGroup(label, true);
|
|
755
|
-
g4.dataset.fieldPath = path;
|
|
756
|
-
g4.appendChild(buildObjectArray(value, path, null));
|
|
757
|
-
container.appendChild(g4);
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
761
|
-
var g5 = createFieldGroup(label, true);
|
|
762
|
-
g5.dataset.fieldPath = path;
|
|
763
|
-
var nested = document.createElement("div");
|
|
764
|
-
nested.className = "caret-nested";
|
|
765
|
-
renderFieldsFallback(nested, value, path);
|
|
766
|
-
g5.appendChild(nested);
|
|
767
|
-
container.appendChild(g5);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
if (typeof value === "boolean") {
|
|
771
|
-
var g6 = createFieldGroup(label, true, true);
|
|
772
|
-
g6.dataset.fieldPath = path;
|
|
773
|
-
var t = document.createElement("button");
|
|
774
|
-
t.type = "button";
|
|
775
|
-
t.className = "studio-toggle" + (value ? " active" : "");
|
|
776
|
-
t.addEventListener("click", function () {
|
|
777
|
-
var next = !t.classList.contains("active");
|
|
778
|
-
t.classList.toggle("active");
|
|
779
|
-
updateField(path, next);
|
|
780
|
-
});
|
|
781
|
-
g6.appendChild(t);
|
|
782
|
-
container.appendChild(g6);
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
if (typeof value === "number") {
|
|
786
|
-
var g7 = createFieldGroup(label, true);
|
|
787
|
-
g7.dataset.fieldPath = path;
|
|
788
|
-
var n = document.createElement("input");
|
|
789
|
-
n.type = "number";
|
|
790
|
-
n.className = "studio-input";
|
|
791
|
-
n.value = value;
|
|
792
|
-
n.addEventListener("input", function () { updateField(path, Number(n.value) || 0); });
|
|
793
|
-
g7.appendChild(n);
|
|
794
|
-
container.appendChild(g7);
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
|
-
if (typeof value === "string") {
|
|
798
|
-
var g8 = createFieldGroup(label, true);
|
|
799
|
-
g8.dataset.fieldPath = path;
|
|
800
|
-
var i;
|
|
801
|
-
if (value.length > 100) { i = document.createElement("textarea"); i.rows = 4; }
|
|
802
|
-
else { i = document.createElement("input"); i.type = "text"; }
|
|
803
|
-
i.className = "studio-input";
|
|
804
|
-
i.value = value;
|
|
805
|
-
i.addEventListener("input", function () { updateField(path, i.value); });
|
|
806
|
-
g8.appendChild(i);
|
|
807
|
-
container.appendChild(g8);
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
// Fallback to JSON
|
|
811
|
-
var g9 = createFieldGroup(label, true);
|
|
812
|
-
g9.dataset.fieldPath = path;
|
|
813
|
-
var jt = document.createElement("textarea");
|
|
814
|
-
jt.className = "studio-input";
|
|
815
|
-
jt.style.fontFamily = "var(--studio-font-mono)";
|
|
816
|
-
jt.style.fontSize = "11px";
|
|
817
|
-
jt.rows = 4;
|
|
818
|
-
jt.value = JSON.stringify(value, null, 2);
|
|
819
|
-
jt.addEventListener("input", function () {
|
|
820
|
-
try { updateField(path, JSON.parse(jt.value)); } catch (e) { /* ignore */ }
|
|
821
|
-
});
|
|
822
|
-
g9.appendChild(jt);
|
|
823
|
-
container.appendChild(g9);
|
|
824
|
-
});
|
|
825
|
-
}
|
|
377
|
+
const { renderFields, renderFieldsFallback } = createFieldRenderer({
|
|
378
|
+
updateField,
|
|
379
|
+
buildSingleImage,
|
|
380
|
+
buildImageGallery,
|
|
381
|
+
buildObjectArray,
|
|
382
|
+
buildTagList,
|
|
383
|
+
getPublicationField: () => publicationFieldName,
|
|
384
|
+
});
|
|
826
385
|
|
|
827
386
|
/* ─── Image gallery ─────────────────────────────────────────────── */
|
|
387
|
+
/** @param {unknown} images @param {string} path */
|
|
828
388
|
function buildImageGallery(images, path) {
|
|
829
389
|
var wrapper = document.createElement("div");
|
|
830
390
|
|
|
831
391
|
function rerender() {
|
|
832
392
|
wrapper.innerHTML = "";
|
|
833
|
-
var
|
|
393
|
+
var galleryValue = getNestedValue(entryData, path);
|
|
394
|
+
var current = Array.isArray(galleryValue)
|
|
395
|
+
? galleryValue.filter(function (value) { return typeof value === "string"; })
|
|
396
|
+
: [];
|
|
834
397
|
|
|
835
398
|
if (current.length > 0) {
|
|
836
399
|
var grid = document.createElement("div");
|
|
837
400
|
grid.className = "caret-gallery-grid";
|
|
401
|
+
/** @type {number | null} */
|
|
838
402
|
var dragIdx = null;
|
|
839
403
|
|
|
840
404
|
current.forEach(function (url, idx) {
|
|
@@ -845,7 +409,7 @@
|
|
|
845
409
|
'<img src="' + url + '" alt="Image ' + (idx + 1) + '" loading="lazy" />' +
|
|
846
410
|
'<button class="caret-gallery-remove" type="button" title="' + htmlEscape(msg("field.remove", "Remove")) + '">×</button>' +
|
|
847
411
|
'<span class="caret-gallery-index">' + (idx + 1) + "</span>";
|
|
848
|
-
item.querySelector(".caret-gallery-remove")
|
|
412
|
+
item.querySelector(".caret-gallery-remove")?.addEventListener("click", function (e) {
|
|
849
413
|
e.stopPropagation();
|
|
850
414
|
var imgs = current.slice();
|
|
851
415
|
imgs.splice(idx, 1);
|
|
@@ -891,9 +455,10 @@
|
|
|
891
455
|
fileInput.setAttribute("aria-label", "Upload images for " + humanizeKey(path.split(".").pop() || "images"));
|
|
892
456
|
fileInput.style.display = "none";
|
|
893
457
|
|
|
458
|
+
/** @param {ArrayLike<File>} files */
|
|
894
459
|
function handleFiles(files) {
|
|
895
|
-
var valid = Array.
|
|
896
|
-
return
|
|
460
|
+
var valid = Array.from(files).filter(function (f) {
|
|
461
|
+
return isSupportedImage(f);
|
|
897
462
|
});
|
|
898
463
|
if (!valid.length) return;
|
|
899
464
|
dropzone.innerHTML =
|
|
@@ -901,8 +466,11 @@
|
|
|
901
466
|
'<p style="margin:0;font-size:0.75rem;color:var(--studio-text-muted);">Uploading ' + valid.length + " image" + (valid.length > 1 ? "s" : "") + "…</p>";
|
|
902
467
|
dropzone.style.pointerEvents = "none";
|
|
903
468
|
|
|
904
|
-
var
|
|
905
|
-
|
|
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) {
|
|
906
474
|
results.forEach(function (r) { if (r.status === "fulfilled") imgs.push(r.value); });
|
|
907
475
|
updateField(path, imgs);
|
|
908
476
|
rerender();
|
|
@@ -915,10 +483,11 @@
|
|
|
915
483
|
dropzone.addEventListener("drop", function (e) {
|
|
916
484
|
e.preventDefault();
|
|
917
485
|
dropzone.classList.remove("drag-over");
|
|
918
|
-
|
|
486
|
+
var files = e.dataTransfer && e.dataTransfer.files;
|
|
487
|
+
if (files && files.length) handleFiles(files);
|
|
919
488
|
});
|
|
920
489
|
fileInput.addEventListener("change", function () {
|
|
921
|
-
if (fileInput.files.length) handleFiles(fileInput.files);
|
|
490
|
+
if (fileInput.files && fileInput.files.length) handleFiles(fileInput.files);
|
|
922
491
|
fileInput.value = "";
|
|
923
492
|
});
|
|
924
493
|
|
|
@@ -931,13 +500,15 @@
|
|
|
931
500
|
}
|
|
932
501
|
|
|
933
502
|
/* ─── Single image ──────────────────────────────────────────────── */
|
|
503
|
+
/** @param {unknown} url @param {string} path */
|
|
934
504
|
function buildSingleImage(url, path) {
|
|
935
505
|
var wrapper = document.createElement("div");
|
|
936
506
|
wrapper.className = "caret-single-image";
|
|
937
507
|
|
|
938
508
|
function rerender() {
|
|
939
509
|
wrapper.innerHTML = "";
|
|
940
|
-
var
|
|
510
|
+
var imageValue = getNestedValue(entryData, path);
|
|
511
|
+
var current = typeof imageValue === "string" ? imageValue : "";
|
|
941
512
|
|
|
942
513
|
var fileInput = document.createElement("input");
|
|
943
514
|
fileInput.type = "file";
|
|
@@ -999,11 +570,12 @@
|
|
|
999
570
|
fileInput.addEventListener("change", function () {
|
|
1000
571
|
var file = fileInput.files && fileInput.files[0];
|
|
1001
572
|
if (!file) return;
|
|
573
|
+
var selectedFile = file;
|
|
1002
574
|
uploadBtn.textContent = "Uploading…";
|
|
1003
575
|
uploadBtn.disabled = true;
|
|
1004
|
-
Promise.all([
|
|
576
|
+
Promise.all([uploadClient.upload(selectedFile), uploadClient.readDimensions(selectedFile)]).then(function (results) {
|
|
1005
577
|
updateField(path, results[0]);
|
|
1006
|
-
populateImageObject(path,
|
|
578
|
+
populateImageObject(path, selectedFile, results[1]);
|
|
1007
579
|
rerender();
|
|
1008
580
|
}).catch(function () {
|
|
1009
581
|
help.textContent = "Upload failed. Check the image type and try again.";
|
|
@@ -1026,12 +598,16 @@
|
|
|
1026
598
|
}
|
|
1027
599
|
|
|
1028
600
|
/* ─── Tag list ──────────────────────────────────────────────────── */
|
|
601
|
+
/** @param {unknown} items @param {string} path */
|
|
1029
602
|
function buildTagList(items, path) {
|
|
1030
603
|
var wrapper = document.createElement("div");
|
|
1031
604
|
|
|
1032
605
|
function rerender() {
|
|
1033
606
|
wrapper.innerHTML = "";
|
|
1034
|
-
var
|
|
607
|
+
var tagValue = getNestedValue(entryData, path);
|
|
608
|
+
var current = Array.isArray(tagValue)
|
|
609
|
+
? tagValue.filter(function (value) { return typeof value === "string"; })
|
|
610
|
+
: [];
|
|
1035
611
|
|
|
1036
612
|
if (current.length > 0) {
|
|
1037
613
|
var tagsEl = document.createElement("div");
|
|
@@ -1091,11 +667,14 @@
|
|
|
1091
667
|
}
|
|
1092
668
|
|
|
1093
669
|
/* ─── Object array ──────────────────────────────────────────────── */
|
|
670
|
+
/** @param {unknown} items @param {string} path @param {Schema | null | undefined} itemSchema */
|
|
1094
671
|
function buildObjectArray(items, path, itemSchema) {
|
|
1095
672
|
var wrapper = document.createElement("div");
|
|
1096
673
|
wrapper.className = "caret-object-array";
|
|
674
|
+
/** @type {number | null} */
|
|
1097
675
|
var draggedIndex = null;
|
|
1098
676
|
|
|
677
|
+
/** @param {Record<string, unknown>} item @param {number} idx */
|
|
1099
678
|
function itemSummary(item, idx) {
|
|
1100
679
|
var summaryKeys = ["title", "label", "name", "alt", "src"];
|
|
1101
680
|
for (var i = 0; i < summaryKeys.length; i++) {
|
|
@@ -1111,6 +690,7 @@
|
|
|
1111
690
|
return singularItemLabel(path).replace(/^./, function (c) { return c.toUpperCase(); }) + " " + (idx + 1);
|
|
1112
691
|
}
|
|
1113
692
|
|
|
693
|
+
/** @param {Record<string, unknown>[]} current @param {number} from @param {number} to */
|
|
1114
694
|
function moveItem(current, from, to) {
|
|
1115
695
|
if (to < 0 || to >= current.length || from === to) return;
|
|
1116
696
|
var next = current.slice();
|
|
@@ -1122,7 +702,10 @@
|
|
|
1122
702
|
|
|
1123
703
|
function rerender() {
|
|
1124
704
|
wrapper.innerHTML = "";
|
|
1125
|
-
var
|
|
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
|
+
: [];
|
|
1126
709
|
|
|
1127
710
|
current.forEach(function (item, idx) {
|
|
1128
711
|
var card = document.createElement("div");
|
|
@@ -1235,9 +818,13 @@
|
|
|
1235
818
|
addBtn.className = "caret-object-array-add";
|
|
1236
819
|
addBtn.textContent = "+ " + msg("field.add", "Add") + " " + singularItemLabel(path);
|
|
1237
820
|
addBtn.addEventListener("click", function () {
|
|
821
|
+
/** @type {Record<string, unknown>} */
|
|
1238
822
|
var template;
|
|
1239
823
|
if (itemSchema && itemSchema.properties) {
|
|
1240
|
-
|
|
824
|
+
var templateValue = templateFromSchema(itemSchema);
|
|
825
|
+
template = templateValue && typeof templateValue === "object" && !Array.isArray(templateValue)
|
|
826
|
+
? /** @type {Record<string, unknown>} */ (templateValue)
|
|
827
|
+
: {};
|
|
1241
828
|
if (Object.prototype.hasOwnProperty.call(template, "id") && !template.id) {
|
|
1242
829
|
template.id = generatedId(path, current.length);
|
|
1243
830
|
}
|
|
@@ -1257,7 +844,7 @@
|
|
|
1257
844
|
// Focus immediately. Deferring focus to the next animation frame can
|
|
1258
845
|
// steal it back after a fast user (or assistive automation) has already
|
|
1259
846
|
// moved to another control in the new row.
|
|
1260
|
-
if (firstControl) firstControl.focus();
|
|
847
|
+
if (firstControl instanceof HTMLElement) firstControl.focus();
|
|
1261
848
|
requestAnimationFrame(function () {
|
|
1262
849
|
if (newest) newest.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
1263
850
|
});
|
|
@@ -1273,16 +860,13 @@
|
|
|
1273
860
|
function loadHistory() {
|
|
1274
861
|
historyList.innerHTML = '<p style="font-size:0.75rem;color:var(--studio-text-dim);margin:0;">Loading…</p>';
|
|
1275
862
|
historyPanel.hidden = false;
|
|
863
|
+
historyBtn.setAttribute("aria-expanded", "true");
|
|
864
|
+
historyCloseBtn.focus();
|
|
1276
865
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
if (!res.ok) throw new Error("history failed");
|
|
1282
|
-
return res.json();
|
|
1283
|
-
}).then(function (json) {
|
|
1284
|
-
if (!json) return;
|
|
1285
|
-
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;
|
|
1286
870
|
if (items.length === 0) {
|
|
1287
871
|
historyList.innerHTML = '<p style="font-size:0.75rem;color:var(--studio-text-dim);margin:0;">No history yet. Edits create snapshots automatically.</p>';
|
|
1288
872
|
return;
|
|
@@ -1330,25 +914,28 @@
|
|
|
1330
914
|
});
|
|
1331
915
|
}
|
|
1332
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 */
|
|
1333
925
|
function restoreSnapshot(ts) {
|
|
1334
926
|
if (!window.confirm("Restore this version? Current changes will be saved to history first.")) return;
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
credentials: "same-origin",
|
|
1339
|
-
body: JSON.stringify({ collection: COLLECTION, id: ID, ts: ts }),
|
|
1340
|
-
}).then(function (res) {
|
|
1341
|
-
if (handleAuth(res)) return null;
|
|
1342
|
-
if (!res.ok) throw new Error("restore failed");
|
|
1343
|
-
return res.json();
|
|
1344
|
-
}).then(function (json) {
|
|
1345
|
-
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");
|
|
1346
930
|
var prevData = entryData;
|
|
1347
931
|
var prevJson = originalJson;
|
|
1348
932
|
try {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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;
|
|
1352
939
|
fieldsEl.innerHTML = "";
|
|
1353
940
|
renderFields(fieldsEl, entryData, entrySchema, "");
|
|
1354
941
|
updateSaveButton();
|
|
@@ -1361,8 +948,8 @@
|
|
|
1361
948
|
showStatus("error", "Restore failed — invalid data");
|
|
1362
949
|
return;
|
|
1363
950
|
}
|
|
1364
|
-
|
|
1365
|
-
announceChange("cms:saved", entryData);
|
|
951
|
+
closeHistoryPanel();
|
|
952
|
+
studioSync.announceChange("cms:saved", entryData);
|
|
1366
953
|
showStatus("saved", "Restored");
|
|
1367
954
|
}).catch(function () {
|
|
1368
955
|
showStatus("error", "Restore failed");
|
|
@@ -1373,20 +960,26 @@
|
|
|
1373
960
|
saveBtn.addEventListener("click", save);
|
|
1374
961
|
if (deleteBtn) deleteBtn.addEventListener("click", deleteEntry);
|
|
1375
962
|
historyBtn.addEventListener("click", loadHistory);
|
|
1376
|
-
historyCloseBtn.addEventListener("click",
|
|
1377
|
-
|
|
1378
|
-
historyList.innerHTML = "";
|
|
1379
|
-
});
|
|
1380
|
-
deleteCancelBtn.addEventListener("click", function () { deleteDialog.hidden = true; });
|
|
963
|
+
historyCloseBtn.addEventListener("click", closeHistoryPanel);
|
|
964
|
+
deleteCancelBtn.addEventListener("click", closeDeleteDialog);
|
|
1381
965
|
deleteConfirmBtn.addEventListener("click", confirmDelete);
|
|
1382
966
|
deleteDialog.addEventListener("click", function (e) {
|
|
1383
|
-
if (e.target === deleteDialog)
|
|
967
|
+
if (e.target === deleteDialog) closeDeleteDialog();
|
|
1384
968
|
});
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
if (
|
|
1388
|
-
|
|
1389
|
-
|
|
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();
|
|
1390
983
|
}
|
|
1391
984
|
});
|
|
1392
985
|
|
|
@@ -1398,109 +991,80 @@
|
|
|
1398
991
|
? event.target.closest("input, select, textarea, button")
|
|
1399
992
|
: null;
|
|
1400
993
|
var group = control && control.closest("[data-field-path]");
|
|
1401
|
-
var path = group
|
|
994
|
+
var path = group instanceof HTMLElement ? group.dataset.fieldPath : undefined;
|
|
1402
995
|
if (!path) return;
|
|
996
|
+
if (!group) return;
|
|
1403
997
|
showLinkedSelection(group);
|
|
1404
|
-
announceFieldSelection(path);
|
|
998
|
+
studioSync.announceFieldSelection(path);
|
|
1405
999
|
});
|
|
1406
1000
|
|
|
1407
|
-
if ("BroadcastChannel" in window) {
|
|
1408
|
-
try {
|
|
1409
|
-
syncChannel = new BroadcastChannel(SYNC_CHANNEL);
|
|
1410
|
-
syncChannel.addEventListener("message", function (event) {
|
|
1411
|
-
if (event.data && event.data.type === "cms:field-selected") {
|
|
1412
|
-
applyRemoteSelection(event.data);
|
|
1413
|
-
}
|
|
1414
|
-
});
|
|
1415
|
-
window.addEventListener("pagehide", function () { syncChannel.close(); }, { once: true });
|
|
1416
|
-
} catch (e) {
|
|
1417
|
-
syncChannel = null;
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
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
|
+
}
|
|
1422
1009
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
1423
1010
|
e.preventDefault();
|
|
1424
1011
|
if (isDirty()) save();
|
|
1425
1012
|
}
|
|
1426
|
-
|
|
1427
|
-
if (!deleteDialog.hidden) deleteDialog.hidden = true;
|
|
1428
|
-
if (!historyPanel.hidden) {
|
|
1429
|
-
historyPanel.hidden = true;
|
|
1430
|
-
historyList.innerHTML = "";
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
});
|
|
1013
|
+
}, true);
|
|
1434
1014
|
|
|
1435
1015
|
window.addEventListener("beforeunload", function (e) {
|
|
1436
1016
|
if (isDirty()) { e.preventDefault(); e.returnValue = ""; }
|
|
1437
1017
|
});
|
|
1438
1018
|
|
|
1439
1019
|
/* ─── Load entry + schema ───────────────────────────────────────── */
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
var schemaPromise = schemaRes.ok ? schemaRes.json() : Promise.resolve(null);
|
|
1454
|
-
return schemaPromise.then(function (schemaJson) {
|
|
1455
|
-
if (schemaJson && schemaJson.schema) entrySchema = schemaJson.schema;
|
|
1456
|
-
|
|
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;
|
|
1457
1032
|
loadingEl.hidden = true;
|
|
1458
|
-
|
|
1459
|
-
if (!entry && !initializedMissingEntry) {
|
|
1033
|
+
if (result.kind === "missing") {
|
|
1460
1034
|
notFoundEl.hidden = false;
|
|
1461
1035
|
return;
|
|
1462
1036
|
}
|
|
1463
|
-
var initialData =
|
|
1464
|
-
? entry.data
|
|
1465
|
-
: (schemaJson && schemaJson.template) || templateFromSchema(entrySchema);
|
|
1037
|
+
var initialData = result.data;
|
|
1466
1038
|
entryData = deepClone(initialData);
|
|
1467
|
-
entryRevision =
|
|
1039
|
+
entryRevision = result.revision;
|
|
1468
1040
|
originalJson = JSON.stringify(initialData);
|
|
1469
1041
|
titleEl.textContent = getTitle(entryData);
|
|
1470
1042
|
renderFields(fieldsEl, entryData, entrySchema, "");
|
|
1471
|
-
if (validationWarningEl &&
|
|
1472
|
-
validationWarningEl.textContent = "This stored entry has invalid fields: " +
|
|
1043
|
+
if (validationWarningEl && result.validationIssues.length > 0) {
|
|
1044
|
+
validationWarningEl.textContent = "This stored entry has invalid fields: " + result.validationIssues.map(function (issue) {
|
|
1473
1045
|
return (issue.path || "entry") + " — " + issue.message;
|
|
1474
1046
|
}).join("; ") + ". Correct them and Save to restore public delivery.";
|
|
1475
1047
|
validationWarningEl.hidden = false;
|
|
1476
1048
|
}
|
|
1477
1049
|
updateSaveButton();
|
|
1478
|
-
if (IS_NEW ||
|
|
1050
|
+
if (IS_NEW || result.initialized) {
|
|
1479
1051
|
var guideTitle = document.getElementById("editor-guide-title");
|
|
1480
1052
|
var guideCopy = document.getElementById("editor-guide-copy");
|
|
1481
1053
|
if (guideTitle) guideTitle.textContent = msg("entry.newGuideTitle", "Entry created with safe defaults");
|
|
1482
|
-
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.
|
|
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.");
|
|
1483
1055
|
}
|
|
1484
1056
|
editorEl.hidden = false;
|
|
1485
|
-
|
|
1486
|
-
try {
|
|
1487
|
-
window.parent.postMessage({
|
|
1488
|
-
type: "cms:entry-ready",
|
|
1489
|
-
collection: COLLECTION,
|
|
1490
|
-
id: ID,
|
|
1491
|
-
embedded: true,
|
|
1492
|
-
}, window.location.origin);
|
|
1493
|
-
} catch (e) { /* embedded field linking is a progressive enhancement */ }
|
|
1494
|
-
}
|
|
1057
|
+
studioSync.announceReady();
|
|
1495
1058
|
if (pendingRemoteSelection) {
|
|
1496
1059
|
var pending = pendingRemoteSelection;
|
|
1497
1060
|
pendingRemoteSelection = null;
|
|
1498
1061
|
applyRemoteSelection(pending);
|
|
1499
1062
|
}
|
|
1500
|
-
|
|
1501
|
-
});
|
|
1502
|
-
}).catch(function () {
|
|
1063
|
+
}).catch(function (error) {
|
|
1503
1064
|
loadingEl.hidden = true;
|
|
1504
|
-
notFoundEl.hidden =
|
|
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.");
|
|
1505
1068
|
});
|
|
1069
|
+
/** @type {HTMLElement} */ (document.getElementById("load-error-retry")).addEventListener("click", function () { window.location.reload(); });
|
|
1506
1070
|
})();
|