collavre 0.23.0 → 0.24.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.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- metadata +42 -1
|
@@ -8,16 +8,43 @@ import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom
|
|
|
8
8
|
import { renderMarkdown } from '../lib/utils/markdown'
|
|
9
9
|
import { reconcileMarkdownSource } from './markdown_source_reconcile'
|
|
10
10
|
import { isHtmlEmpty } from './html_content_empty'
|
|
11
|
+
import { CreativeSaveQueue } from './creative_save_queue'
|
|
11
12
|
import { confirmDialog, alertDialog } from '../lib/utils/dialog'
|
|
12
13
|
import { serverErrorMessage } from '../lib/api/api_error'
|
|
13
14
|
import yaml from 'js-yaml'
|
|
15
|
+
import {
|
|
16
|
+
treeRowElement,
|
|
17
|
+
hasDatasetValue,
|
|
18
|
+
isMarkdownEmpty,
|
|
19
|
+
readRowLevel,
|
|
20
|
+
editorPaddingForLevel,
|
|
21
|
+
} from './creative_row_editor_helpers'
|
|
22
|
+
import {
|
|
23
|
+
creativeTreeElement,
|
|
24
|
+
creativeIdFrom,
|
|
25
|
+
siblingTreeRow,
|
|
26
|
+
siblingOrderingForRow,
|
|
27
|
+
treeContainerElement,
|
|
28
|
+
nodeAfterTreeBlock,
|
|
29
|
+
normalizeRowNode,
|
|
30
|
+
childrenContainerForTree,
|
|
31
|
+
ensureChildrenContainer,
|
|
32
|
+
expandChildrenContainer,
|
|
33
|
+
moveTreeBlock,
|
|
34
|
+
listAllTreeNodes,
|
|
35
|
+
findPreviousTree,
|
|
36
|
+
getTreeLevel,
|
|
37
|
+
updateTreeLevels,
|
|
38
|
+
setTreeLevel,
|
|
39
|
+
removeTreeElement,
|
|
40
|
+
} from './creative_tree_dom'
|
|
41
|
+
import {
|
|
42
|
+
updateRowFromData,
|
|
43
|
+
inlinePayloadFromTree,
|
|
44
|
+
} from './creative_inline_payload'
|
|
14
45
|
// Import Stimulus application from the global window (set by host app)
|
|
15
46
|
const application = window.Stimulus
|
|
16
47
|
|
|
17
|
-
const BULLET_STARTING_LEVEL = 3;
|
|
18
|
-
const HEADING_INDENT_STEP_EM = 0.4;
|
|
19
|
-
const BULLET_INDENT_STEP_PX = 30;
|
|
20
|
-
|
|
21
48
|
let initialized = false;
|
|
22
49
|
let creativeEditClickHandler = null;
|
|
23
50
|
let addCreativeShortcutHandler = null;
|
|
@@ -71,17 +98,34 @@ export function initializeCreativeRowEditor() {
|
|
|
71
98
|
alertDialog(serverMessage || 'Failed to save changes. Please check your connection and try again.');
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
// If the failed item matches the current creative, mark it as dirty so it can be retried
|
|
75
|
-
|
|
101
|
+
// If the failed item matches the current creative, mark it as dirty so it can be retried.
|
|
102
|
+
// Match the id exactly — a substring test (e.g. path.includes("23")) also matches
|
|
103
|
+
// "/creatives/123", flagging the wrong row's toolbar as failed.
|
|
104
|
+
const failedCreativeId = (item.path.match(/\/creatives\/(\d+)/) || [])[1];
|
|
105
|
+
if (form.dataset.creativeId && failedCreativeId === form.dataset.creativeId) {
|
|
76
106
|
console.log('Restoring dirty state for current creative');
|
|
77
107
|
isDirty = true;
|
|
78
108
|
pendingSave = true;
|
|
109
|
+
setSaveStatus('error');
|
|
79
110
|
updateActionButtonStates();
|
|
80
111
|
}
|
|
81
112
|
});
|
|
82
113
|
|
|
83
114
|
// ... rest of initialization
|
|
84
115
|
|
|
116
|
+
// Reflect the inline editor's save lifecycle in the toolbar row.
|
|
117
|
+
// Plain JS controller can't call the i18n `t()` helper, so the localized
|
|
118
|
+
// strings are carried on the span's data-* attributes (set in the ERB).
|
|
119
|
+
// state: 'pending' | 'saving' | 'saved' | 'error' | '' (cleared).
|
|
120
|
+
// 'pending' = dirty, waiting out the debounce; 'saving' = request in flight.
|
|
121
|
+
function setSaveStatus(state) {
|
|
122
|
+
const el = document.getElementById('inline-save-status');
|
|
123
|
+
if (!el) return;
|
|
124
|
+
const label = state ? el.dataset[`label${state.charAt(0).toUpperCase()}${state.slice(1)}`] : '';
|
|
125
|
+
el.textContent = label || '';
|
|
126
|
+
el.dataset.state = state || '';
|
|
127
|
+
}
|
|
128
|
+
|
|
85
129
|
const form = document.getElementById('inline-edit-form-element');
|
|
86
130
|
const descriptionInput = document.getElementById('inline-creative-description');
|
|
87
131
|
const editorContainer = template.querySelector('[data-lexical-editor-root]');
|
|
@@ -157,10 +201,12 @@ export function initializeCreativeRowEditor() {
|
|
|
157
201
|
|
|
158
202
|
let currentTree = null;
|
|
159
203
|
let currentRowElement = null;
|
|
160
|
-
|
|
204
|
+
// Owns the autosave request lifecycle: the debounce timer and the
|
|
205
|
+
// single-flight guard around the in-flight request. `pendingSave` /
|
|
206
|
+
// `isDirty` remain the editor's dirty-tracking flags (what changed), while
|
|
207
|
+
// the queue governs when the request runs. See creative_save_queue.js.
|
|
208
|
+
const saveQueue = new CreativeSaveQueue();
|
|
161
209
|
let pendingSave = false;
|
|
162
|
-
let saving = false;
|
|
163
|
-
let savePromise = Promise.resolve();
|
|
164
210
|
let uploadsPending = false;
|
|
165
211
|
let uploadCompletionPromise = null;
|
|
166
212
|
let resolveUploadCompletion = null;
|
|
@@ -219,105 +265,12 @@ export function initializeCreativeRowEditor() {
|
|
|
219
265
|
}
|
|
220
266
|
}
|
|
221
267
|
|
|
222
|
-
function treeRowElement(node) {
|
|
223
|
-
return node && node.closest ? node.closest('creative-tree-row') : null;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
268
|
function currentRowHasChildren() {
|
|
227
269
|
const row = currentRowElement || (currentTree ? treeRowElement(currentTree) : null);
|
|
228
270
|
if (!row) return false;
|
|
229
271
|
return !!(row.hasChildren || row.getAttribute?.('has-children'));
|
|
230
272
|
}
|
|
231
273
|
|
|
232
|
-
function hasDatasetValue(element, key) {
|
|
233
|
-
if (!element || !element.dataset) return false;
|
|
234
|
-
return Object.prototype.hasOwnProperty.call(element.dataset, key);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function setRowDatasetValue(row, key, value) {
|
|
238
|
-
if (!row || !row.dataset) return;
|
|
239
|
-
if (value === undefined || value === null) {
|
|
240
|
-
delete row.dataset[key];
|
|
241
|
-
} else {
|
|
242
|
-
row.dataset[key] = String(value);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function updateRowFromData(row, data) {
|
|
247
|
-
if (!row || !data) return;
|
|
248
|
-
const descriptionHtml = data.description || '';
|
|
249
|
-
const rawHtml = data.description_raw_html || descriptionHtml;
|
|
250
|
-
row.descriptionHtml = descriptionHtml;
|
|
251
|
-
setRowDatasetValue(row, 'descriptionHtml', descriptionHtml);
|
|
252
|
-
setRowDatasetValue(row, 'descriptionRawHtml', rawHtml);
|
|
253
|
-
if (data.progress_html != null) {
|
|
254
|
-
row.progressHtml = data.progress_html;
|
|
255
|
-
setRowDatasetValue(row, 'progressHtml', data.progress_html);
|
|
256
|
-
}
|
|
257
|
-
if (Object.prototype.hasOwnProperty.call(data, 'progress')) {
|
|
258
|
-
setRowDatasetValue(row, 'progressValue', data.progress ?? '');
|
|
259
|
-
}
|
|
260
|
-
if (Object.prototype.hasOwnProperty.call(data, 'origin_id')) {
|
|
261
|
-
setRowDatasetValue(row, 'originId', data.origin_id ?? '');
|
|
262
|
-
}
|
|
263
|
-
if (Object.prototype.hasOwnProperty.call(data, 'content_type')) {
|
|
264
|
-
setRowDatasetValue(row, 'contentType', data.content_type ?? '');
|
|
265
|
-
}
|
|
266
|
-
if (Object.prototype.hasOwnProperty.call(data, 'markdown_source')) {
|
|
267
|
-
setRowDatasetValue(row, 'markdownSource', data.markdown_source ?? '');
|
|
268
|
-
}
|
|
269
|
-
if (Object.prototype.hasOwnProperty.call(data, 'markdown_editor')) {
|
|
270
|
-
setRowDatasetValue(row, 'markdownEditor', data.markdown_editor ?? '');
|
|
271
|
-
}
|
|
272
|
-
if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
|
|
273
|
-
if (data.has_children) {
|
|
274
|
-
row.setAttribute('has-children', '');
|
|
275
|
-
row.hasChildren = true;
|
|
276
|
-
} else {
|
|
277
|
-
row.removeAttribute('has-children');
|
|
278
|
-
row.hasChildren = false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (typeof row.requestUpdate === 'function') {
|
|
282
|
-
row.requestUpdate();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function inlinePayloadFromTree(tree) {
|
|
287
|
-
if (!tree) return null;
|
|
288
|
-
const row = treeRowElement(tree);
|
|
289
|
-
if (!row) return null;
|
|
290
|
-
|
|
291
|
-
// Relax validation - allow loading with partial data for instant UI
|
|
292
|
-
const hasDescription = hasDatasetValue(row, 'descriptionRawHtml') || hasDatasetValue(row, 'descriptionHtml');
|
|
293
|
-
const hasProgress = hasDatasetValue(row, 'progressValue');
|
|
294
|
-
|
|
295
|
-
// Only require ID to be present
|
|
296
|
-
const id = tree.dataset?.id;
|
|
297
|
-
if (!id) return null;
|
|
298
|
-
|
|
299
|
-
const rawHtml = hasDatasetValue(row, 'descriptionRawHtml') ? row.dataset.descriptionRawHtml : row.dataset.descriptionHtml || '';
|
|
300
|
-
const description = row.dataset.descriptionHtml || rawHtml || '';
|
|
301
|
-
const progressValue = hasProgress ? Number(row.dataset.progressValue ?? 0) : 0;
|
|
302
|
-
const parentId = tree.dataset?.parentId || '';
|
|
303
|
-
|
|
304
|
-
return {
|
|
305
|
-
id: id,
|
|
306
|
-
description,
|
|
307
|
-
description_raw_html: rawHtml,
|
|
308
|
-
origin_id: row.dataset?.originId || '',
|
|
309
|
-
parent_id: parentId,
|
|
310
|
-
progress: Number.isNaN(progressValue) ? 0 : progressValue,
|
|
311
|
-
content_type: row.dataset?.contentType || null,
|
|
312
|
-
markdown_editor: row.dataset?.markdownEditor || null,
|
|
313
|
-
markdown_source: row.dataset?.markdownSource || null
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function isMarkdownEmpty(md) {
|
|
318
|
-
return !md || md.trim().length === 0;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
274
|
function activateMarkdownMode(source) {
|
|
322
275
|
markdownMode = true;
|
|
323
276
|
if (contentTypeInput) contentTypeInput.value = 'markdown';
|
|
@@ -391,11 +344,14 @@ export function initializeCreativeRowEditor() {
|
|
|
391
344
|
// HTML projection), and Markdown-source-based for the textarea surface.
|
|
392
345
|
originalContent = useTextarea ? (data.markdown_source || '') : content;
|
|
393
346
|
isDirty = false;
|
|
347
|
+
setSaveStatus('');
|
|
394
348
|
const progressNumber = Number(data.progress ?? 0);
|
|
395
349
|
const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
|
|
396
350
|
setProgressState(normalizedProgress);
|
|
397
351
|
updateProgressInputAvailability(normalizedProgress);
|
|
398
352
|
completionCascadePending = false;
|
|
353
|
+
// parentId convention: `data-parent-id` is always present ("" === root),
|
|
354
|
+
// so this DOM fallback is unambiguous when server data lacks parent_id.
|
|
399
355
|
const fallbackParent = tree?.dataset?.parentId || '';
|
|
400
356
|
parentInput.value = data.parent_id ?? fallbackParent ?? '';
|
|
401
357
|
beforeInput.value = '';
|
|
@@ -432,219 +388,6 @@ export function initializeCreativeRowEditor() {
|
|
|
432
388
|
}
|
|
433
389
|
}
|
|
434
390
|
|
|
435
|
-
function siblingTreeRow(row, direction) {
|
|
436
|
-
if (!row) return null;
|
|
437
|
-
const step = direction === 'previous' ? 'previousSibling' : 'nextSibling';
|
|
438
|
-
let node = row[step];
|
|
439
|
-
while (node) {
|
|
440
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
441
|
-
node = node[step];
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
if (node.matches?.('creative-tree-row')) return node;
|
|
445
|
-
if (node.classList?.contains?.('creative-children')) {
|
|
446
|
-
node = node[step];
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
node = node[step];
|
|
450
|
-
}
|
|
451
|
-
return null;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function siblingOrderingForRow(row) {
|
|
455
|
-
const beforeRow = siblingTreeRow(row, 'next');
|
|
456
|
-
const afterRow = siblingTreeRow(row, 'previous');
|
|
457
|
-
return {
|
|
458
|
-
beforeId: beforeRow ? creativeIdFrom(beforeRow) : '',
|
|
459
|
-
afterId: afterRow ? creativeIdFrom(afterRow) : ''
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function treeContainerElement(tree) {
|
|
464
|
-
if (!tree) return null;
|
|
465
|
-
const row = treeRowElement(tree);
|
|
466
|
-
if (row && row.parentNode) return row.parentNode;
|
|
467
|
-
return tree.parentNode;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function nodeAfterTreeBlock(tree) {
|
|
471
|
-
if (!tree) return null;
|
|
472
|
-
const row = treeRowElement(tree);
|
|
473
|
-
if (!row) return tree.nextSibling;
|
|
474
|
-
let node = row.nextSibling;
|
|
475
|
-
while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
|
|
476
|
-
const treeId = tree.dataset?.id;
|
|
477
|
-
if (treeId) {
|
|
478
|
-
const childrenContainer = document.getElementById(`creative-children-${treeId}`);
|
|
479
|
-
if (childrenContainer && childrenContainer.parentNode === row.parentNode && node === childrenContainer) {
|
|
480
|
-
node = childrenContainer.nextSibling;
|
|
481
|
-
while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
return node;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function normalizeRowNode(node) {
|
|
488
|
-
if (!node) return null;
|
|
489
|
-
if (node.matches && node.matches('creative-tree-row')) return node;
|
|
490
|
-
if (node.classList && node.classList.contains('creative-tree')) {
|
|
491
|
-
const row = treeRowElement(node);
|
|
492
|
-
return row || node;
|
|
493
|
-
}
|
|
494
|
-
return node;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function childrenContainerForTree(tree) {
|
|
498
|
-
if (!tree) return null;
|
|
499
|
-
const treeId = tree.dataset?.id;
|
|
500
|
-
if (treeId) {
|
|
501
|
-
const byId = document.getElementById(`creative-children-${treeId}`);
|
|
502
|
-
if (byId) return byId;
|
|
503
|
-
}
|
|
504
|
-
if (tree.children && tree.children.length > 0) {
|
|
505
|
-
for (const child of tree.children) {
|
|
506
|
-
if (child && child.classList && child.classList.contains('creative-children')) {
|
|
507
|
-
return child;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
const row = treeRowElement(tree);
|
|
512
|
-
if (row) {
|
|
513
|
-
let sibling = row.nextElementSibling;
|
|
514
|
-
while (sibling) {
|
|
515
|
-
if (sibling.matches?.('creative-tree-row')) break;
|
|
516
|
-
if (sibling.classList?.contains('creative-children')) return sibling;
|
|
517
|
-
sibling = sibling.nextElementSibling;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
return null;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
function buildChildrenLoadUrl(parentId, childLevel, selectMode) {
|
|
524
|
-
const params = new URLSearchParams();
|
|
525
|
-
params.set('level', String(childLevel));
|
|
526
|
-
params.set('select_mode', selectMode ? '1' : '0');
|
|
527
|
-
return `/creatives/${parentId}/children?${params.toString()}`;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function ensureChildrenContainer(tree) {
|
|
531
|
-
if (!tree) return null;
|
|
532
|
-
let container = childrenContainerForTree(tree);
|
|
533
|
-
if (container) return container;
|
|
534
|
-
const parentId = tree.dataset?.id;
|
|
535
|
-
if (!parentId) return null;
|
|
536
|
-
container = document.createElement('div');
|
|
537
|
-
container.className = 'creative-children';
|
|
538
|
-
container.id = `creative-children-${parentId}`;
|
|
539
|
-
const parentRow = treeRowElement(tree);
|
|
540
|
-
const parentLevel = readRowLevel(parentRow) || 1;
|
|
541
|
-
const childLevel = parentLevel + 1;
|
|
542
|
-
const selectModeActive = parentRow?.hasAttribute?.('select-mode') ? 1 : 0;
|
|
543
|
-
container.dataset.loadUrl = buildChildrenLoadUrl(parentId, childLevel, selectModeActive);
|
|
544
|
-
container.dataset.expanded = 'true';
|
|
545
|
-
if (container.dataset.loaded) delete container.dataset.loaded;
|
|
546
|
-
const row = treeRowElement(tree);
|
|
547
|
-
const parentContainer = row?.parentNode || tree.parentNode;
|
|
548
|
-
if (parentContainer) {
|
|
549
|
-
const afterRow = row?.nextSibling;
|
|
550
|
-
if (afterRow) {
|
|
551
|
-
parentContainer.insertBefore(container, afterRow);
|
|
552
|
-
} else {
|
|
553
|
-
parentContainer.appendChild(container);
|
|
554
|
-
}
|
|
555
|
-
} else {
|
|
556
|
-
tree.appendChild(container);
|
|
557
|
-
}
|
|
558
|
-
return container;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function expandChildrenContainer(container) {
|
|
562
|
-
if (!container) return;
|
|
563
|
-
container.style.display = '';
|
|
564
|
-
if (container.dataset) {
|
|
565
|
-
container.dataset.expanded = 'true';
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function moveTreeBlock(tree, targetContainer, referenceNode = null) {
|
|
570
|
-
if (!tree || !targetContainer) return;
|
|
571
|
-
const row = treeRowElement(tree);
|
|
572
|
-
if (!row) return;
|
|
573
|
-
const nodesToMove = [row];
|
|
574
|
-
const childContainer = childrenContainerForTree(tree);
|
|
575
|
-
if (childContainer) nodesToMove.push(childContainer);
|
|
576
|
-
nodesToMove.forEach((node) => {
|
|
577
|
-
if (!node) return;
|
|
578
|
-
if (referenceNode) {
|
|
579
|
-
targetContainer.insertBefore(node, referenceNode);
|
|
580
|
-
} else {
|
|
581
|
-
targetContainer.appendChild(node);
|
|
582
|
-
}
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function listAllTreeNodes() {
|
|
587
|
-
const root = document.getElementById('creatives');
|
|
588
|
-
if (root) return Array.from(root.querySelectorAll('.creative-tree'));
|
|
589
|
-
return Array.from(document.querySelectorAll('.creative-tree'));
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function findPreviousTree(tree) {
|
|
593
|
-
if (!tree) return null;
|
|
594
|
-
const nodes = listAllTreeNodes();
|
|
595
|
-
const index = nodes.indexOf(tree);
|
|
596
|
-
if (index <= 0) return null;
|
|
597
|
-
const currentLevel = getTreeLevel(tree);
|
|
598
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
599
|
-
const candidate = nodes[i];
|
|
600
|
-
if (!candidate) continue;
|
|
601
|
-
const candidateLevel = getTreeLevel(candidate);
|
|
602
|
-
if (candidateLevel === currentLevel) return candidate;
|
|
603
|
-
if (candidateLevel < currentLevel) return null;
|
|
604
|
-
}
|
|
605
|
-
return null;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function getTreeLevel(tree) {
|
|
609
|
-
if (!tree) return 1;
|
|
610
|
-
const levelValue = Number(tree.dataset?.level);
|
|
611
|
-
if (!Number.isNaN(levelValue) && levelValue > 0) {
|
|
612
|
-
return levelValue;
|
|
613
|
-
}
|
|
614
|
-
const row = treeRowElement(tree);
|
|
615
|
-
return readRowLevel(row) || 1;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function updateTreeLevels(tree, delta) {
|
|
619
|
-
if (!tree || !delta) return;
|
|
620
|
-
const currentLevel = Number(tree.dataset?.level) || 1;
|
|
621
|
-
const nextLevel = Math.max(1, currentLevel + delta);
|
|
622
|
-
tree.dataset.level = String(nextLevel);
|
|
623
|
-
const row = treeRowElement(tree);
|
|
624
|
-
if (row) {
|
|
625
|
-
row.setAttribute('level', nextLevel);
|
|
626
|
-
row.level = nextLevel;
|
|
627
|
-
row.requestUpdate?.();
|
|
628
|
-
}
|
|
629
|
-
const container = childrenContainerForTree(tree);
|
|
630
|
-
if (!container) return;
|
|
631
|
-
Array.from(container.children || []).forEach((childRow) => {
|
|
632
|
-
if (!childRow.matches?.('creative-tree-row')) return;
|
|
633
|
-
const childTree = childRow.querySelector('.creative-tree');
|
|
634
|
-
if (childTree) {
|
|
635
|
-
updateTreeLevels(childTree, delta);
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function setTreeLevel(tree, targetLevel) {
|
|
641
|
-
if (!tree || typeof targetLevel !== 'number') return;
|
|
642
|
-
const currentLevel = Number(tree.dataset?.level) || 1;
|
|
643
|
-
const delta = targetLevel - currentLevel;
|
|
644
|
-
if (delta === 0) return;
|
|
645
|
-
updateTreeLevels(tree, delta);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
391
|
function updateParentChildrenState(parentId) {
|
|
649
392
|
if (!parentId) return;
|
|
650
393
|
const parentTree = document.getElementById(`creative-${parentId}`);
|
|
@@ -674,31 +417,6 @@ export function initializeCreativeRowEditor() {
|
|
|
674
417
|
scheduleSave();
|
|
675
418
|
}
|
|
676
419
|
|
|
677
|
-
function readRowLevel(row) {
|
|
678
|
-
if (!row) return null;
|
|
679
|
-
if (row.isTitle) return 0;
|
|
680
|
-
if (row.getAttribute) {
|
|
681
|
-
const levelAttr = row.getAttribute('level');
|
|
682
|
-
if (levelAttr) {
|
|
683
|
-
const parsed = Number(levelAttr);
|
|
684
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
if (typeof row.level === 'number') {
|
|
688
|
-
return row.level;
|
|
689
|
-
}
|
|
690
|
-
if (row.level) {
|
|
691
|
-
const parsed = Number(row.level);
|
|
692
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
693
|
-
}
|
|
694
|
-
const tree = row.querySelector ? row.querySelector('.creative-tree') : null;
|
|
695
|
-
if (tree && tree.dataset?.level) {
|
|
696
|
-
const parsed = Number(tree.dataset.level);
|
|
697
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
698
|
-
}
|
|
699
|
-
return 1;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
420
|
function computeNewRowLevel(parentId, referenceNode, afterId) {
|
|
703
421
|
if (parentId) {
|
|
704
422
|
const parentRow = document.querySelector(`creative-tree-row[creative-id="${parentId}"]`);
|
|
@@ -717,18 +435,6 @@ export function initializeCreativeRowEditor() {
|
|
|
717
435
|
return readRowLevel(normalized);
|
|
718
436
|
}
|
|
719
437
|
|
|
720
|
-
function editorPaddingForLevel(level) {
|
|
721
|
-
if (typeof level !== 'number' || Number.isNaN(level) || level <= 1) {
|
|
722
|
-
return '0px';
|
|
723
|
-
}
|
|
724
|
-
if (level <= BULLET_STARTING_LEVEL) {
|
|
725
|
-
const emValue = (level - 1) * HEADING_INDENT_STEP_EM;
|
|
726
|
-
return emValue ? `${emValue}em` : '0px';
|
|
727
|
-
}
|
|
728
|
-
const pxValue = (level - BULLET_STARTING_LEVEL) * BULLET_INDENT_STEP_PX;
|
|
729
|
-
return `${pxValue}px`;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
438
|
function syncInlineEditorPadding(source) {
|
|
733
439
|
if (!template) return;
|
|
734
440
|
let level = null;
|
|
@@ -741,16 +447,6 @@ export function initializeCreativeRowEditor() {
|
|
|
741
447
|
template.style.paddingLeft = paddingValue;
|
|
742
448
|
}
|
|
743
449
|
|
|
744
|
-
function removeTreeElement(tree) {
|
|
745
|
-
if (!tree) return;
|
|
746
|
-
const row = treeRowElement(tree);
|
|
747
|
-
if (row) {
|
|
748
|
-
row.remove();
|
|
749
|
-
} else if (tree.remove) {
|
|
750
|
-
tree.remove();
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
450
|
function getUploadCompletion() {
|
|
755
451
|
if (!uploadCompletionPromise) {
|
|
756
452
|
uploadCompletionPromise = new Promise(resolve => {
|
|
@@ -997,27 +693,6 @@ export function initializeCreativeRowEditor() {
|
|
|
997
693
|
if (row) row.style.display = '';
|
|
998
694
|
}
|
|
999
695
|
|
|
1000
|
-
function creativeTreeElement(node) {
|
|
1001
|
-
if (!node) return null;
|
|
1002
|
-
if (node.classList && node.classList.contains('creative-tree')) return node;
|
|
1003
|
-
if (node.querySelector) {
|
|
1004
|
-
const inner = node.querySelector('.creative-tree');
|
|
1005
|
-
if (inner) return inner;
|
|
1006
|
-
}
|
|
1007
|
-
return null;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
function creativeIdFrom(node) {
|
|
1011
|
-
const treeEl = creativeTreeElement(node);
|
|
1012
|
-
if (treeEl && treeEl.dataset) {
|
|
1013
|
-
return treeEl.dataset.id || '';
|
|
1014
|
-
}
|
|
1015
|
-
if (node?.getAttribute) {
|
|
1016
|
-
return node.getAttribute('creative-id') || node.getAttribute('data-id') || '';
|
|
1017
|
-
}
|
|
1018
|
-
return '';
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
696
|
function insertRow(tree, data) {
|
|
1022
697
|
if (tree.querySelector('.creative-row')) return;
|
|
1023
698
|
const row = document.createElement('div');
|
|
@@ -1064,10 +739,18 @@ export function initializeCreativeRowEditor() {
|
|
|
1064
739
|
}
|
|
1065
740
|
|
|
1066
741
|
function saveForm(tree = currentTree, parentId = parentInput.value) {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
742
|
+
// Reflect the in-flight save immediately, *before* awaiting pending uploads.
|
|
743
|
+
// Direct-save callers (progress checkbox, structure moves) bypass
|
|
744
|
+
// scheduleSave(), so without this an attachment upload still in flight would
|
|
745
|
+
// let the toolbar keep a stale "saved" label for the whole upload window.
|
|
746
|
+
// Gated on the editor still being bound to this row (mirrors applySaveStatus).
|
|
747
|
+
if (tree === currentTree && (pendingSave || saveQueue.saving || isDirty)) setSaveStatus('pending');
|
|
748
|
+
// Build and run the persist request under the queue's single-flight
|
|
749
|
+
// guard: runExclusive cancels any pending debounce, invokes performSave,
|
|
750
|
+
// and (only when it returns a request for non-empty content) holds the
|
|
751
|
+
// in-flight state until that request settles. Returning null keeps the
|
|
752
|
+
// queue idle without ever flipping the in-flight flag.
|
|
753
|
+
function performSave() {
|
|
1071
754
|
// Sync markdown form fields before saving
|
|
1072
755
|
if (markdownMode) syncMarkdownToForm();
|
|
1073
756
|
|
|
@@ -1076,13 +759,22 @@ export function initializeCreativeRowEditor() {
|
|
|
1076
759
|
: isHtmlEmpty(descriptionInput.value);
|
|
1077
760
|
if (isEmpty) {
|
|
1078
761
|
pendingSave = false;
|
|
1079
|
-
|
|
762
|
+
// Nothing to persist — don't strand the "pending" label set above.
|
|
763
|
+
if (tree === currentTree) setSaveStatus('');
|
|
764
|
+
return null;
|
|
1080
765
|
}
|
|
1081
766
|
|
|
1082
767
|
const method = methodInput.value === 'patch' ? 'PATCH' : 'POST';
|
|
1083
768
|
pendingSave = false;
|
|
1084
|
-
if (!form.action) return
|
|
1085
|
-
|
|
769
|
+
if (!form.action) return null;
|
|
770
|
+
// Only reflect this save's outcome while the editor is still bound to the
|
|
771
|
+
// creative it started on. If the user navigates to another row mid-flight
|
|
772
|
+
// (move() reattaches the shared #inline-save-status span to the new row),
|
|
773
|
+
// a late completion must not mislabel the newly opened row.
|
|
774
|
+
const applySaveStatus = function (state) {
|
|
775
|
+
if (tree === currentTree) setSaveStatus(state);
|
|
776
|
+
};
|
|
777
|
+
applySaveStatus('saving');
|
|
1086
778
|
|
|
1087
779
|
// Capture values being saved to update dirty state on success
|
|
1088
780
|
// NOTE: `let` (not `const`) — when the server rewrites markdown_source
|
|
@@ -1100,8 +792,11 @@ export function initializeCreativeRowEditor() {
|
|
|
1100
792
|
if (progressHiddenInput) progressHiddenInput.disabled = true;
|
|
1101
793
|
}
|
|
1102
794
|
|
|
1103
|
-
|
|
1104
|
-
if (!r.ok)
|
|
795
|
+
return creativesApi.save(form.action, method, form).then(function (r) {
|
|
796
|
+
if (!r.ok) {
|
|
797
|
+
applySaveStatus('error');
|
|
798
|
+
return r;
|
|
799
|
+
}
|
|
1105
800
|
return r.text().then(function (text) {
|
|
1106
801
|
try { return text ? JSON.parse(text) : {}; } catch (e) { return {}; }
|
|
1107
802
|
}).then(function (data) {
|
|
@@ -1194,15 +889,26 @@ export function initializeCreativeRowEditor() {
|
|
|
1194
889
|
}
|
|
1195
890
|
}
|
|
1196
891
|
updateActionButtonStates();
|
|
892
|
+
// Only announce "saved" when the current buffer still matches what we
|
|
893
|
+
// just persisted. Text edits during the in-flight save keep isDirty
|
|
894
|
+
// true; a non-text change (e.g. a second progress toggle) re-arms
|
|
895
|
+
// pendingSave while this early-returns on the shared promise. Either
|
|
896
|
+
// means the latest value isn't persisted, so show "pending".
|
|
897
|
+
applySaveStatus((isDirty || pendingSave) ? 'pending' : 'saved');
|
|
1197
898
|
});
|
|
899
|
+
}).catch(function (err) {
|
|
900
|
+
// Preserve existing rejection propagation; only surface save status.
|
|
901
|
+
applySaveStatus('error');
|
|
902
|
+
throw err;
|
|
1198
903
|
}).finally(function () {
|
|
1199
|
-
saving = false;
|
|
1200
904
|
if (!shouldPersistProgress) {
|
|
1201
905
|
if (progressInput) progressInput.disabled = progressInputsDisabled;
|
|
1202
906
|
if (progressHiddenInput) progressHiddenInput.disabled = hiddenProgressDisabled;
|
|
1203
907
|
}
|
|
1204
908
|
});
|
|
1205
|
-
|
|
909
|
+
}
|
|
910
|
+
return waitForUploads().then(function () {
|
|
911
|
+
return saveQueue.runExclusive(performSave);
|
|
1206
912
|
});
|
|
1207
913
|
}
|
|
1208
914
|
|
|
@@ -1229,7 +935,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1229
935
|
|
|
1230
936
|
const finalizeHide = function () {
|
|
1231
937
|
template.style.display = 'none';
|
|
1232
|
-
const p = (pendingSave || saving) ? saveForm(tree, parentId) : Promise.resolve();
|
|
938
|
+
const p = (pendingSave || saveQueue.saving) ? saveForm(tree, parentId) : Promise.resolve();
|
|
1233
939
|
return p.then(() => {
|
|
1234
940
|
if (wasNew && !form.dataset.creativeId) {
|
|
1235
941
|
removeTreeElement(tree);
|
|
@@ -1286,7 +992,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1286
992
|
}
|
|
1287
993
|
|
|
1288
994
|
function beforeNewOrMove(wasNew, prev, prevParent) {
|
|
1289
|
-
const needsSave = pendingSave || wasNew || saving;
|
|
995
|
+
const needsSave = pendingSave || wasNew || saveQueue.saving;
|
|
1290
996
|
const p = needsSave ? saveForm(prev, prevParent) : Promise.resolve();
|
|
1291
997
|
return p.then(() => {
|
|
1292
998
|
if (wasNew && !form.dataset.creativeId) {
|
|
@@ -1489,7 +1195,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1489
1195
|
}
|
|
1490
1196
|
isDirty = false;
|
|
1491
1197
|
pendingSave = false;
|
|
1492
|
-
|
|
1198
|
+
saveQueue.cancelTimer();
|
|
1493
1199
|
}
|
|
1494
1200
|
|
|
1495
1201
|
async function move(delta) {
|
|
@@ -1634,7 +1340,10 @@ export function initializeCreativeRowEditor() {
|
|
|
1634
1340
|
insertBefore = normalizeRowNode(firstChild);
|
|
1635
1341
|
beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
|
|
1636
1342
|
} else {
|
|
1637
|
-
parentId
|
|
1343
|
+
// parentId convention: `data-parent-id` is always present; "" means
|
|
1344
|
+
// root. Normalize to '' so an absent attribute (legacy DOM) and a
|
|
1345
|
+
// real root are treated identically, and a real parent id is kept.
|
|
1346
|
+
parentId = prev.dataset.parentId || '';
|
|
1638
1347
|
container = treeContainerElement(prev);
|
|
1639
1348
|
afterId = prev.dataset.id;
|
|
1640
1349
|
insertBefore = nodeAfterTreeBlock(prev);
|
|
@@ -1940,6 +1649,10 @@ export function initializeCreativeRowEditor() {
|
|
|
1940
1649
|
originalProgress = 0;
|
|
1941
1650
|
if (unconvertBtn) unconvertBtn.style.display = 'none';
|
|
1942
1651
|
pendingSave = false;
|
|
1652
|
+
isDirty = false;
|
|
1653
|
+
// The status span is shared across rows; clear the previous row's
|
|
1654
|
+
// "saved"/"failed" label so a blank new row doesn't inherit it.
|
|
1655
|
+
setSaveStatus('');
|
|
1943
1656
|
lexicalEditor.focus();
|
|
1944
1657
|
updateActionButtonStates();
|
|
1945
1658
|
document.dispatchEvent(new CustomEvent('creative-editing:start', {
|
|
@@ -1971,8 +1684,15 @@ export function initializeCreativeRowEditor() {
|
|
|
1971
1684
|
if (creativeId && destroyedCreativeIds.has(String(creativeId))) return;
|
|
1972
1685
|
|
|
1973
1686
|
pendingSave = true;
|
|
1974
|
-
|
|
1975
|
-
|
|
1687
|
+
// A prior "saved"/"failed" label would otherwise claim the freshly edited
|
|
1688
|
+
// buffer is persisted during the 5s debounce window, so reflect the pending
|
|
1689
|
+
// save the moment the buffer diverges from what was last stored. The request
|
|
1690
|
+
// hasn't started yet, so this is "pending" (waiting), not "saving". Gate on
|
|
1691
|
+
// pendingSave (just set true), not isDirty: structure-only changes (level
|
|
1692
|
+
// up/down, reorder) schedule a save without setting isDirty, and must not
|
|
1693
|
+
// keep showing the previous "saved" label.
|
|
1694
|
+
setSaveStatus('pending');
|
|
1695
|
+
saveQueue.schedule(function () { saveForm(); });
|
|
1976
1696
|
}
|
|
1977
1697
|
|
|
1978
1698
|
function onLexicalChange(payload) {
|
|
@@ -2089,7 +1809,7 @@ export function initializeCreativeRowEditor() {
|
|
|
2089
1809
|
// Save immediately on checkbox change to prevent losing the last toggle
|
|
2090
1810
|
// when the user navigates away before the debounce timer fires.
|
|
2091
1811
|
pendingSave = true;
|
|
2092
|
-
|
|
1812
|
+
saveQueue.cancelTimer();
|
|
2093
1813
|
saveForm();
|
|
2094
1814
|
});
|
|
2095
1815
|
}
|