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.
- 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 +56 -0
- 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 +80 -3
- 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 +118 -3
- data/app/javascript/controllers/comments/list_controller.js +67 -1
- 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 +105 -6
- data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
- 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/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 +5 -1
- data/config/locales/comments.en.yml +11 -1
- data/config/locales/comments.ko.yml +11 -1
- data/config/routes.rb +2 -0
- data/lib/collavre/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 656a876df08e2034398807dd976a0da67f7b4e5120dd73301ea521dd1291eb8b
|
|
4
|
+
data.tar.gz: c17a5821354531dfaaafd6dd51794114a132c42a3535d612410a838d75499050
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9547afa6f502aa818ef3a565d3e8991f25298623dffd980a2d63f3383debd2eef08ce0440285d5fe7c1f8dc7810245cb1aea8f252bf44d8cdd1400460b280fa1
|
|
7
|
+
data.tar.gz: 336344900a57853600abb3d8f879741d52f8673238a63b90b5054fe5d5f8b412fb54aced24dd9c93ea07a1c66175fe5518a58bd6011f9aae363a0a82720e77ef
|
|
@@ -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;
|
|
@@ -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:
|
|
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)
|
|
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,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
|
-
<
|
|
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
|
|