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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/activity_logs.css +1 -1
- data/app/assets/stylesheets/collavre/comment_versions.css +10 -6
- data/app/assets/stylesheets/collavre/comments_popup.css +71 -1
- data/app/assets/stylesheets/collavre/creatives.css +0 -4
- data/app/controllers/collavre/comments_controller.rb +2 -1
- data/app/controllers/collavre/topics_controller.rb +122 -10
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +5 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +32 -0
- data/app/javascript/controllers/comment_version_controller.js +5 -1
- data/app/javascript/controllers/comments/contexts_controller.js +124 -3
- data/app/javascript/controllers/comments/form_controller.js +78 -5
- data/app/javascript/controllers/comments/list_controller.js +79 -4
- data/app/javascript/controllers/comments/popup_controller.js +124 -10
- data/app/javascript/controllers/comments/presence_controller.js +22 -0
- data/app/javascript/controllers/comments/topics_controller.js +141 -6
- data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
- data/app/javascript/controllers/topic_search_controller.js +80 -10
- data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
- data/app/javascript/lib/api/topics.js +64 -0
- data/app/javascript/modules/creative_row_editor.js +13 -0
- data/app/jobs/collavre/compress_job.rb +24 -25
- data/app/jobs/collavre/merge_comments_job.rb +79 -0
- data/app/models/collavre/comment/broadcastable.rb +7 -2
- data/app/models/collavre/topic.rb +29 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
- data/app/services/collavre/comments/topic_command.rb +27 -13
- data/app/views/collavre/comments/_comments_popup.html.erb +6 -1
- data/config/locales/comments.en.yml +13 -1
- data/config/locales/comments.ko.yml +13 -1
- data/config/routes.rb +3 -0
- data/lib/collavre/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e3281424919fc07e271f712389040c20bef756de2f467de4b5c22d41caf7431
|
|
4
|
+
data.tar.gz: aafc13da62eef7e36d0cc80014553fd1299260b5e8c8b5aa97e1238f9d6241a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09cbd1f6b17e92ec90b3e2d7f8da598ff1db2dbbaf01282bfd53df19bb8620afcd0e3ebd8dac2c1929233f816d000f8b573990df7dea62ebcb098bd414b0af9f'
|
|
7
|
+
data.tar.gz: 56bf15d2aca40fc28ba43cad261a91b2ae64e9c5bdf873c490a14f4f0fad4608d499906b295bfca416d4026406334f79fc422f989f81de4549f367ad88eade8d
|
|
@@ -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-
|
|
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:
|
|
39
|
+
border: 1px solid var(--border-color, #ddd);
|
|
40
|
+
border-radius: var(--radius-1, 4px);
|
|
39
41
|
cursor: pointer;
|
|
40
|
-
color: var(--text-
|
|
42
|
+
color: var(--text-primary, #202123);
|
|
41
43
|
font-size: var(--text-xs, 0.75rem);
|
|
42
|
-
padding: 0 var(--space-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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)
|
|
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"]')
|
|
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
|