collavre 0.8.3 → 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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/activity_logs.css +1 -1
  3. data/app/assets/stylesheets/collavre/comment_versions.css +10 -6
  4. data/app/assets/stylesheets/collavre/comments_popup.css +56 -0
  5. data/app/assets/stylesheets/collavre/creatives.css +0 -4
  6. data/app/controllers/collavre/comments_controller.rb +2 -1
  7. data/app/controllers/collavre/topics_controller.rb +80 -3
  8. data/app/controllers/concerns/collavre/comments/approval_actions.rb +5 -0
  9. data/app/controllers/concerns/collavre/comments/batch_operations.rb +32 -0
  10. data/app/javascript/controllers/comment_version_controller.js +5 -1
  11. data/app/javascript/controllers/comments/contexts_controller.js +118 -3
  12. data/app/javascript/controllers/comments/list_controller.js +67 -1
  13. data/app/javascript/controllers/comments/popup_controller.js +124 -10
  14. data/app/javascript/controllers/comments/presence_controller.js +22 -0
  15. data/app/javascript/controllers/comments/topics_controller.js +105 -6
  16. data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
  17. data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
  18. data/app/javascript/modules/creative_row_editor.js +13 -0
  19. data/app/jobs/collavre/compress_job.rb +24 -25
  20. data/app/jobs/collavre/merge_comments_job.rb +79 -0
  21. data/app/models/collavre/topic.rb +29 -0
  22. data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
  23. data/app/services/collavre/comments/topic_command.rb +27 -13
  24. data/app/views/collavre/comments/_comments_popup.html.erb +5 -1
  25. data/config/locales/comments.en.yml +11 -1
  26. data/config/locales/comments.ko.yml +11 -1
  27. data/config/routes.rb +2 -0
  28. data/lib/collavre/version.rb +1 -1
  29. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82e806ce0c2a6e03029c43d817fb83a6c8114e04bf13be10b05b16fefbeb253c
4
- data.tar.gz: bfa7142c80cd7868c4ad9375a7b9f96ceed0b9b35ad031a6962f92a9d655f029
3
+ metadata.gz: 656a876df08e2034398807dd976a0da67f7b4e5120dd73301ea521dd1291eb8b
4
+ data.tar.gz: c17a5821354531dfaaafd6dd51794114a132c42a3535d612410a838d75499050
5
5
  SHA512:
6
- metadata.gz: e443416101117e0e3440f60d889f4c317fc19d59e5d3e92fdc511f1caf0fee52470e3e37b036709fbc63eea01d266c622e25ded80dc584d77c7fd0e2feed257b
7
- data.tar.gz: 33e264807609298b92f8786f755bce2ef7846e653d6d5b4c7ff0eb1c267cf0273a56753d8e3758cf99e8300d14f1f6cfbde99b21e9f99e39645e44d3a0ec58e6
6
+ metadata.gz: 9547afa6f502aa818ef3a565d3e8991f25298623dffd980a2d63f3383debd2eef08ce0440285d5fe7c1f8dc7810245cb1aea8f252bf44d8cdd1400460b280fa1
7
+ data.tar.gz: 336344900a57853600abb3d8f879741d52f8673238a63b90b5054fe5d5f8b412fb54aced24dd9c93ea07a1c66175fe5518a58bd6011f9aae363a0a82720e77ef
@@ -9,7 +9,7 @@
9
9
  }
10
10
 
11
11
  .comment-activity-log-block details>summary::marker {
12
- color: var(--border-color);
12
+ color: var(--text-muted);
13
13
  }
14
14
 
15
15
  /* List container */
@@ -14,7 +14,8 @@
14
14
  padding: 0 var(--space-2, 0.25rem);
15
15
  font-size: var(--text-xs, 0.75rem);
16
16
  line-height: 1.6;
17
- color: var(--text-color, #333);
17
+ color: var(--text-primary, #202123);
18
+ font-weight: bold;
18
19
  }
19
20
 
20
21
  .comment-version-btn:hover:not(:disabled) {
@@ -35,16 +36,18 @@
35
36
 
36
37
  .comment-version-delete-btn {
37
38
  background: none;
38
- border: none;
39
+ border: 1px solid var(--border-color, #ddd);
40
+ border-radius: var(--radius-1, 4px);
39
41
  cursor: pointer;
40
- color: var(--text-muted, #888);
42
+ color: var(--text-primary, #202123);
41
43
  font-size: var(--text-xs, 0.75rem);
42
- padding: 0 var(--space-1, 0.125rem);
44
+ padding: 0 var(--space-2, 0.25rem);
43
45
  margin-left: var(--space-1, 0.125rem);
46
+ line-height: 1.6;
44
47
  }
45
48
 
46
- .comment-version-delete-btn:hover {
47
- color: var(--danger-color, #e53e3e);
49
+ .comment-version-delete-btn:hover:not(:disabled) {
50
+ background: var(--surface-hover, #f0f0f0);
48
51
  }
49
52
 
50
53
  .comment-version-selected {
@@ -59,6 +62,7 @@
59
62
  cursor: pointer;
60
63
  color: var(--primary-color, #4a90d9);
61
64
  font-size: var(--text-xs, 0.75rem);
65
+ font-weight: bold;
62
66
  padding: 0 var(--space-2, 0.25rem);
63
67
  margin-left: var(--space-1, 0.125rem);
64
68
  line-height: 1.6;
@@ -984,6 +984,12 @@ body.chat-fullscreen {
984
984
  margin-right: -1px;
985
985
  }
986
986
 
987
+ .comment-contexts-list.context-drop-active {
988
+ outline: 2px dashed var(--color-link);
989
+ outline-offset: -2px;
990
+ background-color: color-mix(in srgb, var(--color-link) 8%, transparent);
991
+ }
992
+
987
993
  .context-chip[draggable="true"] {
988
994
  cursor: grab;
989
995
  }
@@ -1100,6 +1106,30 @@ body.chat-fullscreen {
1100
1106
  box-sizing: border-box;
1101
1107
  }
1102
1108
 
1109
+ .topic-agent-avatar {
1110
+ width: 16px;
1111
+ height: 16px;
1112
+ border-radius: 50%;
1113
+ object-fit: cover;
1114
+ flex-shrink: 0;
1115
+ }
1116
+
1117
+ .ai-agent-draggable {
1118
+ cursor: grab;
1119
+ }
1120
+
1121
+ .ai-agent-draggable:active,
1122
+ .ai-agent-draggable.dragging {
1123
+ cursor: grabbing;
1124
+ opacity: 0.5;
1125
+ }
1126
+
1127
+ .topic-creation-container.drag-over {
1128
+ background: var(--color-active);
1129
+ border-radius: 12px;
1130
+ opacity: 0.8;
1131
+ }
1132
+
1103
1133
  .topic-tag:hover {
1104
1134
  background: var(--surface-hover);
1105
1135
  }
@@ -1344,6 +1374,23 @@ body.chat-fullscreen {
1344
1374
  flex-wrap: wrap;
1345
1375
  }
1346
1376
 
1377
+ .selection-action-bar-select-all {
1378
+ display: flex;
1379
+ align-items: center;
1380
+ gap: var(--space-1);
1381
+ font-size: 0.85em;
1382
+ font-weight: bold;
1383
+ color: var(--color-text);
1384
+ cursor: pointer;
1385
+ user-select: none;
1386
+ white-space: nowrap;
1387
+ }
1388
+
1389
+ .selection-action-bar-select-all-checkbox {
1390
+ cursor: pointer;
1391
+ margin: 0;
1392
+ }
1393
+
1347
1394
  .selection-action-bar-count {
1348
1395
  font-size: 0.85em;
1349
1396
  font-weight: bold;
@@ -1367,6 +1414,15 @@ body.chat-fullscreen {
1367
1414
  background: var(--surface-hover);
1368
1415
  }
1369
1416
 
1417
+ .selection-action-bar-btn:disabled {
1418
+ opacity: 0.4;
1419
+ cursor: not-allowed;
1420
+ }
1421
+
1422
+ .selection-action-bar-btn:disabled:hover {
1423
+ background: none;
1424
+ }
1425
+
1370
1426
  .selection-action-delete:hover {
1371
1427
  color: var(--color-danger);
1372
1428
  }
@@ -164,10 +164,6 @@ creative-tree-row.show-edit .creative-row {
164
164
  }
165
165
 
166
166
  .creative-row:hover .creative-content {
167
- text-decoration: underline;
168
- text-decoration-color: var(--color-link);
169
- text-decoration-thickness: 2px;
170
- text-underline-offset: 2px;
171
167
  cursor: pointer;
172
168
  }
173
169
 
@@ -281,7 +281,8 @@ module Collavre
281
281
  name: u.display_name,
282
282
  avatar_url: view_context.user_avatar_url(u, size: 20),
283
283
  default_avatar: !u.avatar.attached? && u.avatar_url.blank?,
284
- initial: u.display_name[0].upcase
284
+ initial: u.display_name[0].upcase,
285
+ ai_user: u.ai_user?
285
286
  }
286
287
  end
287
288
  render json: {
@@ -7,11 +7,12 @@ module Collavre
7
7
  can_manage = @creative.has_permission?(Current.user, :admin) || is_owner
8
8
  can_create_topic = can_manage || @creative.has_permission?(Current.user, :write)
9
9
 
10
- active_topics = @creative.topics.active.order(:created_at)
10
+ active_topics = @creative.topics.active.order(:created_at).to_a
11
+ preload_primary_agents(active_topics)
11
12
  archived_topics = @creative.topics.archived.order(:created_at)
12
13
 
13
14
  render json: {
14
- topics: active_topics,
15
+ topics: active_topics.map { |t| topic_json(t) },
15
16
  archived_topics: archived_topics,
16
17
  can_manage: can_manage,
17
18
  can_create_topic: can_create_topic
@@ -27,9 +28,16 @@ module Collavre
27
28
  topic.user = Current.user
28
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?
35
+ end
36
+
37
+ broadcast_data = agent ? topic_json_with_agent(topic, agent) : topic.slice(:id, :name)
30
38
  TopicsChannel.broadcast_to(
31
39
  @creative,
32
- { action: "created", topic: topic.slice(:id, :name) }
40
+ { action: "created", topic: broadcast_data, user_id: Current.user.id }
33
41
  )
34
42
  render json: topic, status: :created
35
43
  else
@@ -159,6 +167,31 @@ module Collavre
159
167
  render json: { success: true }
160
168
  end
161
169
 
170
+ def set_primary_agent
171
+ unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
172
+ render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
173
+ end
174
+
175
+ topic = @creative.topics.find(params[:id])
176
+ agent = User.find_by(id: params[:agent_id])
177
+
178
+ unless agent&.ai_user?
179
+ render json: { error: I18n.t("collavre.topics.not_ai_agent") }, status: :unprocessable_entity and return
180
+ end
181
+
182
+ topic.set_primary_agent!(agent)
183
+
184
+ TopicsChannel.broadcast_to(
185
+ @creative,
186
+ {
187
+ action: "updated",
188
+ topic: topic_json_with_agent(topic, agent)
189
+ }
190
+ )
191
+
192
+ render json: { success: true, topic: topic_json_with_agent(topic, agent) }
193
+ end
194
+
162
195
  private
163
196
 
164
197
  def set_creative
@@ -168,5 +201,49 @@ module Collavre
168
201
  def topic_params
169
202
  params.require(:topic).permit(:name)
170
203
  end
204
+
205
+ # Batch-load primary agents for all topics to avoid N+1 queries
206
+ def preload_primary_agents(topics)
207
+ topic_ids = topics.map(&:id)
208
+ return if topic_ids.empty?
209
+
210
+ policies = OrchestratorPolicy.where(
211
+ policy_type: "arbitration",
212
+ scope_type: "Topic",
213
+ scope_id: topic_ids
214
+ ).index_by { |p| p.scope_id.to_i }
215
+
216
+ agent_ids = policies.values.filter_map { |p| p.config&.dig("primary_agent_id") }
217
+ agents = agent_ids.present? ? User.where(id: agent_ids).includes(avatar_attachment: :blob).index_by(&:id) : {}
218
+
219
+ topics.each do |topic|
220
+ policy = policies[topic.id]
221
+ agent_id = policy&.config&.dig("primary_agent_id")
222
+ topic.instance_variable_set(:@_primary_agent, agents&.dig(agent_id))
223
+ end
224
+ end
225
+
226
+ def topic_json(topic)
227
+ data = topic.slice(:id, :name)
228
+ agent = topic.instance_variable_get(:@_primary_agent) || topic.primary_agent
229
+ if agent
230
+ data[:primary_agent] = agent_json(agent)
231
+ end
232
+ data
233
+ end
234
+
235
+ def topic_json_with_agent(topic, agent)
236
+ data = topic.slice(:id, :name)
237
+ data[:primary_agent] = agent_json(agent)
238
+ data
239
+ end
240
+
241
+ def agent_json(agent)
242
+ {
243
+ id: agent.id,
244
+ name: agent.display_name,
245
+ avatar_url: view_context.user_avatar_url(agent, size: 20)
246
+ }
247
+ end
171
248
  end
172
249
  end
@@ -53,6 +53,11 @@ module Collavre
53
53
  end
54
54
  end
55
55
 
56
+ # Creative admins can also edit actions even if not the designated approver
57
+ if status_in_lock != :ok && @creative.has_permission?(Current.user, :admin)
58
+ status_in_lock = :ok
59
+ end
60
+
56
61
  if status_in_lock != :ok
57
62
  approver_mismatch_error = true
58
63
  status_error_key = case status_in_lock
@@ -37,6 +37,38 @@ module Collavre
37
37
  head :no_content
38
38
  end
39
39
 
40
+ def merge
41
+ comment_ids = Array(params[:comment_ids]).map(&:to_i).uniq
42
+ if comment_ids.size < 2
43
+ render json: { error: I18n.t("collavre.comments.merge.minimum_required") }, status: :unprocessable_entity and return
44
+ end
45
+
46
+ unless @creative.has_permission?(Current.user, :feedback)
47
+ render json: { error: I18n.t("collavre.comments.merge.not_authorized") }, status: :forbidden and return
48
+ end
49
+
50
+ visible_scope = @creative.comments.where(
51
+ "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
52
+ false, Current.user.id, Current.user.id
53
+ )
54
+ comments = visible_scope.where(id: comment_ids).to_a
55
+
56
+ if comments.length != comment_ids.length
57
+ render json: { error: I18n.t("collavre.comments.batch_delete_not_found") }, status: :not_found and return
58
+ end
59
+
60
+ # All comments must belong to the current user or their AI agents
61
+ my_ai_agent_ids = Current.user.created_ai_users.pluck(:id)
62
+ allowed_user_ids = [ Current.user.id ] + my_ai_agent_ids
63
+ unauthorized = comments.reject { |c| allowed_user_ids.include?(c.user_id) }
64
+ if unauthorized.any?
65
+ render json: { error: I18n.t("collavre.comments.merge.own_messages_only") }, status: :forbidden and return
66
+ end
67
+
68
+ MergeCommentsJob.perform_later(@creative.id, comment_ids, Current.user.id)
69
+ render json: { message: I18n.t("collavre.comments.merge.started") }, status: :accepted
70
+ end
71
+
40
72
  def move
41
73
  result = CommentMoveService.new(creative: @creative, user: Current.user).call(
42
74
  comment_ids: params[:comment_ids],
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { renderCommentMarkdown } from "../lib/utils/markdown"
2
3
 
3
4
  export default class extends Controller {
4
5
  static targets = ["prevBtn", "nextBtn", "indicator", "deleteBtn", "selectBtn"]
@@ -123,7 +124,10 @@ export default class extends Controller {
123
124
  setContentText(text) {
124
125
  const el = document.getElementById(this.contentTargetValue)
125
126
  const target = el?.querySelector(".comment-content") || el?.querySelector("[data-comment-target='content']")
126
- if (target) target.textContent = text
127
+ if (target) {
128
+ target.innerHTML = renderCommentMarkdown(text)
129
+ target.dataset.rendered = "true"
130
+ }
127
131
  }
128
132
 
129
133
  updateButtons() {
@@ -1,5 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ const CREATIVE_MIME_TYPE = 'application/x-collavre-creative'
4
+
3
5
  export default class extends Controller {
4
6
  static targets = ["list", "toggleButton"]
5
7
 
@@ -11,6 +13,7 @@ export default class extends Controller {
11
13
  this.canManage = false
12
14
  this.draggingContextId = null
13
15
  this.listVisible = false
16
+ // External drop zone handlers are now Stimulus actions on the list target
14
17
  }
15
18
 
16
19
  get creativeId() {
@@ -22,6 +25,7 @@ export default class extends Controller {
22
25
  this.listVisible = false
23
26
  this._updateListVisibility()
24
27
  await this.loadContexts()
28
+ this._bindPopupDragDetection()
25
29
  }
26
30
 
27
31
  onPopupClosed() {
@@ -30,6 +34,7 @@ export default class extends Controller {
30
34
  if (this.hasListTarget) {
31
35
  this.listTarget.innerHTML = ''
32
36
  }
37
+ this._unbindPopupDragDetection()
33
38
  }
34
39
 
35
40
  async loadContexts() {
@@ -97,6 +102,7 @@ export default class extends Controller {
97
102
  if (!this.hasListTarget) return
98
103
 
99
104
  this._updateToggleButton()
105
+ // Drop zone is handled by Stimulus data-action on the list element
100
106
 
101
107
  const dragActions = this.canManage
102
108
  ? 'dragstart->comments--contexts#handleDragStart dragend->comments--contexts#handleDragEnd'
@@ -295,11 +301,15 @@ export default class extends Controller {
295
301
  }
296
302
 
297
303
  async handleReorderDrop(event) {
298
- event.preventDefault()
299
-
300
304
  const targetEl = event.currentTarget
301
305
  targetEl.classList.remove('context-drag-over-left', 'context-drag-over-right')
302
306
 
307
+ // If this is a creative drag (not a context reorder), let it bubble to the list handler
308
+ if (!event.dataTransfer.types.includes('application/x-context-id')) return
309
+
310
+ event.preventDefault()
311
+ event.stopPropagation()
312
+
303
313
  const draggedId = parseInt(event.dataTransfer.getData('application/x-context-id'))
304
314
  const targetId = parseInt(targetEl.dataset.contextId)
305
315
 
@@ -345,7 +355,7 @@ export default class extends Controller {
345
355
  method: 'PATCH',
346
356
  headers: {
347
357
  'Content-Type': 'application/json',
348
- 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
358
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
349
359
  },
350
360
  body: JSON.stringify(params)
351
361
  })
@@ -358,6 +368,111 @@ export default class extends Controller {
358
368
  }
359
369
  }
360
370
 
371
+ // --- Auto-show context list when dragging creative over popup ---
372
+ _bindPopupDragDetection() {
373
+ const popup = this.element.closest('#comments-popup')
374
+ if (!popup) return
375
+ // Unbind any existing handlers first to prevent accumulation across opens
376
+ this._unbindPopupDragDetection()
377
+ this._popupEl = popup
378
+ this._boundPopupDragOver = this._handlePopupDragOver.bind(this)
379
+ this._boundPopupDragLeave = this._handlePopupDragLeave.bind(this)
380
+ this._boundPopupDrop = this.handleExternalDrop.bind(this)
381
+ popup.addEventListener('dragover', this._boundPopupDragOver)
382
+ popup.addEventListener('dragleave', this._boundPopupDragLeave)
383
+ popup.addEventListener('drop', this._boundPopupDrop)
384
+ }
385
+
386
+ _unbindPopupDragDetection() {
387
+ if (!this._popupEl) return
388
+ this._popupEl.removeEventListener('dragover', this._boundPopupDragOver)
389
+ this._popupEl.removeEventListener('dragleave', this._boundPopupDragLeave)
390
+ this._popupEl.removeEventListener('drop', this._boundPopupDrop)
391
+ this._popupEl = null
392
+ }
393
+
394
+ _handlePopupDragOver(event) {
395
+ if (this._isInternalReorder(event)) return
396
+ if (!this._isCreativeDrag(event)) return
397
+ if (!this.canManage) return
398
+
399
+ // Must preventDefault to allow drop on the popup
400
+ event.preventDefault()
401
+ event.dataTransfer.dropEffect = 'move'
402
+
403
+ if (!this.listVisible) {
404
+ // Auto-show context list when dragging a creative over the popup
405
+ this.listVisible = true
406
+ this._updateListVisibility()
407
+ }
408
+ }
409
+
410
+ _handlePopupDragLeave(event) {
411
+ if (!this._popupEl) return
412
+ // Only hide if leaving the popup entirely
413
+ if (this._popupEl.contains(event.relatedTarget)) return
414
+ if (this._hasBeenManuallyToggled) return
415
+
416
+ // Restore original state if no contexts
417
+ if (this.contexts.length === 0) {
418
+ this.listVisible = false
419
+ this._updateListVisibility()
420
+ }
421
+ }
422
+
423
+ // --- Drop zone for adding creatives from tree ---
424
+
425
+ _isCreativeDrag(event) {
426
+ return event.dataTransfer.types.includes(CREATIVE_MIME_TYPE)
427
+ }
428
+
429
+ _isInternalReorder(event) {
430
+ return event.dataTransfer.types.includes('application/x-context-id')
431
+ }
432
+
433
+ handleExternalDragOver(event) {
434
+ if (this._isInternalReorder(event)) return
435
+ if (!this._isCreativeDrag(event)) return
436
+ if (!this.canManage) return
437
+
438
+ event.preventDefault()
439
+ event.dataTransfer.dropEffect = 'move'
440
+ this.listTarget.classList.add('context-drop-active')
441
+ }
442
+
443
+ handleExternalDragLeave(event) {
444
+ // Only remove highlight if truly leaving the list area
445
+ if (!this.listTarget.contains(event.relatedTarget)) {
446
+ this.listTarget.classList.remove('context-drop-active')
447
+ }
448
+ }
449
+
450
+ async handleExternalDrop(event) {
451
+ if (this._isInternalReorder(event)) return
452
+ if (!this._isCreativeDrag(event)) return
453
+ if (!this.canManage) return
454
+
455
+ // Always prevent default for creative drags to avoid browser navigation
456
+ event.preventDefault()
457
+ event.stopPropagation()
458
+
459
+ this.listTarget.classList.remove('context-drop-active')
460
+
461
+ let creativeId = null
462
+
463
+ const rawData = event.dataTransfer.getData(CREATIVE_MIME_TYPE)
464
+ if (rawData) {
465
+ try {
466
+ const parsed = JSON.parse(rawData)
467
+ creativeId = parseInt(parsed.creativeId)
468
+ } catch (e) { /* ignore */ }
469
+ }
470
+
471
+ if (!creativeId) return
472
+
473
+ await this._addContextId(creativeId)
474
+ }
475
+
361
476
  _escapeHtml(text) {
362
477
  const div = document.createElement('div')
363
478
  div.textContent = text
@@ -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
- <span class="selection-action-bar-count">${i18n('selectionCountText', '{count}개 선택').replace('{count}', count)}</span>
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