collavre 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) 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 +60 -2
  5. data/app/assets/stylesheets/collavre/creatives.css +7 -4
  6. data/app/assets/stylesheets/collavre/dark_mode.css +25 -5
  7. data/app/controllers/collavre/comments_controller.rb +2 -1
  8. data/app/controllers/collavre/topics_controller.rb +80 -3
  9. data/app/controllers/concerns/collavre/comments/approval_actions.rb +5 -0
  10. data/app/controllers/concerns/collavre/comments/batch_operations.rb +32 -0
  11. data/app/javascript/controllers/comment_version_controller.js +5 -1
  12. data/app/javascript/controllers/comments/contexts_controller.js +118 -3
  13. data/app/javascript/controllers/comments/list_controller.js +67 -1
  14. data/app/javascript/controllers/comments/popup_controller.js +143 -10
  15. data/app/javascript/controllers/comments/presence_controller.js +22 -0
  16. data/app/javascript/controllers/comments/topics_controller.js +105 -6
  17. data/app/javascript/controllers/creatives/expansion_controller.js +8 -0
  18. data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
  19. data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
  20. data/app/javascript/modules/creative_row_editor.js +13 -0
  21. data/app/jobs/collavre/compress_job.rb +24 -25
  22. data/app/jobs/collavre/merge_comments_job.rb +79 -0
  23. data/app/models/collavre/topic.rb +29 -0
  24. data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
  25. data/app/services/collavre/comments/topic_command.rb +27 -13
  26. data/app/views/collavre/comments/_comments_popup.html.erb +5 -1
  27. data/config/locales/comments.en.yml +11 -1
  28. data/config/locales/comments.ko.yml +11 -1
  29. data/config/routes.rb +2 -0
  30. data/lib/collavre/version.rb +1 -1
  31. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f586b6b08d3de11a9c9212f4ff7e11113143bdf0dbbd5963668e9613041b3f02
4
- data.tar.gz: 5ad7d024a1c53506c0ef1ee08731041de3a66842de48f26912b5eb56051c7029
3
+ metadata.gz: 656a876df08e2034398807dd976a0da67f7b4e5120dd73301ea521dd1291eb8b
4
+ data.tar.gz: c17a5821354531dfaaafd6dd51794114a132c42a3535d612410a838d75499050
5
5
  SHA512:
6
- metadata.gz: 187df2c9024426107e264eb14d7af711cc10255f87eec3380287465c2f0b19198b4a67f50b8d8e3920105166d268af57e1a9a525cb9c49ea84e11a2031ac9503
7
- data.tar.gz: eb58fba86ff137079da0f834c3f1b82e2fbdede8c5e2d930ebff1602b6c38e45744d6a31bb01d568142b4bee42b5bd0bc68a5bf6e77f6187993f901bee3449ad
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;
@@ -10,9 +10,11 @@
10
10
  min-width: 200px;
11
11
  min-height: 200px;
12
12
  background: var(--surface-bg);
13
- border: 1px solid var(--border-color);
13
+ border: var(--border-1) solid var(--color-active);
14
14
  border-radius: var(--radius-3);
15
- box-shadow: var(--shadow-3);
15
+ box-shadow: var(--shadow-3),
16
+ 0 0 6px color-mix(in srgb, var(--color-active) 25%, transparent),
17
+ 0 0 14px color-mix(in srgb, var(--color-active) 12%, transparent);
16
18
  padding: 1em;
17
19
  flex-direction: column;
18
20
  transition: top 0.25s ease, left 0.25s ease, width 0.25s ease, height 0.25s ease,
@@ -982,6 +984,12 @@ body.chat-fullscreen {
982
984
  margin-right: -1px;
983
985
  }
984
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
+
985
993
  .context-chip[draggable="true"] {
986
994
  cursor: grab;
987
995
  }
@@ -1098,6 +1106,30 @@ body.chat-fullscreen {
1098
1106
  box-sizing: border-box;
1099
1107
  }
1100
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
+
1101
1133
  .topic-tag:hover {
1102
1134
  background: var(--surface-hover);
1103
1135
  }
@@ -1342,6 +1374,23 @@ body.chat-fullscreen {
1342
1374
  flex-wrap: wrap;
1343
1375
  }
1344
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
+
1345
1394
  .selection-action-bar-count {
1346
1395
  font-size: 0.85em;
1347
1396
  font-weight: bold;
@@ -1365,6 +1414,15 @@ body.chat-fullscreen {
1365
1414
  background: var(--surface-hover);
1366
1415
  }
1367
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
+
1368
1426
  .selection-action-delete:hover {
1369
1427
  color: var(--color-danger);
1370
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
 
@@ -440,6 +436,13 @@ creative-tree-row:not([expanded]) .creative-toggle-btn {
440
436
  background-color: var(--border-drag-over);
441
437
  }
442
438
 
439
+ creative-tree-row.chat-active .creative-row {
440
+ border: var(--border-1) solid var(--color-active);
441
+ border-radius: var(--radius-2);
442
+ box-shadow: 0 0 6px color-mix(in srgb, var(--color-active) 25%, transparent),
443
+ 0 0 14px color-mix(in srgb, var(--color-active) 12%, transparent);
444
+ }
445
+
443
446
  .creative-row:hover,
444
447
  .creative-row.level-1:hover,
445
448
  .creative-row.level-2:hover,
@@ -79,12 +79,7 @@ body.dark-mode {
79
79
  /* ============================================================================
80
80
  * BASE ELEMENT STYLES
81
81
  * ============================================================================ */
82
- html {
83
- scroll-behavior: smooth;
84
- }
85
-
86
82
  @media (prefers-reduced-motion: reduce) {
87
- html { scroll-behavior: auto; }
88
83
 
89
84
  /* Collapse decorative transitions to instant; state changes still
90
85
  apply but without visible motion (hover bg, theme switch, etc.). */
@@ -302,3 +297,28 @@ input[type="datetime-local"]::-webkit-datetime-edit-minute-field {
302
297
  .inline-block {
303
298
  display: inline-block;
304
299
  }
300
+
301
+ /* --- Dark mode: stronger glow for visibility on dark backgrounds --- */
302
+ body.dark-mode #comments-popup {
303
+ box-shadow: var(--shadow-3),
304
+ 0 0 8px color-mix(in srgb, var(--color-active) 45%, transparent),
305
+ 0 0 20px color-mix(in srgb, var(--color-active) 25%, transparent);
306
+ }
307
+
308
+ body.dark-mode creative-tree-row.chat-active .creative-row {
309
+ box-shadow: 0 0 8px color-mix(in srgb, var(--color-active) 45%, transparent),
310
+ 0 0 20px color-mix(in srgb, var(--color-active) 25%, transparent);
311
+ }
312
+
313
+ @media (prefers-color-scheme: dark) {
314
+ body:not(.light-mode):not(.dark-mode) #comments-popup {
315
+ box-shadow: var(--shadow-3),
316
+ 0 0 8px color-mix(in srgb, var(--color-active) 45%, transparent),
317
+ 0 0 20px color-mix(in srgb, var(--color-active) 25%, transparent);
318
+ }
319
+
320
+ body:not(.light-mode):not(.dark-mode) creative-tree-row.chat-active .creative-row {
321
+ box-shadow: 0 0 8px color-mix(in srgb, var(--color-active) 45%, transparent),
322
+ 0 0 20px color-mix(in srgb, var(--color-active) 25%, transparent);
323
+ }
324
+ }
@@ -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