collavre 0.8.2 → 0.9.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/stylesheets/collavre/activity_logs.css +1 -1
- data/app/assets/stylesheets/collavre/comment_versions.css +10 -6
- data/app/assets/stylesheets/collavre/comments_popup.css +60 -2
- data/app/assets/stylesheets/collavre/creatives.css +7 -4
- data/app/assets/stylesheets/collavre/dark_mode.css +25 -5
- data/app/controllers/collavre/comments_controller.rb +2 -1
- data/app/controllers/collavre/topics_controller.rb +80 -3
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +5 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +32 -0
- data/app/javascript/controllers/comment_version_controller.js +5 -1
- data/app/javascript/controllers/comments/contexts_controller.js +118 -3
- data/app/javascript/controllers/comments/list_controller.js +67 -1
- data/app/javascript/controllers/comments/popup_controller.js +143 -10
- data/app/javascript/controllers/comments/presence_controller.js +22 -0
- data/app/javascript/controllers/comments/topics_controller.js +105 -6
- data/app/javascript/controllers/creatives/expansion_controller.js +8 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
- data/app/javascript/modules/creative_row_editor.js +13 -0
- data/app/jobs/collavre/compress_job.rb +24 -25
- data/app/jobs/collavre/merge_comments_job.rb +79 -0
- data/app/models/collavre/topic.rb +29 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
- data/app/services/collavre/comments/topic_command.rb +27 -13
- data/app/views/collavre/comments/_comments_popup.html.erb +5 -1
- data/config/locales/comments.en.yml +11 -1
- data/config/locales/comments.ko.yml +11 -1
- data/config/routes.rb +2 -0
- data/lib/collavre/version.rb +1 -1
- metadata +3 -1
|
@@ -513,14 +513,19 @@ export default class extends Controller {
|
|
|
513
513
|
if (this.selection.size === 0) return
|
|
514
514
|
|
|
515
515
|
const count = this.selection.size
|
|
516
|
+
const total = this.listTarget.querySelectorAll('.comment-select-checkbox').length
|
|
517
|
+
const allSelected = count === total
|
|
516
518
|
const i18n = (key, fallback) => this.element.dataset[key] || fallback
|
|
519
|
+
const selectAllLabel = i18n('selectionSelectAllText', 'All')
|
|
517
520
|
|
|
518
521
|
const bar = document.createElement('div')
|
|
519
522
|
bar.className = 'selection-action-bar'
|
|
520
523
|
bar.innerHTML = `
|
|
521
524
|
<div class="selection-action-bar-main">
|
|
522
|
-
<
|
|
525
|
+
<label class="selection-action-bar-select-all"><input type="checkbox" class="selection-action-bar-select-all-checkbox"${allSelected ? ' checked' : ''}> ${selectAllLabel}</label>
|
|
526
|
+
<span class="selection-action-bar-count">${i18n('selectionCountText', '{count}/{total}개 선택').replace('{count}', count).replace('{total}', total)}</span>
|
|
523
527
|
<button type="button" class="selection-action-bar-btn selection-action-delete" title="${i18n('selectionDeleteText', 'Delete')}">🗑 ${i18n('selectionDeleteText', 'Delete')}</button>
|
|
528
|
+
<button type="button" class="selection-action-bar-btn selection-action-merge" title="${i18n('selectionMergeText', 'Merge')}"${count < 2 ? ' disabled' : ''}>🔗 ${i18n('selectionMergeText', 'Merge')}</button>
|
|
524
529
|
<button type="button" class="selection-action-bar-btn selection-action-move" title="${i18n('selectionMoveText', 'Move')}">📤 ${i18n('selectionMoveText', 'Move')}</button>
|
|
525
530
|
<button type="button" class="selection-action-bar-btn selection-action-topic" title="${i18n('selectionTopicMoveText', 'Move to topic')}">🏷 ${i18n('selectionTopicMoveText', 'Move to topic')}</button>
|
|
526
531
|
<button type="button" class="selection-action-bar-close" title="${i18n('selectionCloseText', 'Cancel')}">✕</button>
|
|
@@ -530,7 +535,40 @@ export default class extends Controller {
|
|
|
530
535
|
</div>
|
|
531
536
|
`
|
|
532
537
|
|
|
538
|
+
// Set indeterminate state if partially selected
|
|
539
|
+
const selectAllCheckbox = bar.querySelector('.selection-action-bar-select-all-checkbox')
|
|
540
|
+
if (count > 0 && !allSelected) {
|
|
541
|
+
selectAllCheckbox.indeterminate = true
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Select All toggle handler
|
|
545
|
+
selectAllCheckbox.addEventListener('change', () => {
|
|
546
|
+
const shouldSelect = selectAllCheckbox.checked
|
|
547
|
+
this.listTarget.querySelectorAll('.comment-select-checkbox').forEach((checkbox) => {
|
|
548
|
+
const commentId = checkbox.value
|
|
549
|
+
const item = checkbox.closest('.comment-item')
|
|
550
|
+
if (shouldSelect && !checkbox.checked) {
|
|
551
|
+
checkbox.checked = true
|
|
552
|
+
this.selection.add(commentId)
|
|
553
|
+
if (item) {
|
|
554
|
+
item.classList.add('selected-for-move')
|
|
555
|
+
item.setAttribute('draggable', 'true')
|
|
556
|
+
}
|
|
557
|
+
} else if (!shouldSelect && checkbox.checked) {
|
|
558
|
+
checkbox.checked = false
|
|
559
|
+
this.selection.delete(commentId)
|
|
560
|
+
if (item) {
|
|
561
|
+
item.classList.remove('selected-for-move')
|
|
562
|
+
item.removeAttribute('draggable')
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
this.notifySelectionChange()
|
|
567
|
+
this.updateSelectionActionBar()
|
|
568
|
+
})
|
|
569
|
+
|
|
533
570
|
bar.querySelector('.selection-action-delete').addEventListener('click', (e) => { e.stopPropagation(); this.deleteSelectedComments() })
|
|
571
|
+
bar.querySelector('.selection-action-merge').addEventListener('click', (e) => { e.stopPropagation(); this.mergeSelectedComments() })
|
|
534
572
|
bar.querySelector('.selection-action-move').addEventListener('click', (e) => this.openMoveModal(e))
|
|
535
573
|
bar.querySelector('.selection-action-topic').addEventListener('click', (e) => this.openTopicSearchPopup(e))
|
|
536
574
|
bar.querySelector('.selection-action-bar-close').addEventListener('click', () => this.clearSelection())
|
|
@@ -575,6 +613,34 @@ export default class extends Controller {
|
|
|
575
613
|
}
|
|
576
614
|
}
|
|
577
615
|
|
|
616
|
+
async mergeSelectedComments() {
|
|
617
|
+
if (this.selection.size < 2) return
|
|
618
|
+
const confirmText = this.element.dataset.mergeConfirmText || 'Merge the selected messages into one?'
|
|
619
|
+
if (!confirm(confirmText)) return
|
|
620
|
+
|
|
621
|
+
const commentIds = Array.from(this.selection)
|
|
622
|
+
try {
|
|
623
|
+
const response = await fetch(`/creatives/${this.creativeId}/comments/merge`, {
|
|
624
|
+
method: 'POST',
|
|
625
|
+
headers: {
|
|
626
|
+
'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
|
|
627
|
+
'Content-Type': 'application/json',
|
|
628
|
+
},
|
|
629
|
+
body: JSON.stringify({ comment_ids: commentIds }),
|
|
630
|
+
})
|
|
631
|
+
if (response.ok || response.status === 202) {
|
|
632
|
+
this.clearSelection()
|
|
633
|
+
// Job is async — the merged comment will update via broadcast
|
|
634
|
+
} else {
|
|
635
|
+
const data = await response.json().catch(() => ({}))
|
|
636
|
+
alert(data.error || 'Failed to merge comments')
|
|
637
|
+
}
|
|
638
|
+
} catch (error) {
|
|
639
|
+
console.error('Error merging comments:', error)
|
|
640
|
+
alert('Failed to merge comments')
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
578
644
|
openTopicSearchPopup(event) {
|
|
579
645
|
if (this.selection.size === 0) return
|
|
580
646
|
|
|
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
|
|
2
2
|
|
|
3
3
|
const SIZE_STORAGE_KEY = 'commentsPopupSize'
|
|
4
4
|
const CREATIVE_CLICK_EVENT = 'creative-comments-click'
|
|
5
|
+
const CREATIVE_DESTROYED_EVENT = 'creative-destroyed'
|
|
5
6
|
|
|
6
7
|
export default class extends Controller {
|
|
7
8
|
static targets = [
|
|
@@ -24,6 +25,7 @@ export default class extends Controller {
|
|
|
24
25
|
this.openFromUrlObserver = null
|
|
25
26
|
this.openFromUrlTimeout = null
|
|
26
27
|
this.handleCreativeClick = this.handleCreativeClick.bind(this)
|
|
28
|
+
this.handleCreativeDestroyed = this.handleCreativeDestroyed.bind(this)
|
|
27
29
|
this.handleTouchStart = this.handleTouchStart.bind(this)
|
|
28
30
|
this.handleTouchEnd = this.handleTouchEnd.bind(this)
|
|
29
31
|
this.handleResizeMove = this.handleResizeMove.bind(this)
|
|
@@ -37,6 +39,7 @@ export default class extends Controller {
|
|
|
37
39
|
this.handlePopupWheel = this.handlePopupWheel.bind(this)
|
|
38
40
|
|
|
39
41
|
document.addEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
|
|
42
|
+
document.addEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
|
|
40
43
|
this.element.addEventListener('wheel', this.handlePopupWheel, { passive: false })
|
|
41
44
|
window.addEventListener('online', this.handleOnline)
|
|
42
45
|
window.addEventListener('focus', this.handleWindowFocus)
|
|
@@ -92,6 +95,7 @@ export default class extends Controller {
|
|
|
92
95
|
disconnect() {
|
|
93
96
|
this.clearPendingOpenFromUrl()
|
|
94
97
|
document.removeEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
|
|
98
|
+
document.removeEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
|
|
95
99
|
this.element.removeEventListener('wheel', this.handlePopupWheel)
|
|
96
100
|
window.removeEventListener('online', this.handleOnline)
|
|
97
101
|
window.removeEventListener('focus', this.handleWindowFocus)
|
|
@@ -147,6 +151,14 @@ export default class extends Controller {
|
|
|
147
151
|
this.open(button, { creativeId })
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
handleCreativeDestroyed(event) {
|
|
155
|
+
const destroyedIds = event.detail?.creativeIds || []
|
|
156
|
+
if (this.element.style.display !== 'flex') return
|
|
157
|
+
if (destroyedIds.includes(this.element.dataset.creativeId)) {
|
|
158
|
+
this.close()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
150
162
|
async open(button, { creativeId, highlightId } = {}) {
|
|
151
163
|
this.currentButton = button
|
|
152
164
|
const resolvedCreativeId = creativeId || button?.dataset.creativeId
|
|
@@ -157,6 +169,8 @@ export default class extends Controller {
|
|
|
157
169
|
this.element.dataset.canComment = canComment ? 'true' : 'false'
|
|
158
170
|
this.titleTarget.textContent = snippet
|
|
159
171
|
|
|
172
|
+
this._markChatActiveRow(resolvedCreativeId)
|
|
173
|
+
|
|
160
174
|
this.prepareSize()
|
|
161
175
|
|
|
162
176
|
this.showPopup()
|
|
@@ -184,6 +198,8 @@ export default class extends Controller {
|
|
|
184
198
|
this.element.dataset.canComment = canComment ? 'true' : 'false'
|
|
185
199
|
this.titleTarget.textContent = snippet
|
|
186
200
|
|
|
201
|
+
this._markChatActiveRow(resolvedCreativeId)
|
|
202
|
+
|
|
187
203
|
this.showPopup()
|
|
188
204
|
|
|
189
205
|
await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment })
|
|
@@ -268,6 +284,8 @@ export default class extends Controller {
|
|
|
268
284
|
}
|
|
269
285
|
}))
|
|
270
286
|
|
|
287
|
+
this._clearChatActiveRow()
|
|
288
|
+
|
|
271
289
|
this.element.style.display = 'none'
|
|
272
290
|
this.element.classList.remove('open')
|
|
273
291
|
this.element.style.width = ''
|
|
@@ -313,14 +331,27 @@ export default class extends Controller {
|
|
|
313
331
|
updatePosition() {
|
|
314
332
|
if (this.isFullscreen() || !this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
|
|
315
333
|
const rect = this.currentButton.getBoundingClientRect()
|
|
334
|
+
const popupWidth = this.element.offsetWidth
|
|
335
|
+
const popupHeight = this.element.offsetHeight
|
|
336
|
+
const gap = 8
|
|
337
|
+
|
|
316
338
|
let top = rect.bottom + 4
|
|
317
|
-
const bottom = top +
|
|
339
|
+
const bottom = top + popupHeight
|
|
318
340
|
if (bottom > window.innerHeight) {
|
|
319
|
-
top = Math.max(4, window.innerHeight -
|
|
341
|
+
top = Math.max(4, window.innerHeight - popupHeight - 4)
|
|
320
342
|
}
|
|
321
343
|
this.element.style.top = `${top}px`
|
|
322
|
-
|
|
323
|
-
|
|
344
|
+
|
|
345
|
+
// If there's enough space to the right of the button, align popup to the right
|
|
346
|
+
// so the creative list on the left remains visible
|
|
347
|
+
const spaceRight = window.innerWidth - rect.right - gap
|
|
348
|
+
if (spaceRight >= popupWidth) {
|
|
349
|
+
this.element.style.left = `${rect.right + gap}px`
|
|
350
|
+
this.element.style.right = ''
|
|
351
|
+
} else {
|
|
352
|
+
this.element.style.right = `${window.innerWidth - rect.right + 24}px`
|
|
353
|
+
this.element.style.left = ''
|
|
354
|
+
}
|
|
324
355
|
}
|
|
325
356
|
|
|
326
357
|
startResize(event, direction) {
|
|
@@ -440,6 +471,38 @@ export default class extends Controller {
|
|
|
440
471
|
handlePopupWheel(event) {
|
|
441
472
|
if (this.isFullscreen()) return // fullscreen already blocks body scroll via CSS
|
|
442
473
|
|
|
474
|
+
// Don't interfere with scroll inside overlays (e.g., share modal)
|
|
475
|
+
if (event.target.closest('#share-creative-modal')) return
|
|
476
|
+
|
|
477
|
+
// Allow scroll inside any independently scrollable child element.
|
|
478
|
+
// Walk up from the event target to find any element (other than the main
|
|
479
|
+
// comments list, which is handled below) that can scroll on its own.
|
|
480
|
+
const { element: scrollableChild, axis } = this._findScrollableAncestor(event.target, event)
|
|
481
|
+
if (scrollableChild) {
|
|
482
|
+
if (axis === 'x') {
|
|
483
|
+
// Horizontal scroll — check left/right boundaries
|
|
484
|
+
const { scrollLeft, scrollWidth, clientWidth } = scrollableChild
|
|
485
|
+
const isScrollingRight = event.deltaX > 0
|
|
486
|
+
const atLeft = scrollLeft <= 0
|
|
487
|
+
const atRight = scrollLeft + clientWidth >= scrollWidth - 1
|
|
488
|
+
|
|
489
|
+
if ((isScrollingRight && atRight) || (!isScrollingRight && atLeft)) {
|
|
490
|
+
event.preventDefault()
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
// Vertical scroll — check top/bottom boundaries
|
|
494
|
+
const { scrollTop, scrollHeight, clientHeight } = scrollableChild
|
|
495
|
+
const isScrollingDown = event.deltaY > 0
|
|
496
|
+
const atTop = scrollTop <= 0
|
|
497
|
+
const atBottom = scrollTop + clientHeight >= scrollHeight - 1
|
|
498
|
+
|
|
499
|
+
if ((isScrollingDown && atBottom) || (!isScrollingDown && atTop)) {
|
|
500
|
+
event.preventDefault()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
|
|
443
506
|
if (!this.hasListTarget) {
|
|
444
507
|
event.preventDefault()
|
|
445
508
|
return
|
|
@@ -622,7 +685,7 @@ export default class extends Controller {
|
|
|
622
685
|
if (targetButton) {
|
|
623
686
|
this.currentButton = targetButton
|
|
624
687
|
const btnRect = targetButton.getBoundingClientRect()
|
|
625
|
-
const
|
|
688
|
+
const gap = 8
|
|
626
689
|
|
|
627
690
|
animWidth = parseFloat(finalWidth) || 420
|
|
628
691
|
animHeight = parseFloat(finalHeight) || 640
|
|
@@ -635,10 +698,20 @@ export default class extends Controller {
|
|
|
635
698
|
}
|
|
636
699
|
|
|
637
700
|
finalTop = `${top}px`
|
|
638
|
-
finalRight = `${rightPx}px`
|
|
639
701
|
|
|
640
|
-
|
|
641
|
-
|
|
702
|
+
// Right-align if enough space to the right of the button
|
|
703
|
+
const spaceRight = window.innerWidth - btnRect.right - gap
|
|
704
|
+
if (spaceRight >= animWidth) {
|
|
705
|
+
this._exitToRight = true
|
|
706
|
+
animLeft = btnRect.right + gap
|
|
707
|
+
animTop = top
|
|
708
|
+
} else {
|
|
709
|
+
this._exitToRight = false
|
|
710
|
+
const rightPx = window.innerWidth - btnRect.right + 24
|
|
711
|
+
finalRight = `${rightPx}px`
|
|
712
|
+
animTop = top
|
|
713
|
+
animLeft = window.innerWidth - rightPx - animWidth
|
|
714
|
+
}
|
|
642
715
|
} else if (savedStyles && Object.values(savedStyles).some(v => v)) {
|
|
643
716
|
// Fallback to saved styles (already viewport-relative since popup is fixed)
|
|
644
717
|
const rightVal = parseFloat(savedStyles.right) || 32
|
|
@@ -692,10 +765,15 @@ export default class extends Controller {
|
|
|
692
765
|
|
|
693
766
|
if (targetButton) {
|
|
694
767
|
el.style.top = finalTop
|
|
695
|
-
el.style.right = finalRight
|
|
696
|
-
el.style.left = ''
|
|
697
768
|
el.style.width = finalWidth
|
|
698
769
|
el.style.height = finalHeight
|
|
770
|
+
if (this._exitToRight) {
|
|
771
|
+
el.style.left = `${animLeft}px`
|
|
772
|
+
el.style.right = ''
|
|
773
|
+
} else {
|
|
774
|
+
el.style.right = finalRight
|
|
775
|
+
el.style.left = ''
|
|
776
|
+
}
|
|
699
777
|
} else if (savedStyles) {
|
|
700
778
|
el.style.top = ''
|
|
701
779
|
el.style.left = ''
|
|
@@ -855,4 +933,59 @@ export default class extends Controller {
|
|
|
855
933
|
this.openFromUrlTimeout = null
|
|
856
934
|
}
|
|
857
935
|
}
|
|
936
|
+
|
|
937
|
+
_markChatActiveRow(creativeId) {
|
|
938
|
+
this._clearChatActiveRow()
|
|
939
|
+
if (!creativeId) return
|
|
940
|
+
const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`)
|
|
941
|
+
if (row) row.classList.add('chat-active')
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
_clearChatActiveRow() {
|
|
945
|
+
document.querySelectorAll('creative-tree-row.chat-active').forEach(el => {
|
|
946
|
+
el.classList.remove('chat-active')
|
|
947
|
+
})
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Walk up from the target element to find the nearest scrollable ancestor
|
|
951
|
+
// that is NOT the main comments list (which has its own scroll handling).
|
|
952
|
+
// Detects both vertical and horizontal scrollable elements.
|
|
953
|
+
// Returns { element, axis } or { element: null, axis: null }.
|
|
954
|
+
_findScrollableAncestor(target, event) {
|
|
955
|
+
let el = target
|
|
956
|
+
const listEl = this.hasListTarget ? this.listTarget : null
|
|
957
|
+
const dominantAxis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'x' : 'y'
|
|
958
|
+
|
|
959
|
+
while (el && el !== this.element) {
|
|
960
|
+
// Skip the main comments list — it's handled separately
|
|
961
|
+
if (el === listEl) return { element: null, axis: null }
|
|
962
|
+
|
|
963
|
+
// Cheap size checks first to avoid expensive getComputedStyle calls
|
|
964
|
+
const hasOverflowY = el.scrollHeight > el.clientHeight
|
|
965
|
+
const hasOverflowX = el.scrollWidth > el.clientWidth
|
|
966
|
+
|
|
967
|
+
if (hasOverflowY || hasOverflowX) {
|
|
968
|
+
const style = getComputedStyle(el)
|
|
969
|
+
|
|
970
|
+
// Check dominant axis first for better matching
|
|
971
|
+
if (dominantAxis === 'x' && hasOverflowX) {
|
|
972
|
+
const scrollableX = style.overflowX === 'auto' || style.overflowX === 'scroll'
|
|
973
|
+
if (scrollableX) return { element: el, axis: 'x' }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (hasOverflowY) {
|
|
977
|
+
const scrollableY = style.overflowY === 'auto' || style.overflowY === 'scroll'
|
|
978
|
+
if (scrollableY) return { element: el, axis: 'y' }
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (dominantAxis !== 'x' && hasOverflowX) {
|
|
982
|
+
const scrollableX = style.overflowX === 'auto' || style.overflowX === 'scroll'
|
|
983
|
+
if (scrollableX) return { element: el, axis: 'x' }
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
el = el.parentElement
|
|
988
|
+
}
|
|
989
|
+
return { element: null, axis: null }
|
|
990
|
+
}
|
|
858
991
|
}
|
|
@@ -204,6 +204,28 @@ export default class extends Controller {
|
|
|
204
204
|
if (user.email) img.dataset.email = user.email
|
|
205
205
|
img.dataset.userId = user.id
|
|
206
206
|
img.dataset.userName = user.name
|
|
207
|
+
|
|
208
|
+
// AI agents are draggable to topic tabs
|
|
209
|
+
if (user.ai_user) {
|
|
210
|
+
wrapper.draggable = true
|
|
211
|
+
wrapper.classList.add('ai-agent-draggable')
|
|
212
|
+
wrapper.dataset.agentId = user.id
|
|
213
|
+
wrapper.dataset.agentName = user.name
|
|
214
|
+
wrapper.dataset.agentAvatarUrl = user.avatar_url
|
|
215
|
+
wrapper.addEventListener('dragstart', (e) => {
|
|
216
|
+
e.dataTransfer.setData('application/x-agent-drop', JSON.stringify({
|
|
217
|
+
id: user.id,
|
|
218
|
+
name: user.name,
|
|
219
|
+
avatar_url: user.avatar_url
|
|
220
|
+
}))
|
|
221
|
+
e.dataTransfer.effectAllowed = 'copy'
|
|
222
|
+
wrapper.classList.add('dragging')
|
|
223
|
+
})
|
|
224
|
+
wrapper.addEventListener('dragend', () => {
|
|
225
|
+
wrapper.classList.remove('dragging')
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
wrapper.appendChild(img)
|
|
208
230
|
|
|
209
231
|
if (user.default_avatar) {
|
|
@@ -114,10 +114,13 @@ export default class extends Controller {
|
|
|
114
114
|
// Ensure comparison handles string/number difference
|
|
115
115
|
const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
|
|
116
116
|
const draggable = canManage ? 'draggable="true"' : ''
|
|
117
|
+
const agentAvatar = topic.primary_agent?.avatar_url
|
|
118
|
+
? `<img src="${this.escapeAttr(topic.primary_agent.avatar_url)}" class="topic-agent-avatar" alt="${this.escapeAttr(topic.primary_agent.name)}" title="${this.escapeAttr(topic.primary_agent.name)}">`
|
|
119
|
+
: ''
|
|
117
120
|
html += `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
|
|
118
121
|
data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
|
|
119
122
|
data-id="${topic.id}">
|
|
120
|
-
#${topic.name}`
|
|
123
|
+
${agentAvatar}#${topic.name}`
|
|
121
124
|
|
|
122
125
|
if (canManage) {
|
|
123
126
|
html += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
|
|
@@ -144,7 +147,8 @@ export default class extends Controller {
|
|
|
144
147
|
|
|
145
148
|
// Add create button container (write permission is sufficient for topic creation)
|
|
146
149
|
if (canCreateTopic) {
|
|
147
|
-
html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer"
|
|
150
|
+
html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer"
|
|
151
|
+
data-action="dragover->comments--topics#handleAddButtonDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleAddButtonDrop">
|
|
148
152
|
<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>
|
|
149
153
|
</span>`
|
|
150
154
|
}
|
|
@@ -153,11 +157,13 @@ export default class extends Controller {
|
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
handleDragOver(event) {
|
|
156
|
-
//
|
|
157
|
-
|
|
160
|
+
// Accept comment drops or agent drops
|
|
161
|
+
const isComment = event.dataTransfer.types.includes('application/x-comment-ids')
|
|
162
|
+
const isAgent = event.dataTransfer.types.includes('application/x-agent-drop')
|
|
163
|
+
if (!isComment && !isAgent) return
|
|
158
164
|
|
|
159
165
|
event.preventDefault()
|
|
160
|
-
event.dataTransfer.dropEffect = 'move'
|
|
166
|
+
event.dataTransfer.dropEffect = isAgent ? 'copy' : 'move'
|
|
161
167
|
event.currentTarget.classList.add('drag-over')
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -169,6 +175,18 @@ export default class extends Controller {
|
|
|
169
175
|
event.preventDefault()
|
|
170
176
|
event.currentTarget.classList.remove('drag-over')
|
|
171
177
|
|
|
178
|
+
// Handle agent drop
|
|
179
|
+
const agentJson = event.dataTransfer.getData('application/x-agent-drop')
|
|
180
|
+
if (agentJson) {
|
|
181
|
+
const agent = JSON.parse(agentJson)
|
|
182
|
+
const targetTopicId = event.currentTarget.dataset.id
|
|
183
|
+
if (targetTopicId) {
|
|
184
|
+
await this.setTopicPrimaryAgent(targetTopicId, agent)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Handle comment drop
|
|
172
190
|
const commentIdsJson = event.dataTransfer.getData('application/x-comment-ids')
|
|
173
191
|
if (!commentIdsJson) return
|
|
174
192
|
|
|
@@ -710,7 +728,14 @@ export default class extends Controller {
|
|
|
710
728
|
|
|
711
729
|
this.topics = [...topics, data.topic]
|
|
712
730
|
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
713
|
-
|
|
731
|
+
|
|
732
|
+
// Auto-select the new topic if created by the current user
|
|
733
|
+
const currentUserId = document.body.dataset.currentUserId
|
|
734
|
+
if (data.user_id && currentUserId && String(data.user_id) === String(currentUserId)) {
|
|
735
|
+
this.selectTopic(String(data.topic.id))
|
|
736
|
+
} else {
|
|
737
|
+
this.restoreSelection()
|
|
738
|
+
}
|
|
714
739
|
}
|
|
715
740
|
|
|
716
741
|
reorderTopicsFromServer(topicIds) {
|
|
@@ -760,4 +785,78 @@ export default class extends Controller {
|
|
|
760
785
|
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
761
786
|
this.restoreSelection()
|
|
762
787
|
}
|
|
788
|
+
|
|
789
|
+
handleAddButtonDragOver(event) {
|
|
790
|
+
if (!event.dataTransfer.types.includes('application/x-agent-drop')) return
|
|
791
|
+
event.preventDefault()
|
|
792
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
793
|
+
event.currentTarget.classList.add('drag-over')
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async handleAddButtonDrop(event) {
|
|
797
|
+
event.preventDefault()
|
|
798
|
+
event.currentTarget.classList.remove('drag-over')
|
|
799
|
+
|
|
800
|
+
const agentJson = event.dataTransfer.getData('application/x-agent-drop')
|
|
801
|
+
if (!agentJson) return
|
|
802
|
+
|
|
803
|
+
const agent = JSON.parse(agentJson)
|
|
804
|
+
await this.createTopicWithAgent(agent)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async setTopicPrimaryAgent(topicId, agent) {
|
|
808
|
+
if (!this.creativeId) return
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}/set_primary_agent`, {
|
|
812
|
+
method: 'PATCH',
|
|
813
|
+
headers: {
|
|
814
|
+
'Content-Type': 'application/json',
|
|
815
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
816
|
+
},
|
|
817
|
+
body: JSON.stringify({ agent_id: agent.id })
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
if (!response.ok) {
|
|
821
|
+
const data = await response.json()
|
|
822
|
+
console.error('Failed to set primary agent:', data.error)
|
|
823
|
+
}
|
|
824
|
+
// Topic update comes via WebSocket broadcast
|
|
825
|
+
} catch (e) {
|
|
826
|
+
console.error('Error setting primary agent', e)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async createTopicWithAgent(agent) {
|
|
831
|
+
if (!this.creativeId) return
|
|
832
|
+
|
|
833
|
+
const topicName = `Talk to ${agent.name}`
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
const response = await fetch(`/creatives/${this.creativeId}/topics`, {
|
|
837
|
+
method: 'POST',
|
|
838
|
+
headers: {
|
|
839
|
+
'Content-Type': 'application/json',
|
|
840
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
841
|
+
},
|
|
842
|
+
body: JSON.stringify({ topic: { name: topicName }, agent_id: agent.id })
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
if (response.ok) {
|
|
846
|
+
const topic = await response.json()
|
|
847
|
+
this.currentTopicId = topic.id
|
|
848
|
+
await this.loadTopics()
|
|
849
|
+
this.dispatch("change", { detail: { topicId: topic.id } })
|
|
850
|
+
} else {
|
|
851
|
+
console.error('Failed to create topic with agent')
|
|
852
|
+
}
|
|
853
|
+
} catch (e) {
|
|
854
|
+
console.error('Error creating topic with agent', e)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
escapeAttr(str) {
|
|
859
|
+
if (!str) return ''
|
|
860
|
+
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>')
|
|
861
|
+
}
|
|
763
862
|
}
|
|
@@ -100,6 +100,7 @@ export default class extends Controller {
|
|
|
100
100
|
const childrenDiv = this.childrenContainerFor(row)
|
|
101
101
|
this.ensureLoaded(row, childrenDiv).then((hasChildren) => {
|
|
102
102
|
if (!hasChildren || !childrenDiv) {
|
|
103
|
+
row.hasChildren = false
|
|
103
104
|
this.collapseRow(row, { persist: false })
|
|
104
105
|
return
|
|
105
106
|
}
|
|
@@ -177,6 +178,13 @@ export default class extends Controller {
|
|
|
177
178
|
|
|
178
179
|
syncInitialState(row) {
|
|
179
180
|
const childrenDiv = this.childrenContainerFor(row)
|
|
181
|
+
|
|
182
|
+
// If the row claims it has children but no children container exists,
|
|
183
|
+
// correct the hasChildren flag so the chevron is hidden.
|
|
184
|
+
if (row.hasChildren && !childrenDiv) {
|
|
185
|
+
row.hasChildren = false
|
|
186
|
+
}
|
|
187
|
+
|
|
180
188
|
const shouldExpand =
|
|
181
189
|
this.allExpanded ||
|
|
182
190
|
row.expanded ||
|
|
@@ -97,6 +97,10 @@ export default class extends Controller {
|
|
|
97
97
|
)
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
+
document.dispatchEvent(new CustomEvent('creative-destroyed', {
|
|
101
|
+
detail: { creativeIds: ids.map(String) }
|
|
102
|
+
}))
|
|
103
|
+
|
|
100
104
|
ids.forEach((id) => {
|
|
101
105
|
const tree = document.getElementById(`creative-${id}`)
|
|
102
106
|
if (tree) tree.remove()
|
|
@@ -30,13 +30,13 @@ import { initIndicator, showLinkHover, hideLinkHover } from './indicator';
|
|
|
30
30
|
const childZoneRatio = 0.3;
|
|
31
31
|
const coordPrecision = 5;
|
|
32
32
|
|
|
33
|
-
const TRANSFER_MIME_TYPE = 'application/x-
|
|
34
|
-
const DRAG_TOKEN_STORAGE_KEY = '
|
|
35
|
-
const DROP_SIGNAL_STORAGE_KEY = '
|
|
36
|
-
const WINDOW_ID_SESSION_KEY = '
|
|
33
|
+
const TRANSFER_MIME_TYPE = 'application/x-collavre-creative';
|
|
34
|
+
const DRAG_TOKEN_STORAGE_KEY = 'collavre.dragToken';
|
|
35
|
+
const DROP_SIGNAL_STORAGE_KEY = 'collavre.dragDropSignal';
|
|
36
|
+
const WINDOW_ID_SESSION_KEY = 'collavre.dragWindowId';
|
|
37
37
|
const INVALID_DROP_MESSAGE =
|
|
38
38
|
'We could not verify that drop. Please refresh the page and try again.';
|
|
39
|
-
const DROP_COMPLETED_EVENT = '
|
|
39
|
+
const DROP_COMPLETED_EVENT = 'collavre:creative-drop-complete';
|
|
40
40
|
|
|
41
41
|
let cachedDragToken;
|
|
42
42
|
let cachedWindowId;
|
|
@@ -1510,6 +1510,19 @@ export function initializeCreativeRowEditor() {
|
|
|
1510
1510
|
}
|
|
1511
1511
|
|
|
1512
1512
|
creativesApi.destroy(id, withChildren).then(() => {
|
|
1513
|
+
const destroyedIds = [String(id)];
|
|
1514
|
+
if (withChildren) {
|
|
1515
|
+
const childrenContainer = document.getElementById("creative-children-" + id);
|
|
1516
|
+
if (childrenContainer) {
|
|
1517
|
+
childrenContainer.querySelectorAll('creative-tree-row').forEach(row => {
|
|
1518
|
+
const cid = row.getAttribute('creative-id');
|
|
1519
|
+
if (cid) destroyedIds.push(cid);
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
document.dispatchEvent(new CustomEvent('creative-destroyed', {
|
|
1524
|
+
detail: { creativeIds: destroyedIds }
|
|
1525
|
+
}));
|
|
1513
1526
|
const parentTree = parentId ? document.getElementById(`creative-${parentId}`) : null;
|
|
1514
1527
|
const childrenTree = document.getElementById("creative-children-" + id)
|
|
1515
1528
|
if (!withChildren && childrenTree && parentTree) {
|