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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 656a876df08e2034398807dd976a0da67f7b4e5120dd73301ea521dd1291eb8b
4
- data.tar.gz: c17a5821354531dfaaafd6dd51794114a132c42a3535d612410a838d75499050
3
+ metadata.gz: 6e3281424919fc07e271f712389040c20bef756de2f467de4b5c22d41caf7431
4
+ data.tar.gz: aafc13da62eef7e36d0cc80014553fd1299260b5e8c8b5aa97e1238f9d6241a3
5
5
  SHA512:
6
- metadata.gz: 9547afa6f502aa818ef3a565d3e8991f25298623dffd980a2d63f3383debd2eef08ce0440285d5fe7c1f8dc7810245cb1aea8f252bf44d8cdd1400460b280fa1
7
- data.tar.gz: 336344900a57853600abb3d8f879741d52f8673238a63b90b5054fe5d5f8b412fb54aced24dd9c93ea07a1c66175fe5518a58bd6011f9aae363a0a82720e77ef
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
- if topic.save
31
- agent = nil
32
- if params[:agent_id].present?
33
- agent = User.find_by(id: params[:agent_id])
34
- topic.set_primary_agent!(agent) if agent&.ai_user?
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
- broadcast_data = agent ? topic_json_with_agent(topic, agent) : topic.slice(:id, :name)
38
- TopicsChannel.broadcast_to(
39
- @creative,
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 = this.handleExternalDrop.bind(this)
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.textareaTarget.addEventListener('dragover', this.handleDragOver)
61
- this.textareaTarget.addEventListener('drop', this.handleDrop)
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.textareaTarget.removeEventListener('dragover', this.handleDragOver)
116
- this.textareaTarget.removeEventListener('drop', this.handleDrop)
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
- if (this.hasImageFromDataTransfer(event.dataTransfer)) {
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
- const commentIds = Array.from(this.selection)
653
- this.handleMoveToTopic({ detail: { commentIds, targetTopicId: topic.id } })
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">&times;</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
- if (!event.dataTransfer.types.includes('application/x-agent-drop')) return
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
- this.setItems(this._allTopics)
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 response = await fetch(`/creatives/${creativeId}/topics`, {
68
- headers: { Accept: 'application/json' }
69
- })
70
- const data = await response.json()
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
- this.setItems(this._allTopics)
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
- return text
157
+ const escaped = text
94
158
  .replace(/&/g, "&amp;")
95
159
  .replace(/</g, "&lt;")
96
160
  .replace(/>/g, "&gt;")
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, :broadcast_badges
7
+ after_create_commit :broadcast_create
8
8
  after_update_commit :broadcast_update
9
- after_destroy_commit :broadcast_destroy, :broadcast_badges
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
@@ -54,6 +54,7 @@ Collavre::Engine.routes.draw do
54
54
  resources :topics, only: [ :index, :create, :update, :destroy ] do
55
55
  collection do
56
56
  post :reorder
57
+ get :next_name
57
58
  end
58
59
  member do
59
60
  patch :move
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.9.0"
2
+ VERSION = "0.10.0"
3
3
  end
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.9.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