collavre 0.1.1 → 0.2.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
  3. data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
  4. data/app/assets/stylesheets/collavre/popup.css +7 -0
  5. data/app/assets/stylesheets/collavre/print.css +18 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +33 -0
  7. data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
  8. data/app/components/collavre/autocomplete_popup_component.rb +18 -0
  9. data/app/components/collavre/command_menu_component.rb +7 -0
  10. data/app/components/collavre/plans_timeline_component.html.erb +1 -1
  11. data/app/components/collavre/plans_timeline_component.rb +29 -32
  12. data/app/components/collavre/user_mention_menu_component.rb +4 -5
  13. data/app/controllers/collavre/comments_controller.rb +111 -10
  14. data/app/controllers/collavre/creatives_controller.rb +8 -0
  15. data/app/controllers/collavre/google_auth_controller.rb +5 -1
  16. data/app/controllers/collavre/plans_controller.rb +65 -9
  17. data/app/controllers/collavre/topics_controller.rb +42 -0
  18. data/app/controllers/collavre/users_controller.rb +4 -14
  19. data/app/errors/collavre/approval_pending_error.rb +54 -0
  20. data/app/errors/collavre/cancelled_error.rb +9 -0
  21. data/app/helpers/collavre/navigation_helper.rb +3 -1
  22. data/app/javascript/collavre.js +1 -0
  23. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
  24. data/app/javascript/controllers/comments/form_controller.js +2 -1
  25. data/app/javascript/controllers/comments/list_controller.js +185 -2
  26. data/app/javascript/controllers/comments/popup_controller.js +95 -20
  27. data/app/javascript/controllers/comments/presence_controller.js +30 -1
  28. data/app/javascript/controllers/comments/topics_controller.js +314 -4
  29. data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
  30. data/app/javascript/modules/command_menu.js +116 -0
  31. data/app/javascript/modules/creative_progress.js +14 -0
  32. data/app/javascript/modules/creative_row_editor.js +104 -20
  33. data/app/javascript/modules/plans_timeline.js +15 -4
  34. data/app/javascript/modules/share_modal.js +3 -0
  35. data/app/jobs/collavre/ai_agent_job.rb +35 -21
  36. data/app/models/collavre/calendar_event.rb +7 -1
  37. data/app/models/collavre/comment.rb +35 -2
  38. data/app/models/collavre/creative.rb +1 -3
  39. data/app/models/collavre/mcp_tool.rb +4 -0
  40. data/app/models/collavre/plan.rb +23 -0
  41. data/app/models/collavre/topic.rb +12 -0
  42. data/app/models/collavre/user.rb +15 -1
  43. data/app/services/collavre/ai_agent_service.rb +174 -66
  44. data/app/services/collavre/ai_client.rb +31 -2
  45. data/app/services/collavre/comments/action_executor.rb +47 -1
  46. data/app/services/collavre/comments/calendar_command.rb +117 -18
  47. data/app/services/collavre/google_calendar_service.rb +38 -15
  48. data/app/services/collavre/markdown_importer.rb +47 -8
  49. data/app/services/collavre/mcp_service.rb +23 -10
  50. data/app/services/collavre/system_events/router.rb +50 -26
  51. data/app/services/collavre/tools/creative_create_service.rb +97 -0
  52. data/app/services/collavre/tools/creative_update_service.rb +116 -0
  53. data/app/views/collavre/comments/_comment.html.erb +2 -2
  54. data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
  55. data/app/views/collavre/comments/fullscreen.html.erb +5 -0
  56. data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
  57. data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
  58. data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
  59. data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
  60. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
  61. data/app/views/collavre/creatives/_share_button.html.erb +1 -1
  62. data/app/views/collavre/creatives/index.html.erb +22 -4
  63. data/app/views/collavre/users/edit_ai.html.erb +15 -0
  64. data/app/views/collavre/users/new_ai.html.erb +15 -0
  65. data/app/views/layouts/collavre/chat.html.erb +46 -0
  66. data/config/locales/ai_agent.en.yml +15 -0
  67. data/config/locales/ai_agent.ko.yml +15 -0
  68. data/config/locales/comments.en.yml +15 -3
  69. data/config/locales/comments.ko.yml +15 -3
  70. data/config/locales/creatives.en.yml +3 -31
  71. data/config/locales/creatives.ko.yml +3 -27
  72. data/config/locales/plans.en.yml +4 -0
  73. data/config/locales/plans.ko.yml +4 -0
  74. data/config/locales/users.en.yml +3 -0
  75. data/config/locales/users.ko.yml +3 -0
  76. data/config/routes.rb +8 -3
  77. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
  78. data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
  79. data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
  80. data/lib/collavre/engine.rb +171 -6
  81. data/lib/collavre/integration_registry.rb +129 -0
  82. data/lib/collavre/version.rb +1 -1
  83. data/lib/collavre.rb +2 -0
  84. data/lib/navigation/registry.rb +130 -0
  85. metadata +22 -15
  86. data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
  87. data/app/controllers/collavre/notion_auth_controller.rb +0 -25
  88. data/app/jobs/collavre/notion_export_job.rb +0 -30
  89. data/app/jobs/collavre/notion_sync_job.rb +0 -48
  90. data/app/models/collavre/notion_account.rb +0 -17
  91. data/app/models/collavre/notion_block_link.rb +0 -10
  92. data/app/models/collavre/notion_page_link.rb +0 -19
  93. data/app/services/collavre/notion_client.rb +0 -231
  94. data/app/services/collavre/notion_creative_exporter.rb +0 -296
  95. data/app/services/collavre/notion_service.rb +0 -249
  96. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
  97. data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
  98. data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
  99. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
@@ -86,12 +86,25 @@ export default class extends Controller {
86
86
  }
87
87
 
88
88
  renderTopics(topics, canManage = false) {
89
- let html = `<span class="topic-tag ${this.currentTopicId ? '' : 'active'}" data-action="click->comments--topics#select" data-id="">#Main</span>`
89
+ const dragActions = canManage
90
+ ? 'dragstart->comments--topics#handleTopicDragStart dragend->comments--topics#handleTopicDragEnd'
91
+ : ''
92
+ const dropActions = 'dragover->comments--topics#handleDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleDrop'
93
+ const topicDropActions = canManage
94
+ ? 'dragover->comments--topics#handleTopicReorderDragOver dragleave->comments--topics#handleTopicReorderDragLeave drop->comments--topics#handleTopicReorderDrop'
95
+ : ''
96
+
97
+ let html = `<span class="topic-tag topic-drop-target ${this.currentTopicId ? '' : 'active'}"
98
+ data-action="click->comments--topics#select ${dropActions}"
99
+ data-id="">#Main</span>`
90
100
 
91
101
  topics.forEach(topic => {
92
102
  // Ensure comparison handles string/number difference
93
103
  const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
94
- html += `<span class="topic-tag ${isActive}" data-action="click->comments--topics#select" data-id="${topic.id}">
104
+ const draggable = canManage ? 'draggable="true"' : ''
105
+ html += `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
106
+ data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
107
+ data-id="${topic.id}">
95
108
  #${topic.name}`
96
109
 
97
110
  if (canManage) {
@@ -111,6 +124,163 @@ export default class extends Controller {
111
124
  this.listTarget.innerHTML = html
112
125
  }
113
126
 
127
+ handleDragOver(event) {
128
+ // Only accept comment drops
129
+ if (!event.dataTransfer.types.includes('application/x-comment-ids')) return
130
+
131
+ event.preventDefault()
132
+ event.dataTransfer.dropEffect = 'move'
133
+ event.currentTarget.classList.add('drag-over')
134
+ }
135
+
136
+ handleDragLeave(event) {
137
+ event.currentTarget.classList.remove('drag-over')
138
+ }
139
+
140
+ async handleDrop(event) {
141
+ event.preventDefault()
142
+ event.currentTarget.classList.remove('drag-over')
143
+
144
+ const commentIdsJson = event.dataTransfer.getData('application/x-comment-ids')
145
+ if (!commentIdsJson) return
146
+
147
+ const commentIds = JSON.parse(commentIdsJson)
148
+ if (!commentIds || commentIds.length === 0) return
149
+
150
+ const targetTopicId = event.currentTarget.dataset.id // Empty string for Main
151
+
152
+ // Dispatch event for list_controller to handle the move
153
+ this.dispatch('move-to-topic', {
154
+ detail: {
155
+ commentIds,
156
+ targetTopicId
157
+ }
158
+ })
159
+ }
160
+
161
+ // Topic reorder drag & drop handlers
162
+ handleTopicDragStart(event) {
163
+ const topicEl = event.currentTarget
164
+ const topicId = topicEl.dataset.id
165
+ if (!topicId) {
166
+ event.preventDefault()
167
+ return
168
+ }
169
+
170
+ this.draggingTopicId = topicId
171
+ event.dataTransfer.setData('application/x-topic-id', topicId)
172
+ event.dataTransfer.effectAllowed = 'move'
173
+
174
+ requestAnimationFrame(() => {
175
+ topicEl.classList.add('topic-dragging')
176
+ })
177
+ }
178
+
179
+ handleTopicDragEnd(event) {
180
+ this.draggingTopicId = null
181
+ event.currentTarget.classList.remove('topic-dragging')
182
+ this.listTarget.querySelectorAll('.topic-tag').forEach(el => {
183
+ el.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
184
+ })
185
+ }
186
+
187
+ handleTopicReorderDragOver(event) {
188
+ // Only accept topic reorder drops
189
+ if (!event.dataTransfer.types.includes('application/x-topic-id')) return
190
+ if (!this.draggingTopicId) return
191
+
192
+ const targetEl = event.currentTarget
193
+ const targetId = targetEl.dataset.id
194
+
195
+ // Don't allow drop on self or Main
196
+ if (!targetId || targetId === this.draggingTopicId) return
197
+
198
+ event.preventDefault()
199
+ event.dataTransfer.dropEffect = 'move'
200
+
201
+ // Determine drop position (left or right) based on mouse position
202
+ const rect = targetEl.getBoundingClientRect()
203
+ const midpoint = rect.left + rect.width / 2
204
+ const isLeft = event.clientX < midpoint
205
+
206
+ targetEl.classList.toggle('topic-drag-over-left', isLeft)
207
+ targetEl.classList.toggle('topic-drag-over-right', !isLeft)
208
+ }
209
+
210
+ handleTopicReorderDragLeave(event) {
211
+ event.currentTarget.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
212
+ }
213
+
214
+ async handleTopicReorderDrop(event) {
215
+ event.preventDefault()
216
+
217
+ const targetEl = event.currentTarget
218
+ targetEl.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
219
+
220
+ const draggedTopicId = event.dataTransfer.getData('application/x-topic-id')
221
+ const targetTopicId = targetEl.dataset.id
222
+
223
+ if (!draggedTopicId || !targetTopicId || draggedTopicId === targetTopicId) return
224
+
225
+ // Determine drop position
226
+ const rect = targetEl.getBoundingClientRect()
227
+ const midpoint = rect.left + rect.width / 2
228
+ const insertBefore = event.clientX < midpoint
229
+
230
+ // Reorder topics array
231
+ const topics = [...this.topics]
232
+ const draggedIndex = topics.findIndex(t => String(t.id) === String(draggedTopicId))
233
+ const targetIndex = topics.findIndex(t => String(t.id) === String(targetTopicId))
234
+
235
+ if (draggedIndex === -1 || targetIndex === -1) return
236
+
237
+ // Remove dragged topic
238
+ const [draggedTopic] = topics.splice(draggedIndex, 1)
239
+
240
+ // Calculate new position
241
+ let newIndex = targetIndex
242
+ if (draggedIndex < targetIndex) {
243
+ newIndex = insertBefore ? targetIndex - 1 : targetIndex
244
+ } else {
245
+ newIndex = insertBefore ? targetIndex : targetIndex + 1
246
+ }
247
+
248
+ // Insert at new position
249
+ topics.splice(newIndex, 0, draggedTopic)
250
+
251
+ // Update local state and UI immediately
252
+ this.topics = topics
253
+ this.renderTopics(topics, this.canManageTopics)
254
+ this.restoreSelection()
255
+
256
+ // Send to server
257
+ await this.saveTopicOrder(topics.map(t => t.id))
258
+ }
259
+
260
+ async saveTopicOrder(topicIds) {
261
+ if (!this.creativeId) return
262
+
263
+ try {
264
+ const response = await fetch(`/creatives/${this.creativeId}/topics/reorder`, {
265
+ method: 'POST',
266
+ headers: {
267
+ 'Content-Type': 'application/json',
268
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
269
+ },
270
+ body: JSON.stringify({ topic_ids: topicIds })
271
+ })
272
+
273
+ if (!response.ok) {
274
+ console.error('Failed to save topic order')
275
+ // Reload to restore server state
276
+ this.loadTopics()
277
+ }
278
+ } catch (e) {
279
+ console.error('Error saving topic order', e)
280
+ this.loadTopics()
281
+ }
282
+ }
283
+
114
284
  async deleteTopic(event) {
115
285
  event.stopPropagation()
116
286
  const confirmText = this.listTarget.dataset.confirmDeleteText || "This will delete all messages in this topic. Are you sure?"
@@ -182,8 +352,17 @@ export default class extends Controller {
182
352
  select(event) {
183
353
  // Ignore if clicking on delete button (though stopPropagation should handle it)
184
354
  if (event.target.closest('.delete-topic-btn')) return
355
+ // Ignore if clicking on edit input
356
+ if (event.target.closest('.topic-edit-input')) return
185
357
 
186
358
  const id = event.currentTarget.dataset.id
359
+
360
+ // If clicking on already active topic (not Main), show edit mode
361
+ if (id && String(this.currentTopicId) === String(id) && this.canManageTopics) {
362
+ this.showEditInput(event.currentTarget, id)
363
+ return
364
+ }
365
+
187
366
  this.selectTopic(id)
188
367
  }
189
368
 
@@ -196,15 +375,105 @@ export default class extends Controller {
196
375
  this.dispatch("change", { detail: { topicId: id } })
197
376
  }
198
377
 
378
+ showEditInput(topicEl, topicId) {
379
+ const topic = this.topics.find(t => String(t.id) === String(topicId))
380
+ if (!topic) return
381
+
382
+ const currentName = topic.name
383
+
384
+ // Store original HTML for restore
385
+ topicEl.dataset.originalHtml = topicEl.innerHTML
386
+
387
+ // Replace content with input
388
+ topicEl.innerHTML = `<input type="text" class="topic-edit-input" value="${currentName}"
389
+ data-action="keydown->comments--topics#handleEditKey blur->comments--topics#cancelEdit"
390
+ data-topic-id="${topicId}">`
391
+
392
+ const input = topicEl.querySelector('input')
393
+ requestAnimationFrame(() => {
394
+ input.focus()
395
+ input.select()
396
+ })
397
+ }
398
+
399
+ handleEditKey(event) {
400
+ if (event.key === 'Enter') {
401
+ event.preventDefault()
402
+ const name = event.target.value.trim()
403
+ const topicId = event.target.dataset.topicId
404
+ if (name) {
405
+ this.updateTopic(topicId, name)
406
+ } else {
407
+ this.cancelEdit(event)
408
+ }
409
+ } else if (event.key === 'Escape') {
410
+ event.preventDefault()
411
+ this.cancelEdit(event)
412
+ }
413
+ }
414
+
415
+ cancelEdit(event) {
416
+ // Prevent blur from firing multiple times
417
+ if (this.editCancelling) return
418
+ this.editCancelling = true
419
+
420
+ const topicEl = event.target.closest('.topic-tag')
421
+ if (topicEl && topicEl.dataset.originalHtml) {
422
+ topicEl.innerHTML = topicEl.dataset.originalHtml
423
+ delete topicEl.dataset.originalHtml
424
+ }
425
+
426
+ setTimeout(() => { this.editCancelling = false }, 100)
427
+ }
428
+
429
+ async updateTopic(topicId, name) {
430
+ if (!this.creativeId) return
431
+
432
+ try {
433
+ const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}`, {
434
+ method: 'PATCH',
435
+ headers: {
436
+ 'Content-Type': 'application/json',
437
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
438
+ },
439
+ body: JSON.stringify({ topic: { name } })
440
+ })
441
+
442
+ if (response.ok) {
443
+ const updatedTopic = await response.json()
444
+ // Update local topics array
445
+ const index = this.topics.findIndex(t => String(t.id) === String(topicId))
446
+ if (index !== -1) {
447
+ this.topics[index] = updatedTopic
448
+ }
449
+ this.renderTopics(this.topics, this.canManageTopics)
450
+ this.restoreSelection()
451
+ } else {
452
+ alert("Failed to update topic")
453
+ this.loadTopics() // Reload to restore state
454
+ }
455
+ } catch (e) {
456
+ console.error("Error updating topic", e)
457
+ this.loadTopics()
458
+ }
459
+ }
460
+
199
461
  updateSelectionUI(id) {
200
462
  this.currentTopicId = id
201
463
  // Update UI
464
+ let activeEl = null
202
465
  this.listTarget.querySelectorAll('.topic-tag').forEach(el => {
203
- el.classList.toggle('active', String(el.dataset.id) === String(id))
204
- if (String(el.dataset.id) === String(id)) {
466
+ const isActive = String(el.dataset.id) === String(id)
467
+ el.classList.toggle('active', isActive)
468
+ if (isActive) {
205
469
  el.classList.remove('has-new-messages')
470
+ activeEl = el
206
471
  }
207
472
  })
473
+ // Scroll active topic into view
474
+ if (activeEl) {
475
+ activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
476
+ }
208
477
  }
209
478
 
210
479
  handleNewMessage(event) {
@@ -308,6 +577,16 @@ export default class extends Controller {
308
577
  return
309
578
  }
310
579
 
580
+ if (action === "updated" && data.topic) {
581
+ this.updateTopicInList(data.topic)
582
+ return
583
+ }
584
+
585
+ if (action === "reordered" && data.topic_ids) {
586
+ this.reorderTopicsFromServer(data.topic_ids)
587
+ return
588
+ }
589
+
311
590
  if (!data.topic) return
312
591
 
313
592
  const topics = this.topics || []
@@ -319,6 +598,37 @@ export default class extends Controller {
319
598
  this.restoreSelection()
320
599
  }
321
600
 
601
+ reorderTopicsFromServer(topicIds) {
602
+ if (!topicIds || !Array.isArray(topicIds)) return
603
+
604
+ const reorderedTopics = []
605
+ topicIds.forEach(id => {
606
+ const topic = this.topics.find(t => String(t.id) === String(id))
607
+ if (topic) reorderedTopics.push(topic)
608
+ })
609
+
610
+ // Add any topics not in the list (shouldn't happen, but safety)
611
+ this.topics.forEach(topic => {
612
+ if (!reorderedTopics.find(t => String(t.id) === String(topic.id))) {
613
+ reorderedTopics.push(topic)
614
+ }
615
+ })
616
+
617
+ this.topics = reorderedTopics
618
+ this.renderTopics(this.topics, this.canManageTopics)
619
+ this.restoreSelection()
620
+ }
621
+
622
+ updateTopicInList(updatedTopic) {
623
+ const topics = this.topics || []
624
+ const index = topics.findIndex(t => String(t.id) === String(updatedTopic.id))
625
+ if (index === -1) return
626
+
627
+ this.topics[index] = updatedTopic
628
+ this.renderTopics(this.topics, this.canManageTopics)
629
+ this.restoreSelection()
630
+ }
631
+
322
632
  removeTopic(topicId) {
323
633
  if (!topicId) return
324
634
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from '../creative_progress';
5
+
6
+ describe('creative progress helpers', () => {
7
+ describe('isProgressComplete', () => {
8
+ test('returns true for values >= 1', () => {
9
+ expect(isProgressComplete(1)).toBe(true);
10
+ expect(isProgressComplete(1.0)).toBe(true);
11
+ expect(isProgressComplete(1.5)).toBe(true);
12
+ });
13
+
14
+ test('returns false for values < 1', () => {
15
+ expect(isProgressComplete(0)).toBe(false);
16
+ expect(isProgressComplete(0.5)).toBe(false);
17
+ expect(isProgressComplete(0.99)).toBe(false);
18
+ });
19
+
20
+ test('returns false for NaN values', () => {
21
+ expect(isProgressComplete(NaN)).toBe(false);
22
+ expect(isProgressComplete('invalid')).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe('progressBaselineValueFrom', () => {
27
+ test('returns 1 for complete values', () => {
28
+ expect(progressBaselineValueFrom(1)).toBe(1);
29
+ expect(progressBaselineValueFrom(1.5)).toBe(1);
30
+ });
31
+
32
+ test('returns 0 for incomplete values', () => {
33
+ expect(progressBaselineValueFrom(0)).toBe(0);
34
+ expect(progressBaselineValueFrom(0.4)).toBe(0);
35
+ expect(progressBaselineValueFrom(0.99)).toBe(0);
36
+ });
37
+ });
38
+
39
+ describe('progressValueChangedFrom', () => {
40
+ test('preserves fractional values unless checkbox toggled', () => {
41
+ expect(progressValueChangedFrom(0.4, false)).toBe(false);
42
+ expect(progressValueChangedFrom(0.4, true)).toBe(true);
43
+ });
44
+
45
+ test('detects change from complete to incomplete', () => {
46
+ expect(progressValueChangedFrom(1, false)).toBe(true);
47
+ expect(progressValueChangedFrom(1, true)).toBe(false);
48
+ });
49
+ });
50
+ });
@@ -0,0 +1,116 @@
1
+ import CommonPopup from '../lib/common_popup'
2
+ import { getCaretClientRect } from '../utils/caret_position'
3
+
4
+ let commandMenuInitialized = false
5
+
6
+ if (!commandMenuInitialized) {
7
+ commandMenuInitialized = true
8
+
9
+ document.addEventListener('turbo:load', function () {
10
+ const textarea = document.querySelector('#new-comment-form textarea')
11
+ const menu = document.getElementById('command-menu')
12
+ const popup = document.getElementById('comments-popup')
13
+ if (!textarea || !menu || !popup) return
14
+
15
+ const list = menu.querySelector('[data-popup-list]')
16
+ const commandCache = new Map()
17
+
18
+ const popupMenu = new CommonPopup(menu, {
19
+ listElement: list,
20
+ renderItem: (command) => {
21
+ const aliasLabel = command.aliases?.length
22
+ ? `<span class="command-aliases">(${command.aliases.join(', ')})</span>`
23
+ : ''
24
+ const args = command.args ? `<span class="command-args">${command.args}</span>` : ''
25
+ const description = command.description
26
+ ? `<div class="command-description">${command.description}</div>`
27
+ : ''
28
+ return `
29
+ <div class="command-item">
30
+ <span class="command-label">${command.label}</span>
31
+ ${aliasLabel}
32
+ ${args}
33
+ </div>
34
+ ${description}
35
+ `
36
+ },
37
+ onSelect: (command) => {
38
+ insert(command)
39
+ popupMenu.hide()
40
+ textarea.focus()
41
+ }
42
+ })
43
+
44
+ function fetchCommands(creativeId) {
45
+ if (!creativeId) return Promise.resolve([])
46
+ if (commandCache.has(creativeId)) return Promise.resolve(commandCache.get(creativeId))
47
+
48
+ return fetch(`/creatives/${creativeId}/comments/commands`, { headers: { Accept: 'application/json' } })
49
+ .then((response) => (response.ok ? response.json() : []))
50
+ .then((data) => {
51
+ const list = Array.isArray(data) ? data : []
52
+ commandCache.set(creativeId, list)
53
+ return list
54
+ })
55
+ .catch(() => [])
56
+ }
57
+
58
+ function insert(command) {
59
+ const pos = textarea.selectionStart
60
+ const after = textarea.value.slice(pos)
61
+ // Since "/" is always at the start, replace from beginning
62
+ const replaced = `${command.label} `
63
+ textarea.value = replaced + after
64
+ textarea.setSelectionRange(replaced.length, replaced.length)
65
+ }
66
+
67
+ function hide() {
68
+ popupMenu.hide()
69
+ }
70
+
71
+ function show(commands, query) {
72
+ if (!commands || commands.length === 0) {
73
+ hide()
74
+ return
75
+ }
76
+
77
+ const lowered = query.toLowerCase()
78
+ const filtered = commands.filter((command) => {
79
+ if (!lowered) return true
80
+ const name = command.name?.toLowerCase?.() || ''
81
+ const aliases = (command.aliases || []).map((alias) => alias.toLowerCase())
82
+ return name.includes(lowered) || aliases.some((alias) => alias.replace('/', '').includes(lowered))
83
+ })
84
+
85
+ if (filtered.length === 0) {
86
+ hide()
87
+ return
88
+ }
89
+
90
+ popupMenu.setItems(filtered)
91
+ const caretRect = getCaretClientRect(textarea) || textarea.getBoundingClientRect()
92
+ popupMenu.showAt(caretRect)
93
+ }
94
+
95
+ textarea.addEventListener('keydown', function (event) {
96
+ if (popupMenu.handleKey(event)) return
97
+ })
98
+
99
+ textarea.addEventListener('input', function () {
100
+ const pos = textarea.selectionStart
101
+ const before = textarea.value.slice(0, pos)
102
+ // Only trigger when "/" is at the very beginning of the message
103
+ const match = before.match(/^\/([^\s/]*)$/)
104
+ if (!match) {
105
+ hide()
106
+ return
107
+ }
108
+
109
+ const creativeId = popup.dataset.creativeId
110
+ const query = match[1]
111
+
112
+ fetchCommands(creativeId)
113
+ .then((commands) => show(commands, query))
114
+ })
115
+ })
116
+ }
@@ -0,0 +1,14 @@
1
+ export function isProgressComplete(value) {
2
+ const numeric = Number(value);
3
+ return !Number.isNaN(numeric) && numeric >= 1;
4
+ }
5
+
6
+ export function progressBaselineValueFrom(originalProgress) {
7
+ return isProgressComplete(originalProgress) ? 1 : 0;
8
+ }
9
+
10
+ export function progressValueChangedFrom(originalProgress, checked) {
11
+ const baseline = progressBaselineValueFrom(originalProgress);
12
+ const current = checked ? 1 : 0;
13
+ return current !== baseline;
14
+ }