collavre 0.8.3 → 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.
Files changed (33) 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 +71 -1
  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 +122 -10
  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 +124 -3
  12. data/app/javascript/controllers/comments/form_controller.js +78 -5
  13. data/app/javascript/controllers/comments/list_controller.js +79 -4
  14. data/app/javascript/controllers/comments/popup_controller.js +124 -10
  15. data/app/javascript/controllers/comments/presence_controller.js +22 -0
  16. data/app/javascript/controllers/comments/topics_controller.js +141 -6
  17. data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
  18. data/app/javascript/controllers/topic_search_controller.js +80 -10
  19. data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
  20. data/app/javascript/lib/api/topics.js +64 -0
  21. data/app/javascript/modules/creative_row_editor.js +13 -0
  22. data/app/jobs/collavre/compress_job.rb +24 -25
  23. data/app/jobs/collavre/merge_comments_job.rb +79 -0
  24. data/app/models/collavre/comment/broadcastable.rb +7 -2
  25. data/app/models/collavre/topic.rb +29 -0
  26. data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
  27. data/app/services/collavre/comments/topic_command.rb +27 -13
  28. data/app/views/collavre/comments/_comments_popup.html.erb +6 -1
  29. data/config/locales/comments.en.yml +13 -1
  30. data/config/locales/comments.ko.yml +13 -1
  31. data/config/routes.rb +3 -0
  32. data/lib/collavre/version.rb +1 -1
  33. metadata +4 -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: 6e3281424919fc07e271f712389040c20bef756de2f467de4b5c22d41caf7431
4
+ data.tar.gz: aafc13da62eef7e36d0cc80014553fd1299260b5e8c8b5aa97e1238f9d6241a3
5
5
  SHA512:
6
- metadata.gz: e443416101117e0e3440f60d889f4c317fc19d59e5d3e92fdc511f1caf0fee52470e3e37b036709fbc63eea01d266c622e25ded80dc584d77c7fd0e2feed257b
7
- data.tar.gz: 33e264807609298b92f8786f755bce2ef7846e653d6d5b4c7ff0eb1c267cf0273a56753d8e3758cf99e8300d14f1f6cfbde99b21e9f99e39645e44d3a0ec58e6
6
+ metadata.gz: '09cbd1f6b17e92ec90b3e2d7f8da598ff1db2dbbaf01282bfd53df19bb8620afcd0e3ebd8dac2c1929233f816d000f8b573990df7dea62ebcb098bd414b0af9f'
7
+ data.tar.gz: 56bf15d2aca40fc28ba43cad261a91b2ae64e9c5bdf873c490a14f4f0fad4608d499906b295bfca416d4026406334f79fc422f989f81de4549f367ad88eade8d
@@ -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;
@@ -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
  }
@@ -984,6 +989,12 @@ body.chat-fullscreen {
984
989
  margin-right: -1px;
985
990
  }
986
991
 
992
+ .comment-contexts-list.context-drop-active {
993
+ outline: 2px dashed var(--color-link);
994
+ outline-offset: -2px;
995
+ background-color: color-mix(in srgb, var(--color-link) 8%, transparent);
996
+ }
997
+
987
998
  .context-chip[draggable="true"] {
988
999
  cursor: grab;
989
1000
  }
@@ -1100,6 +1111,30 @@ body.chat-fullscreen {
1100
1111
  box-sizing: border-box;
1101
1112
  }
1102
1113
 
1114
+ .topic-agent-avatar {
1115
+ width: 16px;
1116
+ height: 16px;
1117
+ border-radius: 50%;
1118
+ object-fit: cover;
1119
+ flex-shrink: 0;
1120
+ }
1121
+
1122
+ .ai-agent-draggable {
1123
+ cursor: grab;
1124
+ }
1125
+
1126
+ .ai-agent-draggable:active,
1127
+ .ai-agent-draggable.dragging {
1128
+ cursor: grabbing;
1129
+ opacity: 0.5;
1130
+ }
1131
+
1132
+ .topic-creation-container.drag-over {
1133
+ background: var(--color-active);
1134
+ border-radius: 12px;
1135
+ opacity: 0.8;
1136
+ }
1137
+
1103
1138
  .topic-tag:hover {
1104
1139
  background: var(--surface-hover);
1105
1140
  }
@@ -1344,6 +1379,23 @@ body.chat-fullscreen {
1344
1379
  flex-wrap: wrap;
1345
1380
  }
1346
1381
 
1382
+ .selection-action-bar-select-all {
1383
+ display: flex;
1384
+ align-items: center;
1385
+ gap: var(--space-1);
1386
+ font-size: 0.85em;
1387
+ font-weight: bold;
1388
+ color: var(--color-text);
1389
+ cursor: pointer;
1390
+ user-select: none;
1391
+ white-space: nowrap;
1392
+ }
1393
+
1394
+ .selection-action-bar-select-all-checkbox {
1395
+ cursor: pointer;
1396
+ margin: 0;
1397
+ }
1398
+
1347
1399
  .selection-action-bar-count {
1348
1400
  font-size: 0.85em;
1349
1401
  font-weight: bold;
@@ -1367,6 +1419,15 @@ body.chat-fullscreen {
1367
1419
  background: var(--surface-hover);
1368
1420
  }
1369
1421
 
1422
+ .selection-action-bar-btn:disabled {
1423
+ opacity: 0.4;
1424
+ cursor: not-allowed;
1425
+ }
1426
+
1427
+ .selection-action-bar-btn:disabled:hover {
1428
+ background: none;
1429
+ }
1430
+
1370
1431
  .selection-action-delete:hover {
1371
1432
  color: var(--color-danger);
1372
1433
  }
@@ -1483,3 +1544,12 @@ body.chat-fullscreen {
1483
1544
  0%, 70% { opacity: 1; }
1484
1545
  100% { opacity: 0; }
1485
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
+ }
@@ -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
@@ -26,15 +27,43 @@ module Collavre
26
27
  topic = @creative.topics.build(topic_params)
27
28
  topic.user = Current.user
28
29
 
29
- if topic.save
30
- TopicsChannel.broadcast_to(
31
- @creative,
32
- { action: "created", topic: topic.slice(:id, :name) }
33
- )
34
- render json: topic, status: :created
35
- else
36
- render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
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
55
+ end
56
+ end
57
+ rescue CommentMoveService::MoveError => e
58
+ render json: { errors: [ e.message ] }, status: :unprocessable_entity
59
+ end
60
+
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
37
64
  end
65
+
66
+ render json: { name: generate_next_topic_name }
38
67
  end
39
68
 
40
69
  def update
@@ -159,6 +188,31 @@ module Collavre
159
188
  render json: { success: true }
160
189
  end
161
190
 
191
+ def set_primary_agent
192
+ unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
193
+ render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
194
+ end
195
+
196
+ topic = @creative.topics.find(params[:id])
197
+ agent = User.find_by(id: params[:agent_id])
198
+
199
+ unless agent&.ai_user?
200
+ render json: { error: I18n.t("collavre.topics.not_ai_agent") }, status: :unprocessable_entity and return
201
+ end
202
+
203
+ topic.set_primary_agent!(agent)
204
+
205
+ TopicsChannel.broadcast_to(
206
+ @creative,
207
+ {
208
+ action: "updated",
209
+ topic: topic_json_with_agent(topic, agent)
210
+ }
211
+ )
212
+
213
+ render json: { success: true, topic: topic_json_with_agent(topic, agent) }
214
+ end
215
+
162
216
  private
163
217
 
164
218
  def set_creative
@@ -168,5 +222,63 @@ module Collavre
168
222
  def topic_params
169
223
  params.require(:topic).permit(:name)
170
224
  end
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
+
240
+ # Batch-load primary agents for all topics to avoid N+1 queries
241
+ def preload_primary_agents(topics)
242
+ topic_ids = topics.map(&:id)
243
+ return if topic_ids.empty?
244
+
245
+ policies = OrchestratorPolicy.where(
246
+ policy_type: "arbitration",
247
+ scope_type: "Topic",
248
+ scope_id: topic_ids
249
+ ).index_by { |p| p.scope_id.to_i }
250
+
251
+ agent_ids = policies.values.filter_map { |p| p.config&.dig("primary_agent_id") }
252
+ agents = agent_ids.present? ? User.where(id: agent_ids).includes(avatar_attachment: :blob).index_by(&:id) : {}
253
+
254
+ topics.each do |topic|
255
+ policy = policies[topic.id]
256
+ agent_id = policy&.config&.dig("primary_agent_id")
257
+ topic.instance_variable_set(:@_primary_agent, agents&.dig(agent_id))
258
+ end
259
+ end
260
+
261
+ def topic_json(topic)
262
+ data = topic.slice(:id, :name)
263
+ agent = topic.instance_variable_get(:@_primary_agent) || topic.primary_agent
264
+ if agent
265
+ data[:primary_agent] = agent_json(agent)
266
+ end
267
+ data
268
+ end
269
+
270
+ def topic_json_with_agent(topic, agent)
271
+ data = topic.slice(:id, :name)
272
+ data[:primary_agent] = agent_json(agent)
273
+ data
274
+ end
275
+
276
+ def agent_json(agent)
277
+ {
278
+ id: agent.id,
279
+ name: agent.display_name,
280
+ avatar_url: view_context.user_avatar_url(agent, size: 20)
281
+ }
282
+ end
171
283
  end
172
284
  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,117 @@ 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 = (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
+ }
385
+ popup.addEventListener('dragover', this._boundPopupDragOver)
386
+ popup.addEventListener('dragleave', this._boundPopupDragLeave)
387
+ popup.addEventListener('drop', this._boundPopupDrop)
388
+ }
389
+
390
+ _unbindPopupDragDetection() {
391
+ if (!this._popupEl) return
392
+ this._popupEl.removeEventListener('dragover', this._boundPopupDragOver)
393
+ this._popupEl.removeEventListener('dragleave', this._boundPopupDragLeave)
394
+ this._popupEl.removeEventListener('drop', this._boundPopupDrop)
395
+ this._popupEl = null
396
+ }
397
+
398
+ _handlePopupDragOver(event) {
399
+ if (this._isInternalReorder(event)) return
400
+ if (!this._isCreativeDrag(event)) return
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
404
+
405
+ // Must preventDefault to allow drop on the popup
406
+ event.preventDefault()
407
+ event.dataTransfer.dropEffect = 'move'
408
+
409
+ if (!this.listVisible) {
410
+ // Auto-show context list when dragging a creative over the popup
411
+ this.listVisible = true
412
+ this._updateListVisibility()
413
+ }
414
+ }
415
+
416
+ _handlePopupDragLeave(event) {
417
+ if (!this._popupEl) return
418
+ // Only hide if leaving the popup entirely
419
+ if (this._popupEl.contains(event.relatedTarget)) return
420
+ if (this._hasBeenManuallyToggled) return
421
+
422
+ // Restore original state if no contexts
423
+ if (this.contexts.length === 0) {
424
+ this.listVisible = false
425
+ this._updateListVisibility()
426
+ }
427
+ }
428
+
429
+ // --- Drop zone for adding creatives from tree ---
430
+
431
+ _isCreativeDrag(event) {
432
+ return event.dataTransfer.types.includes(CREATIVE_MIME_TYPE)
433
+ }
434
+
435
+ _isInternalReorder(event) {
436
+ return event.dataTransfer.types.includes('application/x-context-id')
437
+ }
438
+
439
+ handleExternalDragOver(event) {
440
+ if (this._isInternalReorder(event)) return
441
+ if (!this._isCreativeDrag(event)) return
442
+ if (!this.canManage) return
443
+
444
+ event.preventDefault()
445
+ event.dataTransfer.dropEffect = 'move'
446
+ this.listTarget.classList.add('context-drop-active')
447
+ }
448
+
449
+ handleExternalDragLeave(event) {
450
+ // Only remove highlight if truly leaving the list area
451
+ if (!this.listTarget.contains(event.relatedTarget)) {
452
+ this.listTarget.classList.remove('context-drop-active')
453
+ }
454
+ }
455
+
456
+ async handleExternalDrop(event) {
457
+ if (this._isInternalReorder(event)) return
458
+ if (!this._isCreativeDrag(event)) return
459
+ if (!this.canManage) return
460
+
461
+ // Always prevent default for creative drags to avoid browser navigation
462
+ event.preventDefault()
463
+ event.stopPropagation()
464
+
465
+ this.listTarget.classList.remove('context-drop-active')
466
+
467
+ let creativeId = null
468
+
469
+ const rawData = event.dataTransfer.getData(CREATIVE_MIME_TYPE)
470
+ if (rawData) {
471
+ try {
472
+ const parsed = JSON.parse(rawData)
473
+ creativeId = parseInt(parsed.creativeId)
474
+ } catch (e) { /* ignore */ }
475
+ }
476
+
477
+ if (!creativeId) return
478
+
479
+ await this._addContextId(creativeId)
480
+ }
481
+
361
482
  _escapeHtml(text) {
362
483
  const div = document.createElement('div')
363
484
  div.textContent = text