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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. 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
- if (form.dataset.creativeId && item.path.includes(form.dataset.creativeId)) {
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
- let saveTimer = null;
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
- return waitForUploads().then(function () {
1068
- if (saving) return savePromise;
1069
- clearTimeout(saveTimer);
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
- return Promise.resolve();
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 Promise.resolve();
1085
- saving = true;
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
- savePromise = creativesApi.save(form.action, method, form).then(function (r) {
1104
- if (!r.ok) return r;
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
- return savePromise;
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
- clearTimeout(saveTimer);
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 = prev.dataset.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
- clearTimeout(saveTimer);
1975
- saveTimer = setTimeout(saveForm, 5000);
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
- clearTimeout(saveTimer);
1812
+ saveQueue.cancelTimer();
2093
1813
  saveForm();
2094
1814
  });
2095
1815
  }