collavre 0.9.0 → 0.10.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/comments_popup.css +15 -1
- data/app/controllers/collavre/topics_controller.rb +48 -13
- data/app/javascript/controllers/comments/contexts_controller.js +7 -1
- data/app/javascript/controllers/comments/form_controller.js +78 -5
- data/app/javascript/controllers/comments/list_controller.js +12 -3
- data/app/javascript/controllers/comments/topics_controller.js +38 -2
- data/app/javascript/controllers/topic_search_controller.js +80 -10
- data/app/javascript/lib/api/topics.js +64 -0
- data/app/models/collavre/comment/broadcastable.rb +7 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
- data/config/locales/comments.en.yml +2 -0
- data/config/locales/comments.ko.yml +2 -0
- data/config/routes.rb +1 -0
- data/lib/collavre/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e3281424919fc07e271f712389040c20bef756de2f467de4b5c22d41caf7431
|
|
4
|
+
data.tar.gz: aafc13da62eef7e36d0cc80014553fd1299260b5e8c8b5aa97e1238f9d6241a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09cbd1f6b17e92ec90b3e2d7f8da598ff1db2dbbaf01282bfd53df19bb8620afcd0e3ebd8dac2c1929233f816d000f8b573990df7dea62ebcb098bd414b0af9f'
|
|
7
|
+
data.tar.gz: 56bf15d2aca40fc28ba43cad261a91b2ae64e9c5bdf873c490a14f4f0fad4608d499906b295bfca416d4026406334f79fc422f989f81de4549f367ad88eade8d
|
|
@@ -159,10 +159,15 @@ body.chat-fullscreen {
|
|
|
159
159
|
overflow-y: auto;
|
|
160
160
|
min-height: calc(var(--text-1) * 1.5 * 2); /* 2 lines minimum */
|
|
161
161
|
max-height: calc(var(--text-1) * 1.5 * 10); /* 10 lines maximum */
|
|
162
|
-
transition: height 0.1s ease-out;
|
|
162
|
+
transition: height 0.1s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out;
|
|
163
163
|
/* Prevent iOS zoom on focus */
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
#new-comment-form.creative-drop-hover textarea {
|
|
167
|
+
border-color: var(--link);
|
|
168
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--link) 25%, transparent);
|
|
169
|
+
}
|
|
170
|
+
|
|
166
171
|
#new-comment-form button[type="submit"] {
|
|
167
172
|
float: right;
|
|
168
173
|
}
|
|
@@ -1539,3 +1544,12 @@ body.chat-fullscreen {
|
|
|
1539
1544
|
0%, 70% { opacity: 1; }
|
|
1540
1545
|
100% { opacity: 0; }
|
|
1541
1546
|
}
|
|
1547
|
+
|
|
1548
|
+
.topic-create-option {
|
|
1549
|
+
color: var(--link);
|
|
1550
|
+
font-style: italic;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
li:hover .topic-create-option {
|
|
1554
|
+
text-decoration: underline;
|
|
1555
|
+
}
|
|
@@ -27,22 +27,43 @@ module Collavre
|
|
|
27
27
|
topic = @creative.topics.build(topic_params)
|
|
28
28
|
topic.user = Current.user
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
Topic.transaction do
|
|
31
|
+
if topic.save
|
|
32
|
+
agent = nil
|
|
33
|
+
if params[:agent_id].present?
|
|
34
|
+
agent = User.find_by(id: params[:agent_id])
|
|
35
|
+
topic.set_primary_agent!(agent) if agent&.ai_user?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Move comments to the new topic if comment_ids provided
|
|
39
|
+
comment_ids = Array(params[:comment_ids]).map(&:presence).compact
|
|
40
|
+
if comment_ids.any?
|
|
41
|
+
CommentMoveService.new(creative: @creative, user: Current.user).call(
|
|
42
|
+
comment_ids: comment_ids,
|
|
43
|
+
target_topic_id: topic.id
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
broadcast_data = agent ? topic_json_with_agent(topic, agent) : topic.slice(:id, :name)
|
|
48
|
+
TopicsChannel.broadcast_to(
|
|
49
|
+
@creative,
|
|
50
|
+
{ action: "created", topic: broadcast_data, user_id: Current.user.id }
|
|
51
|
+
)
|
|
52
|
+
render json: topic, status: :created
|
|
53
|
+
else
|
|
54
|
+
render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
|
|
35
55
|
end
|
|
56
|
+
end
|
|
57
|
+
rescue CommentMoveService::MoveError => e
|
|
58
|
+
render json: { errors: [ e.message ] }, status: :unprocessable_entity
|
|
59
|
+
end
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{ action: "created", topic: broadcast_data, user_id: Current.user.id }
|
|
41
|
-
)
|
|
42
|
-
render json: topic, status: :created
|
|
43
|
-
else
|
|
44
|
-
render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
|
|
61
|
+
def next_name
|
|
62
|
+
unless @creative.has_permission?(Current.user, :read) || @creative.user == Current.user
|
|
63
|
+
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
45
64
|
end
|
|
65
|
+
|
|
66
|
+
render json: { name: generate_next_topic_name }
|
|
46
67
|
end
|
|
47
68
|
|
|
48
69
|
def update
|
|
@@ -202,6 +223,20 @@ module Collavre
|
|
|
202
223
|
params.require(:topic).permit(:name)
|
|
203
224
|
end
|
|
204
225
|
|
|
226
|
+
def generate_next_topic_name
|
|
227
|
+
prefix = I18n.t("collavre.topics.default_name_prefix")
|
|
228
|
+
existing_numbers = @creative.topics.active
|
|
229
|
+
.where("name LIKE ?", "#{Topic.sanitize_sql_like(prefix)}%")
|
|
230
|
+
.pluck(:name)
|
|
231
|
+
.filter_map { |n|
|
|
232
|
+
suffix = n.delete_prefix(prefix)
|
|
233
|
+
suffix.match?(/\A\d+\z/) ? suffix.to_i : nil
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
next_number = (existing_numbers.max || 0) + 1
|
|
237
|
+
"#{prefix}#{next_number}"
|
|
238
|
+
end
|
|
239
|
+
|
|
205
240
|
# Batch-load primary agents for all topics to avoid N+1 queries
|
|
206
241
|
def preload_primary_agents(topics)
|
|
207
242
|
topic_ids = topics.map(&:id)
|
|
@@ -377,7 +377,11 @@ export default class extends Controller {
|
|
|
377
377
|
this._popupEl = popup
|
|
378
378
|
this._boundPopupDragOver = this._handlePopupDragOver.bind(this)
|
|
379
379
|
this._boundPopupDragLeave = this._handlePopupDragLeave.bind(this)
|
|
380
|
-
this._boundPopupDrop =
|
|
380
|
+
this._boundPopupDrop = (event) => {
|
|
381
|
+
// Skip if dropping on the comment form — let form_controller handle it
|
|
382
|
+
if (event.target.closest('#new-comment-form')) return
|
|
383
|
+
this.handleExternalDrop(event)
|
|
384
|
+
}
|
|
381
385
|
popup.addEventListener('dragover', this._boundPopupDragOver)
|
|
382
386
|
popup.addEventListener('dragleave', this._boundPopupDragLeave)
|
|
383
387
|
popup.addEventListener('drop', this._boundPopupDrop)
|
|
@@ -395,6 +399,8 @@ export default class extends Controller {
|
|
|
395
399
|
if (this._isInternalReorder(event)) return
|
|
396
400
|
if (!this._isCreativeDrag(event)) return
|
|
397
401
|
if (!this.canManage) return
|
|
402
|
+
// Skip if dragging over the comment form — let form_controller handle it
|
|
403
|
+
if (event.target.closest('#new-comment-form')) return
|
|
398
404
|
|
|
399
405
|
// Must preventDefault to allow drop on the popup
|
|
400
406
|
event.preventDefault()
|
|
@@ -45,6 +45,7 @@ export default class extends Controller {
|
|
|
45
45
|
this.handleImageButtonClick = this.handleImageButtonClick.bind(this)
|
|
46
46
|
this.handleImageChange = this.handleImageChange.bind(this)
|
|
47
47
|
this.handleDragOver = this.handleDragOver.bind(this)
|
|
48
|
+
this.handleDragLeave = this.handleDragLeave.bind(this)
|
|
48
49
|
this.handleDrop = this.handleDrop.bind(this)
|
|
49
50
|
|
|
50
51
|
this.formTarget.addEventListener('submit', this.handleSubmit)
|
|
@@ -57,8 +58,9 @@ export default class extends Controller {
|
|
|
57
58
|
|
|
58
59
|
this.imageButtonTarget?.addEventListener('click', this.handleImageButtonClick)
|
|
59
60
|
this.imageInputTarget?.addEventListener('change', this.handleImageChange)
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
61
|
+
this.formTarget.addEventListener('dragover', this.handleDragOver)
|
|
62
|
+
this.formTarget.addEventListener('dragleave', this.handleDragLeave)
|
|
63
|
+
this.formTarget.addEventListener('drop', this.handleDrop)
|
|
62
64
|
this.handlePaste = this.handlePaste.bind(this)
|
|
63
65
|
this.textareaTarget.addEventListener('paste', this.handlePaste)
|
|
64
66
|
|
|
@@ -112,8 +114,9 @@ export default class extends Controller {
|
|
|
112
114
|
this.imageButtonTarget?.removeEventListener('click', this.handleImageButtonClick)
|
|
113
115
|
this.imageInputTarget?.removeEventListener('change', this.handleImageChange)
|
|
114
116
|
this.textareaTarget.removeEventListener('input', this._autoResize)
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
117
|
+
this.formTarget.removeEventListener('dragover', this.handleDragOver)
|
|
118
|
+
this.formTarget.removeEventListener('dragleave', this.handleDragLeave)
|
|
119
|
+
this.formTarget.removeEventListener('drop', this.handleDrop)
|
|
117
120
|
this.textareaTarget.removeEventListener('paste', this.handlePaste)
|
|
118
121
|
this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
|
|
119
122
|
}
|
|
@@ -510,12 +513,39 @@ export default class extends Controller {
|
|
|
510
513
|
}
|
|
511
514
|
|
|
512
515
|
handleDragOver(event) {
|
|
513
|
-
|
|
516
|
+
const isCreative = this.hasCreativeFromDataTransfer(event.dataTransfer)
|
|
517
|
+
const isImage = this.hasImageFromDataTransfer(event.dataTransfer)
|
|
518
|
+
if (isImage || isCreative) {
|
|
514
519
|
event.preventDefault()
|
|
520
|
+
event.stopPropagation()
|
|
521
|
+
if (isCreative) {
|
|
522
|
+
this.formTarget.classList.add('creative-drop-hover')
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
handleDragLeave(event) {
|
|
528
|
+
// Only remove highlight if truly leaving the form
|
|
529
|
+
if (!this.formTarget.contains(event.relatedTarget)) {
|
|
530
|
+
this.formTarget.classList.remove('creative-drop-hover')
|
|
515
531
|
}
|
|
516
532
|
}
|
|
517
533
|
|
|
518
534
|
handleDrop(event) {
|
|
535
|
+
this.formTarget.classList.remove('creative-drop-hover')
|
|
536
|
+
|
|
537
|
+
// Handle creative drop — stop propagation so contexts_controller doesn't intercept
|
|
538
|
+
if (this.hasCreativeFromDataTransfer(event.dataTransfer)) {
|
|
539
|
+
event.preventDefault()
|
|
540
|
+
event.stopPropagation()
|
|
541
|
+
const creativeData = this.extractCreativeData(event.dataTransfer)
|
|
542
|
+
if (creativeData) {
|
|
543
|
+
this.insertCreativeLink(creativeData)
|
|
544
|
+
}
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Handle image drop
|
|
519
549
|
const imageFiles = this.extractImageFiles(event.dataTransfer)
|
|
520
550
|
if (!imageFiles.length) return
|
|
521
551
|
event.preventDefault()
|
|
@@ -523,6 +553,49 @@ export default class extends Controller {
|
|
|
523
553
|
this.updateAttachmentList()
|
|
524
554
|
}
|
|
525
555
|
|
|
556
|
+
hasCreativeFromDataTransfer(dataTransfer) {
|
|
557
|
+
if (!dataTransfer || !dataTransfer.types) return false
|
|
558
|
+
return Array.from(dataTransfer.types).includes('application/x-collavre-creative')
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
extractCreativeData(dataTransfer) {
|
|
562
|
+
if (!dataTransfer) return null
|
|
563
|
+
const raw = dataTransfer.getData('application/x-collavre-creative') || dataTransfer.getData('text/plain')
|
|
564
|
+
if (!raw) return null
|
|
565
|
+
try {
|
|
566
|
+
const parsed = JSON.parse(raw)
|
|
567
|
+
if (!parsed || !parsed.creativeId) return null
|
|
568
|
+
const label = this.getCreativeLabelFromDom(parsed.creativeId)
|
|
569
|
+
return { id: parsed.creativeId, label: label || `Creative #${parsed.creativeId}` }
|
|
570
|
+
} catch {
|
|
571
|
+
return null
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
getCreativeLabelFromDom(creativeId) {
|
|
576
|
+
const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`)
|
|
577
|
+
if (!row) return null
|
|
578
|
+
const descriptionHtml = row.descriptionHtml || row.dataset?.descriptionHtml || ''
|
|
579
|
+
if (!descriptionHtml) return null
|
|
580
|
+
const tmp = document.createElement('div')
|
|
581
|
+
tmp.innerHTML = descriptionHtml
|
|
582
|
+
return (tmp.textContent || tmp.innerText || '').trim()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
insertCreativeLink({ id, label }) {
|
|
586
|
+
const link = `[${label}](/creatives/${id})`
|
|
587
|
+
const textarea = this.textareaTarget
|
|
588
|
+
const pos = textarea.selectionStart
|
|
589
|
+
const before = textarea.value.substring(0, pos)
|
|
590
|
+
const after = textarea.value.substring(pos)
|
|
591
|
+
const needsSpace = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n')
|
|
592
|
+
textarea.value = `${before}${needsSpace ? ' ' : ''}${link}${after ? '' : ' '}${after}`
|
|
593
|
+
const newPos = pos + (needsSpace ? 1 : 0) + link.length + (after ? 0 : 1)
|
|
594
|
+
textarea.setSelectionRange(newPos, newPos)
|
|
595
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
596
|
+
textarea.focus()
|
|
597
|
+
}
|
|
598
|
+
|
|
526
599
|
extractImageFiles(dataTransfer) {
|
|
527
600
|
if (!dataTransfer) return []
|
|
528
601
|
const files = Array.from(dataTransfer.files || []).filter((file) => file.type?.startsWith('image/'))
|
|
@@ -644,15 +644,23 @@ export default class extends Controller {
|
|
|
644
644
|
openTopicSearchPopup(event) {
|
|
645
645
|
if (this.selection.size === 0) return
|
|
646
646
|
|
|
647
|
+
const commentIds = Array.from(this.selection)
|
|
648
|
+
|
|
647
649
|
const openWithController = (controller, btnRect) => {
|
|
648
650
|
controller.openForCreative(
|
|
649
651
|
this.creativeId,
|
|
650
652
|
btnRect,
|
|
651
653
|
(topic) => {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
+
if (topic.created) {
|
|
655
|
+
// Topic was just created with comments moved — refresh
|
|
656
|
+
this.clearSelection()
|
|
657
|
+
this.loadInitialComments()
|
|
658
|
+
} else {
|
|
659
|
+
this.handleMoveToTopic({ detail: { commentIds, targetTopicId: topic.id } })
|
|
660
|
+
}
|
|
654
661
|
},
|
|
655
|
-
this.element.dataset.topicMainText || 'Main'
|
|
662
|
+
this.element.dataset.topicMainText || 'Main',
|
|
663
|
+
commentIds
|
|
656
664
|
)
|
|
657
665
|
}
|
|
658
666
|
|
|
@@ -674,6 +682,7 @@ export default class extends Controller {
|
|
|
674
682
|
modal.className = 'common-popup'
|
|
675
683
|
modal.style.display = 'none'
|
|
676
684
|
modal.dataset.controller = 'topic-search'
|
|
685
|
+
modal.dataset.createAndMoveText = this.element.dataset.topicCreateAndMoveText || 'Create "%{name}" and move'
|
|
677
686
|
modal.innerHTML = `
|
|
678
687
|
<button type="button" class="popup-close-btn" data-topic-search-target="close">×</button>
|
|
679
688
|
<input type="text" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { createSubscription } from "../../services/cable"
|
|
3
|
+
import { fetchNextTopicName, createTopicWithComments } from "../../lib/api/topics"
|
|
3
4
|
|
|
4
5
|
const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
|
|
5
6
|
const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
|
|
@@ -787,9 +788,11 @@ export default class extends Controller {
|
|
|
787
788
|
}
|
|
788
789
|
|
|
789
790
|
handleAddButtonDragOver(event) {
|
|
790
|
-
|
|
791
|
+
const isAgent = event.dataTransfer.types.includes('application/x-agent-drop')
|
|
792
|
+
const isComment = event.dataTransfer.types.includes('application/x-comment-ids')
|
|
793
|
+
if (!isAgent && !isComment) return
|
|
791
794
|
event.preventDefault()
|
|
792
|
-
event.dataTransfer.dropEffect = 'copy'
|
|
795
|
+
event.dataTransfer.dropEffect = isAgent ? 'copy' : 'move'
|
|
793
796
|
event.currentTarget.classList.add('drag-over')
|
|
794
797
|
}
|
|
795
798
|
|
|
@@ -797,6 +800,17 @@ export default class extends Controller {
|
|
|
797
800
|
event.preventDefault()
|
|
798
801
|
event.currentTarget.classList.remove('drag-over')
|
|
799
802
|
|
|
803
|
+
// Handle comment drop → create new topic + move
|
|
804
|
+
const commentIdsJson = event.dataTransfer.getData('application/x-comment-ids')
|
|
805
|
+
if (commentIdsJson) {
|
|
806
|
+
const commentIds = JSON.parse(commentIdsJson)
|
|
807
|
+
if (commentIds && commentIds.length > 0) {
|
|
808
|
+
await this.createTopicAndMoveComments(commentIds)
|
|
809
|
+
}
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Handle agent drop (existing logic)
|
|
800
814
|
const agentJson = event.dataTransfer.getData('application/x-agent-drop')
|
|
801
815
|
if (!agentJson) return
|
|
802
816
|
|
|
@@ -804,6 +818,28 @@ export default class extends Controller {
|
|
|
804
818
|
await this.createTopicWithAgent(agent)
|
|
805
819
|
}
|
|
806
820
|
|
|
821
|
+
async createTopicAndMoveComments(commentIds, topicName = null) {
|
|
822
|
+
if (!this.creativeId) return
|
|
823
|
+
|
|
824
|
+
const name = topicName || await fetchNextTopicName(this.creativeId)
|
|
825
|
+
if (!name) return
|
|
826
|
+
|
|
827
|
+
const result = await createTopicWithComments(this.creativeId, name, commentIds)
|
|
828
|
+
if (result.ok) {
|
|
829
|
+
this.currentTopicId = result.topic.id
|
|
830
|
+
await this.loadTopics()
|
|
831
|
+
this.dispatch("change", { detail: { topicId: result.topic.id } })
|
|
832
|
+
|
|
833
|
+
// Clear selection in list controller
|
|
834
|
+
const listController = this.application.getControllerForElementAndIdentifier(
|
|
835
|
+
this.element, 'comments--list'
|
|
836
|
+
)
|
|
837
|
+
if (listController) listController.clearSelection()
|
|
838
|
+
} else {
|
|
839
|
+
alert(result.error)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
807
843
|
async setTopicPrimaryAgent(topicId, agent) {
|
|
808
844
|
if (!this.creativeId) return
|
|
809
845
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import CommonPopupController from './common_popup_controller'
|
|
2
|
+
import { fetchNextTopicName, createTopicWithComments } from '../lib/api/topics'
|
|
2
3
|
|
|
3
4
|
export default class extends CommonPopupController {
|
|
4
5
|
static targets = ['input', 'list', 'close']
|
|
@@ -7,6 +8,9 @@ export default class extends CommonPopupController {
|
|
|
7
8
|
super.connect()
|
|
8
9
|
this._debounceTimer = null
|
|
9
10
|
this._allTopics = []
|
|
11
|
+
this._defaultCreateItem = null
|
|
12
|
+
this._creativeId = null
|
|
13
|
+
this._commentIds = null
|
|
10
14
|
this.inputTarget.addEventListener('input', this._onInput.bind(this))
|
|
11
15
|
this.inputTarget.addEventListener('keydown', this.handleInputKeydown.bind(this))
|
|
12
16
|
this.closeTarget.addEventListener('click', () => this.close())
|
|
@@ -20,9 +24,12 @@ export default class extends CommonPopupController {
|
|
|
20
24
|
super.disconnect()
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
openForCreative(creativeId, anchorRect, onSelectCallback, mainLabel = 'Main') {
|
|
27
|
+
openForCreative(creativeId, anchorRect, onSelectCallback, mainLabel = 'Main', commentIds = null) {
|
|
24
28
|
this.onSelectCallback = onSelectCallback
|
|
25
29
|
this._allTopics = []
|
|
30
|
+
this._defaultCreateItem = null
|
|
31
|
+
this._creativeId = creativeId
|
|
32
|
+
this._commentIds = commentIds
|
|
26
33
|
this.setItems([])
|
|
27
34
|
this.inputTarget.value = ''
|
|
28
35
|
super.open(anchorRect)
|
|
@@ -50,54 +57,117 @@ export default class extends CommonPopupController {
|
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
_onInput() {
|
|
53
|
-
const q = this.inputTarget.value.toLowerCase()
|
|
60
|
+
const q = this.inputTarget.value.toLowerCase().trim()
|
|
54
61
|
if (!q) {
|
|
55
|
-
|
|
62
|
+
// No query: show all topics + default create option
|
|
63
|
+
const items = [...this._allTopics]
|
|
64
|
+
if (this._defaultCreateItem) {
|
|
65
|
+
items.push(this._defaultCreateItem)
|
|
66
|
+
}
|
|
67
|
+
this.setItems(items)
|
|
56
68
|
return
|
|
57
69
|
}
|
|
58
70
|
const filtered = this._allTopics.filter(t =>
|
|
59
71
|
(t.label || '').toLowerCase().includes(q)
|
|
60
72
|
)
|
|
73
|
+
// Always show "create and move" option with the query as name
|
|
74
|
+
// (even when there are partial matches, user may want to create a new one)
|
|
75
|
+
const exactMatch = this._allTopics.some(t =>
|
|
76
|
+
(t.label || '').toLowerCase() === `#${q}`
|
|
77
|
+
)
|
|
78
|
+
if (!exactMatch) {
|
|
79
|
+
const createLabel = this.element.dataset.createAndMoveText || 'Create "%{name}" and move'
|
|
80
|
+
filtered.push({
|
|
81
|
+
id: '__create__',
|
|
82
|
+
label: `+ ${createLabel.replace('%{name}', this.inputTarget.value.trim())}`,
|
|
83
|
+
createName: this.inputTarget.value.trim(),
|
|
84
|
+
isCreate: true
|
|
85
|
+
})
|
|
86
|
+
}
|
|
61
87
|
this.setItems(filtered)
|
|
62
88
|
}
|
|
63
89
|
|
|
64
90
|
async _loadTopics(creativeId, mainLabel) {
|
|
65
91
|
if (!creativeId) return
|
|
66
92
|
try {
|
|
67
|
-
const
|
|
68
|
-
headers: { Accept: 'application/json' }
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
const [topicsResponse, nextName] = await Promise.all([
|
|
94
|
+
fetch(`/creatives/${creativeId}/topics`, { headers: { Accept: 'application/json' } }),
|
|
95
|
+
fetchNextTopicName(creativeId)
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
const data = await topicsResponse.json()
|
|
71
99
|
const topics = data.topics || []
|
|
72
100
|
|
|
73
101
|
this._allTopics = [
|
|
74
102
|
{ id: '', label: `📋 ${mainLabel}` },
|
|
75
103
|
...topics.map(t => ({ id: t.id, label: `#${t.name}` }))
|
|
76
104
|
]
|
|
77
|
-
|
|
105
|
+
|
|
106
|
+
// Build default create item from next_name
|
|
107
|
+
if (nextName) {
|
|
108
|
+
const createLabel = this.element.dataset.createAndMoveText || 'Create "%{name}" and move'
|
|
109
|
+
this._defaultCreateItem = {
|
|
110
|
+
id: '__create_default__',
|
|
111
|
+
label: `+ ${createLabel.replace('%{name}', nextName)}`,
|
|
112
|
+
createName: nextName,
|
|
113
|
+
isCreate: true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const items = [...this._allTopics]
|
|
118
|
+
if (this._defaultCreateItem) {
|
|
119
|
+
items.push(this._defaultCreateItem)
|
|
120
|
+
}
|
|
121
|
+
this.setItems(items)
|
|
78
122
|
} catch (error) {
|
|
79
123
|
console.error('Error loading topics:', error)
|
|
80
124
|
this.setItems([])
|
|
81
125
|
}
|
|
82
126
|
}
|
|
83
127
|
|
|
84
|
-
select(item) {
|
|
128
|
+
async select(item) {
|
|
129
|
+
if (item.isCreate && this._commentIds && this._commentIds.length > 0) {
|
|
130
|
+
// Create new topic + move comments
|
|
131
|
+
await this._createTopicAndMove(item.createName, this._commentIds)
|
|
132
|
+
this.close()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
85
136
|
if (this.onSelectCallback) {
|
|
86
137
|
this.onSelectCallback(item)
|
|
87
138
|
}
|
|
88
139
|
this.close()
|
|
89
140
|
}
|
|
90
141
|
|
|
142
|
+
async _createTopicAndMove(name, commentIds) {
|
|
143
|
+
if (!this._creativeId) return
|
|
144
|
+
|
|
145
|
+
const result = await createTopicWithComments(this._creativeId, name, commentIds)
|
|
146
|
+
if (result.ok) {
|
|
147
|
+
if (this.onSelectCallback) {
|
|
148
|
+
this.onSelectCallback({ id: result.topic.id, label: `#${result.topic.name}`, created: true })
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
alert(result.error)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
91
155
|
renderItem(item) {
|
|
92
156
|
const text = item.label || ''
|
|
93
|
-
|
|
157
|
+
const escaped = text
|
|
94
158
|
.replace(/&/g, "&")
|
|
95
159
|
.replace(/</g, "<")
|
|
96
160
|
.replace(/>/g, ">")
|
|
161
|
+
if (item.isCreate) {
|
|
162
|
+
return `<span class="topic-create-option">${escaped}</span>`
|
|
163
|
+
}
|
|
164
|
+
return escaped
|
|
97
165
|
}
|
|
98
166
|
|
|
99
167
|
dispatchClose(reason) {
|
|
100
168
|
this.onSelectCallback = null
|
|
169
|
+
this._commentIds = null
|
|
170
|
+
this._creativeId = null
|
|
101
171
|
super.dispatchClose(reason)
|
|
102
172
|
}
|
|
103
173
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topics API helper — shared between topics_controller and topic_search_controller
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function csrfToken() {
|
|
6
|
+
return document.querySelector('meta[name="csrf-token"]')?.content
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch the next auto-generated topic name for a creative.
|
|
11
|
+
* @param {string|number} creativeId
|
|
12
|
+
* @returns {Promise<string|null>}
|
|
13
|
+
*/
|
|
14
|
+
export async function fetchNextTopicName(creativeId) {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`/creatives/${creativeId}/topics/next_name`, {
|
|
17
|
+
headers: { Accept: 'application/json' }
|
|
18
|
+
})
|
|
19
|
+
if (response.ok) {
|
|
20
|
+
const data = await response.json()
|
|
21
|
+
return data.name
|
|
22
|
+
}
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.error('Error fetching next topic name', e)
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a new topic and optionally move comments into it.
|
|
31
|
+
* @param {string|number} creativeId
|
|
32
|
+
* @param {string} name - topic name
|
|
33
|
+
* @param {string[]|number[]} commentIds - comments to move (optional)
|
|
34
|
+
* @returns {Promise<{ok: boolean, topic?: object, error?: string}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function createTopicWithComments(creativeId, name, commentIds = []) {
|
|
37
|
+
try {
|
|
38
|
+
const body = { topic: { name } }
|
|
39
|
+
if (commentIds.length > 0) {
|
|
40
|
+
body.comment_ids = commentIds
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const response = await fetch(`/creatives/${creativeId}/topics`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
'X-CSRF-Token': csrfToken()
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify(body)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (response.ok) {
|
|
53
|
+
const topic = await response.json()
|
|
54
|
+
return { ok: true, topic }
|
|
55
|
+
} else {
|
|
56
|
+
const data = await response.json().catch(() => ({}))
|
|
57
|
+
const error = (data.errors || [data.error]).filter(Boolean).join(', ') || 'Failed to create topic'
|
|
58
|
+
return { ok: false, error }
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error('Error creating topic with comments', e)
|
|
62
|
+
return { ok: false, error: e.message }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,9 +4,14 @@ module Collavre
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
-
after_create_commit :broadcast_create
|
|
7
|
+
after_create_commit :broadcast_create
|
|
8
8
|
after_update_commit :broadcast_update
|
|
9
|
-
after_destroy_commit :broadcast_destroy
|
|
9
|
+
after_destroy_commit :broadcast_destroy
|
|
10
|
+
# Use after_commit with on: to avoid Rails callback deduplication.
|
|
11
|
+
# Registering the same method via both after_create_commit and
|
|
12
|
+
# after_destroy_commit causes the later registration to silently
|
|
13
|
+
# overwrite the earlier one.
|
|
14
|
+
after_commit :broadcast_badges, on: [ :create, :destroy ]
|
|
10
15
|
end
|
|
11
16
|
|
|
12
17
|
module ClassMethods
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
data-batch-delete-confirm-text="<%= t('collavre.comments.batch_delete_confirm') %>"
|
|
42
42
|
data-topic-search-placeholder-text="<%= t('collavre.comments.topic_search_placeholder') %>"
|
|
43
43
|
data-topic-main-text="<%= t('collavre.comments.topic_main') %>"
|
|
44
|
+
data-topic-create-and-move-text="<%= t('collavre.topics.create_and_move', name: '%{name}') %>"
|
|
44
45
|
data-add-participant-text="<%= t('collavre.comments.add_participant') %>"
|
|
45
46
|
data-review-button-text="<%= t('collavre.comments.review_button') %>"
|
|
46
47
|
data-review-feedback-placeholder="<%= t('collavre.comments.review_feedback_placeholder') %>"
|
|
@@ -9,6 +9,8 @@ en:
|
|
|
9
9
|
unarchive: Restore
|
|
10
10
|
archived_topics: "Archived topics (%{count})"
|
|
11
11
|
not_ai_agent: Only AI agents can be set as primary agent.
|
|
12
|
+
default_name_prefix: "Topic"
|
|
13
|
+
create_and_move: 'Create "%{name}" and move'
|
|
12
14
|
move:
|
|
13
15
|
no_target_permission: You don't have write permission on the target creative.
|
|
14
16
|
duplicate_name: A topic named '%{name}' already exists in the target creative.
|
|
@@ -9,6 +9,8 @@ ko:
|
|
|
9
9
|
unarchive: 복원
|
|
10
10
|
archived_topics: "아카이브된 토픽 (%{count}개)"
|
|
11
11
|
not_ai_agent: AI 에이전트만 Primary Agent로 설정할 수 있습니다.
|
|
12
|
+
default_name_prefix: "토픽"
|
|
13
|
+
create_and_move: '"%{name}" 생성후 이동'
|
|
12
14
|
move:
|
|
13
15
|
no_target_permission: 대상 크리에이티브에 대한 쓰기 권한이 없습니다.
|
|
14
16
|
duplicate_name: "'%{name}' 토픽이 대상 크리에이티브에 이미 존재합니다."
|
data/config/routes.rb
CHANGED
data/lib/collavre/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -289,6 +289,7 @@ files:
|
|
|
289
289
|
- app/javascript/lib/api/csrf_fetch.js
|
|
290
290
|
- app/javascript/lib/api/drag_drop.js
|
|
291
291
|
- app/javascript/lib/api/queue_manager.js
|
|
292
|
+
- app/javascript/lib/api/topics.js
|
|
292
293
|
- app/javascript/lib/apply_lexical_styles.js
|
|
293
294
|
- app/javascript/lib/common_popup.js
|
|
294
295
|
- app/javascript/lib/html_code_block_wrapper.js
|