collavre 0.5.0 → 0.7.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/comment_versions.css +76 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
- data/app/assets/stylesheets/collavre/creatives.css +73 -1
- data/app/assets/stylesheets/collavre/org_chart.css +319 -0
- data/app/assets/stylesheets/collavre/popup.css +68 -1
- data/app/controllers/collavre/application_controller.rb +13 -0
- data/app/controllers/collavre/comments/versions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +14 -153
- data/app/controllers/collavre/concerns/exportable.rb +30 -0
- data/app/controllers/collavre/concerns/shareable.rb +28 -0
- data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
- data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
- data/app/controllers/collavre/creative_imports_controller.rb +6 -0
- data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
- data/app/controllers/collavre/creative_plans_controller.rb +1 -1
- data/app/controllers/collavre/creative_shares_controller.rb +84 -14
- data/app/controllers/collavre/creatives_controller.rb +70 -194
- data/app/controllers/collavre/google_auth_controller.rb +3 -0
- data/app/controllers/collavre/invites_controller.rb +2 -1
- data/app/controllers/collavre/sessions_controller.rb +3 -0
- data/app/controllers/collavre/topics_controller.rb +39 -2
- data/app/controllers/collavre/users_controller.rb +5 -404
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
- data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
- data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
- data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
- data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +12 -9
- data/app/helpers/collavre/navigation_helper.rb +1 -1
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/controllers/comment_controller.js +33 -70
- data/app/javascript/controllers/comment_version_controller.js +164 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
- data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
- data/app/javascript/controllers/comments/contexts_controller.js +363 -0
- data/app/javascript/controllers/comments/form_controller.js +304 -13
- data/app/javascript/controllers/comments/list_controller.js +151 -62
- data/app/javascript/controllers/comments/popup_controller.js +66 -38
- data/app/javascript/controllers/comments/presence_controller.js +2 -10
- data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
- data/app/javascript/controllers/comments/topics_controller.js +34 -10
- data/app/javascript/controllers/index.js +15 -1
- data/app/javascript/controllers/org_chart_controller.js +46 -0
- data/app/javascript/controllers/share_modal_controller.js +369 -0
- data/app/javascript/controllers/topic_search_controller.js +103 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
- data/app/javascript/lib/api/creatives.js +12 -0
- data/app/javascript/lib/api/csrf_fetch.js +35 -0
- data/app/javascript/lib/api/drag_drop.js +17 -0
- data/app/javascript/modules/command_menu.js +40 -0
- data/app/javascript/modules/creative_row_editor.js +88 -0
- data/app/javascript/modules/slide_view.js +2 -1
- data/app/jobs/collavre/ai_agent_job.rb +42 -30
- data/app/jobs/collavre/compress_job.rb +92 -0
- data/app/models/collavre/comment.rb +36 -1
- data/app/models/collavre/comment_version.rb +15 -0
- data/app/models/collavre/creative/describable.rb +1 -1
- data/app/models/collavre/creative.rb +51 -0
- data/app/models/collavre/task.rb +30 -2
- data/app/models/collavre/user.rb +20 -3
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
- data/app/services/collavre/ai_agent/message_builder.rb +85 -6
- data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
- data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
- data/app/services/collavre/ai_agent/review_handler.rb +18 -1
- data/app/services/collavre/ai_agent_service.rb +130 -183
- data/app/services/collavre/ai_client.rb +6 -0
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/command_menu_service.rb +19 -0
- data/app/services/collavre/comments/command_processor.rb +3 -1
- data/app/services/collavre/comments/compress_command.rb +75 -0
- data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
- data/app/services/collavre/comments/work_command.rb +161 -0
- data/app/services/collavre/comments/workflow_executor.rb +276 -0
- data/app/services/collavre/creatives/plan_tagger.rb +14 -3
- data/app/services/collavre/creatives/tree_formatter.rb +53 -13
- data/app/services/collavre/gemini_parent_recommender.rb +4 -4
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
- data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
- data/app/services/collavre/orchestration/scheduler.rb +3 -2
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/dispatcher.rb +9 -0
- data/app/services/collavre/tools/creative_create_service.rb +1 -8
- data/app/services/collavre/tools/creative_import_service.rb +46 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
- data/app/services/collavre/tools/creative_update_service.rb +1 -8
- data/app/services/collavre/tools/cron_list_service.rb +1 -1
- data/app/services/collavre/tools/description_normalizable.rb +16 -0
- data/app/views/collavre/comments/_comment.html.erb +25 -8
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
- data/app/views/collavre/creatives/_share_button.html.erb +4 -1
- data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
- data/app/views/collavre/creatives/index.html.erb +5 -5
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
- data/app/views/collavre/users/_org_chart.html.erb +68 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
- data/app/views/collavre/users/new_ai.html.erb +9 -0
- data/app/views/collavre/users/show.html.erb +32 -8
- data/config/locales/comments.en.yml +57 -2
- data/config/locales/comments.ko.yml +57 -2
- data/config/locales/contacts.en.yml +31 -0
- data/config/locales/contacts.ko.yml +31 -0
- data/config/locales/contexts.en.yml +8 -0
- data/config/locales/contexts.ko.yml +8 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +6 -0
- data/config/locales/users.en.yml +1 -0
- data/config/locales/users.ko.yml +1 -0
- data/config/routes.rb +14 -1
- data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
- data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
- data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
- data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
- data/lib/collavre/version.rb +1 -1
- metadata +47 -10
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
- data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
- data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
- data/app/javascript/modules/share_modal.js +0 -76
- data/app/javascript/modules/share_user_popup.js +0 -77
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
- data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
- data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["container"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
if (!this.hasContainerTarget) {
|
|
8
|
+
this.globalContainer = document.createElement("div")
|
|
9
|
+
this.globalContainer.dataset.shareModalTarget = "container"
|
|
10
|
+
document.body.appendChild(this.globalContainer)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
this.handleKeydown = this.handleKeydown.bind(this)
|
|
14
|
+
document.addEventListener("keydown", this.handleKeydown)
|
|
15
|
+
this.refreshTimer = null
|
|
16
|
+
|
|
17
|
+
this.checkOpenShareParam()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
22
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
|
23
|
+
if (this.globalContainer) {
|
|
24
|
+
this.globalContainer.remove()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get container() {
|
|
29
|
+
return this.hasContainerTarget ? this.containerTarget : this.globalContainer
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
open(event) {
|
|
33
|
+
const btn = event.currentTarget
|
|
34
|
+
const sharesUrl = btn.dataset.shareModalUrlParam || btn.dataset.sharesUrl
|
|
35
|
+
|
|
36
|
+
if (!sharesUrl) {
|
|
37
|
+
console.error("share-modal: No shares URL provided")
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.currentSharesUrl = sharesUrl
|
|
42
|
+
|
|
43
|
+
fetch(sharesUrl, {
|
|
44
|
+
headers: { "Accept": "text/html" }
|
|
45
|
+
})
|
|
46
|
+
.then(r => r.text())
|
|
47
|
+
.then(html => {
|
|
48
|
+
this.container.innerHTML = html
|
|
49
|
+
this.#initializeModal()
|
|
50
|
+
this.#dispatchEvent("share-modal:opened")
|
|
51
|
+
})
|
|
52
|
+
.catch(err => {
|
|
53
|
+
console.error("share-modal: Failed to load modal", err)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
close() {
|
|
58
|
+
const modal = document.getElementById("share-creative-modal")
|
|
59
|
+
if (modal) {
|
|
60
|
+
modal.style.display = "none"
|
|
61
|
+
document.body.classList.remove("no-scroll")
|
|
62
|
+
}
|
|
63
|
+
if (this.container) {
|
|
64
|
+
this.container.innerHTML = ""
|
|
65
|
+
}
|
|
66
|
+
this.#dispatchEvent("share-modal:closed")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handleKeydown(event) {
|
|
70
|
+
if (event.key === "Escape") {
|
|
71
|
+
const modal = document.getElementById("share-creative-modal")
|
|
72
|
+
if (modal && modal.style.display === "flex") {
|
|
73
|
+
this.close()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
checkOpenShareParam() {
|
|
79
|
+
const params = new URLSearchParams(window.location.search)
|
|
80
|
+
if (params.get("open_share") === "true") {
|
|
81
|
+
const shareBtn = document.getElementById("share-creative-btn")
|
|
82
|
+
if (shareBtn) {
|
|
83
|
+
shareBtn.click()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Private
|
|
89
|
+
|
|
90
|
+
get #errorFallbackMessage() {
|
|
91
|
+
const modal = document.getElementById("share-creative-modal")
|
|
92
|
+
return modal?.dataset?.errorMessage || "An error occurred"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#initializeModal() {
|
|
96
|
+
const modal = document.getElementById("share-creative-modal")
|
|
97
|
+
if (!modal) return
|
|
98
|
+
|
|
99
|
+
modal.style.display = "flex"
|
|
100
|
+
document.body.classList.add("no-scroll")
|
|
101
|
+
|
|
102
|
+
const closeBtn = document.getElementById("close-share-modal")
|
|
103
|
+
if (closeBtn) {
|
|
104
|
+
closeBtn.onclick = () => this.close()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
modal.onclick = (e) => {
|
|
108
|
+
if (e.target === modal) this.close()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.#initializeForm()
|
|
112
|
+
this.#initializeDeleteButtons()
|
|
113
|
+
this.#initializeInviteLink()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#initializeForm() {
|
|
117
|
+
const form = document.getElementById("share-creative-form")
|
|
118
|
+
if (!form) return
|
|
119
|
+
|
|
120
|
+
form.addEventListener("submit", (e) => {
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
const formData = new FormData(form)
|
|
123
|
+
const email = formData.get("user_email")
|
|
124
|
+
const permission = formData.get("permission")
|
|
125
|
+
const submitBtn = form.querySelector("button[type='submit']")
|
|
126
|
+
if (submitBtn) submitBtn.disabled = true
|
|
127
|
+
|
|
128
|
+
// Optimistic UI: add a pending entry immediately
|
|
129
|
+
const pendingEl = this.#addPendingEntry(email, permission)
|
|
130
|
+
|
|
131
|
+
// Clear the email input
|
|
132
|
+
const emailInput = form.querySelector("#share-user-email")
|
|
133
|
+
if (emailInput) emailInput.value = ""
|
|
134
|
+
if (submitBtn) submitBtn.disabled = false
|
|
135
|
+
|
|
136
|
+
fetch(form.action, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: {
|
|
139
|
+
"Accept": "application/json",
|
|
140
|
+
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content
|
|
141
|
+
},
|
|
142
|
+
body: formData
|
|
143
|
+
})
|
|
144
|
+
.then(r => r.json().then(data => ({ status: r.status, data })))
|
|
145
|
+
.then(({ status, data }) => {
|
|
146
|
+
if (pendingEl) pendingEl.remove()
|
|
147
|
+
|
|
148
|
+
if (status >= 200 && status < 300) {
|
|
149
|
+
this.#showMessage(data.notice, "success")
|
|
150
|
+
this.#debouncedRefresh()
|
|
151
|
+
} else {
|
|
152
|
+
this.#showMessage(data.error, "error")
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.catch(() => {
|
|
156
|
+
if (pendingEl) pendingEl.remove()
|
|
157
|
+
this.#showMessage(this.#errorFallbackMessage, "error")
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#addPendingEntry(email, permission) {
|
|
163
|
+
if (!email) return null
|
|
164
|
+
const modal = document.getElementById("share-creative-modal")
|
|
165
|
+
if (!modal) return null
|
|
166
|
+
|
|
167
|
+
let listSection = modal.querySelector(".share-grid")
|
|
168
|
+
if (!listSection) {
|
|
169
|
+
const popupBox = modal.querySelector(".popup-box")
|
|
170
|
+
if (!popupBox) return null
|
|
171
|
+
const section = document.createElement("div")
|
|
172
|
+
section.style.marginTop = "1em"
|
|
173
|
+
const strong = document.createElement("strong")
|
|
174
|
+
strong.textContent = "..."
|
|
175
|
+
section.appendChild(strong)
|
|
176
|
+
listSection = document.createElement("ul")
|
|
177
|
+
listSection.className = "share-grid"
|
|
178
|
+
section.appendChild(listSection)
|
|
179
|
+
popupBox.appendChild(section)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const li = document.createElement("li")
|
|
183
|
+
li.className = "share-modal-pending"
|
|
184
|
+
|
|
185
|
+
// Build DOM elements instead of innerHTML to avoid XSS
|
|
186
|
+
const avatarSpan = document.createElement("span")
|
|
187
|
+
const avatar = document.createElement("span")
|
|
188
|
+
avatar.className = "avatar share-avatar"
|
|
189
|
+
Object.assign(avatar.style, {
|
|
190
|
+
display: "inline-block", width: "20px", height: "20px",
|
|
191
|
+
borderRadius: "50%", background: "var(--surface-3,#ddd)",
|
|
192
|
+
textAlign: "center", lineHeight: "20px", fontSize: "10px"
|
|
193
|
+
})
|
|
194
|
+
avatar.textContent = email[0].toUpperCase()
|
|
195
|
+
avatarSpan.appendChild(avatar)
|
|
196
|
+
|
|
197
|
+
const emailSpan = document.createElement("span")
|
|
198
|
+
emailSpan.textContent = email
|
|
199
|
+
|
|
200
|
+
const permSpan = document.createElement("span")
|
|
201
|
+
permSpan.style.opacity = "0.5"
|
|
202
|
+
permSpan.textContent = permission
|
|
203
|
+
|
|
204
|
+
const spinnerSpan = document.createElement("span")
|
|
205
|
+
const spinner = document.createElement("span")
|
|
206
|
+
spinner.className = "share-modal-spinner"
|
|
207
|
+
spinnerSpan.appendChild(spinner)
|
|
208
|
+
|
|
209
|
+
li.append(avatarSpan, emailSpan, permSpan, spinnerSpan)
|
|
210
|
+
listSection.appendChild(li)
|
|
211
|
+
return li
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#initializeDeleteButtons() {
|
|
215
|
+
const modal = document.getElementById("share-creative-modal")
|
|
216
|
+
if (!modal) return
|
|
217
|
+
|
|
218
|
+
const deleteForms = modal.querySelectorAll("form")
|
|
219
|
+
deleteForms.forEach(form => {
|
|
220
|
+
const methodInput = form.querySelector("input[name='_method'][value='delete']")
|
|
221
|
+
if (!methodInput) return
|
|
222
|
+
|
|
223
|
+
form.addEventListener("submit", (e) => {
|
|
224
|
+
e.preventDefault()
|
|
225
|
+
|
|
226
|
+
const confirmMessage = form.dataset.turboConfirm
|
|
227
|
+
|| form.querySelector("button[type='submit']")?.dataset?.turboConfirm
|
|
228
|
+
|| form.querySelector("button")?.dataset?.confirm
|
|
229
|
+
if (confirmMessage && !window.confirm(confirmMessage)) return
|
|
230
|
+
|
|
231
|
+
const listItem = form.closest("li")
|
|
232
|
+
if (listItem) {
|
|
233
|
+
listItem.style.opacity = "0.3"
|
|
234
|
+
listItem.style.pointerEvents = "none"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fetch(form.action, {
|
|
238
|
+
method: "DELETE",
|
|
239
|
+
headers: {
|
|
240
|
+
"Accept": "application/json",
|
|
241
|
+
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
.then(r => {
|
|
245
|
+
if (r.ok) {
|
|
246
|
+
if (listItem) listItem.remove()
|
|
247
|
+
this.#debouncedRefresh()
|
|
248
|
+
} else {
|
|
249
|
+
if (listItem) {
|
|
250
|
+
listItem.style.opacity = "1"
|
|
251
|
+
listItem.style.pointerEvents = ""
|
|
252
|
+
}
|
|
253
|
+
return r.json().then(data => {
|
|
254
|
+
this.#showMessage(data.error, "error")
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
.catch(() => {
|
|
259
|
+
if (listItem) {
|
|
260
|
+
listItem.style.opacity = "1"
|
|
261
|
+
listItem.style.pointerEvents = ""
|
|
262
|
+
}
|
|
263
|
+
this.#showMessage(this.#errorFallbackMessage, "error")
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#debouncedRefresh() {
|
|
270
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
|
271
|
+
this.refreshTimer = setTimeout(() => this.#refreshModal(), 300)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#refreshModal() {
|
|
275
|
+
if (!this.currentSharesUrl) return
|
|
276
|
+
|
|
277
|
+
fetch(this.currentSharesUrl, {
|
|
278
|
+
headers: { "Accept": "text/html" }
|
|
279
|
+
})
|
|
280
|
+
.then(r => r.text())
|
|
281
|
+
.then(html => {
|
|
282
|
+
this.container.innerHTML = html
|
|
283
|
+
this.#initializeModal()
|
|
284
|
+
})
|
|
285
|
+
.catch(err => {
|
|
286
|
+
console.error("share-modal: Failed to refresh modal", err)
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#showMessage(text, type) {
|
|
291
|
+
if (!text) return
|
|
292
|
+
const modal = document.getElementById("share-creative-modal")
|
|
293
|
+
if (!modal) return
|
|
294
|
+
|
|
295
|
+
const existing = modal.querySelector(".share-modal-message")
|
|
296
|
+
if (existing) existing.remove()
|
|
297
|
+
|
|
298
|
+
const msg = document.createElement("div")
|
|
299
|
+
msg.className = `share-modal-message share-modal-message-${type}`
|
|
300
|
+
msg.textContent = text
|
|
301
|
+
|
|
302
|
+
const title = modal.querySelector("h2")
|
|
303
|
+
if (title) {
|
|
304
|
+
title.insertAdjacentElement("afterend", msg)
|
|
305
|
+
} else {
|
|
306
|
+
modal.querySelector(".popup-box")?.prepend(msg)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
setTimeout(() => msg.remove(), 4000)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#initializeInviteLink() {
|
|
313
|
+
const inviteLinkBtn = document.getElementById("creative-invite-link")
|
|
314
|
+
if (!inviteLinkBtn) return
|
|
315
|
+
|
|
316
|
+
inviteLinkBtn.onclick = () => {
|
|
317
|
+
const creativeId = inviteLinkBtn.dataset.creativeId
|
|
318
|
+
const permissionSelect = document.getElementById("share-permission")
|
|
319
|
+
const permission = permissionSelect ? permissionSelect.value : "read"
|
|
320
|
+
const permissionLabel = permissionSelect ? permissionSelect.options[permissionSelect.selectedIndex].text : ""
|
|
321
|
+
const noAccessMessage = inviteLinkBtn.dataset.noAccessMessage
|
|
322
|
+
const copiedTemplate = inviteLinkBtn.dataset.copiedTemplate
|
|
323
|
+
|
|
324
|
+
if (permission === "no_access") {
|
|
325
|
+
alert(noAccessMessage)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const emailInput = document.getElementById("share-user-email")
|
|
330
|
+
const email = emailInput ? emailInput.value.trim() : ""
|
|
331
|
+
|
|
332
|
+
fetch("/invite", {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: {
|
|
335
|
+
"Content-Type": "application/json",
|
|
336
|
+
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content
|
|
337
|
+
},
|
|
338
|
+
body: JSON.stringify({ creative_id: creativeId, permission, email: email || undefined })
|
|
339
|
+
})
|
|
340
|
+
.then(r => r.json())
|
|
341
|
+
.then(data => {
|
|
342
|
+
const plan42Copy = window.Plan42 && window.Plan42.copyTextToClipboard
|
|
343
|
+
let copyPromise = null
|
|
344
|
+
if (plan42Copy) {
|
|
345
|
+
copyPromise = plan42Copy(data.url)
|
|
346
|
+
} else if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
347
|
+
copyPromise = navigator.clipboard.writeText(data.url)
|
|
348
|
+
}
|
|
349
|
+
if (copyPromise) {
|
|
350
|
+
copyPromise.then(() => {
|
|
351
|
+
this.#showMessage(copiedTemplate.replace("__PERMISSION__", permissionLabel), "success")
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
// Refresh modal to show the new invitation in the list
|
|
355
|
+
this.#debouncedRefresh()
|
|
356
|
+
})
|
|
357
|
+
.catch(err => {
|
|
358
|
+
console.error("Failed to create invite link", err)
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#dispatchEvent(eventName) {
|
|
364
|
+
this.element.dispatchEvent(new CustomEvent(eventName, {
|
|
365
|
+
bubbles: true,
|
|
366
|
+
detail: {}
|
|
367
|
+
}))
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import CommonPopupController from './common_popup_controller'
|
|
2
|
+
|
|
3
|
+
export default class extends CommonPopupController {
|
|
4
|
+
static targets = ['input', 'list', 'close']
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
super.connect()
|
|
8
|
+
this._debounceTimer = null
|
|
9
|
+
this._allTopics = []
|
|
10
|
+
this.inputTarget.addEventListener('input', this._onInput.bind(this))
|
|
11
|
+
this.inputTarget.addEventListener('keydown', this.handleInputKeydown.bind(this))
|
|
12
|
+
this.closeTarget.addEventListener('click', () => this.close())
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
disconnect() {
|
|
16
|
+
if (this._debounceTimer) {
|
|
17
|
+
clearTimeout(this._debounceTimer)
|
|
18
|
+
this._debounceTimer = null
|
|
19
|
+
}
|
|
20
|
+
super.disconnect()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
openForCreative(creativeId, anchorRect, onSelectCallback, mainLabel = 'Main') {
|
|
24
|
+
this.onSelectCallback = onSelectCallback
|
|
25
|
+
this._allTopics = []
|
|
26
|
+
this.setItems([])
|
|
27
|
+
this.inputTarget.value = ''
|
|
28
|
+
super.open(anchorRect)
|
|
29
|
+
|
|
30
|
+
requestAnimationFrame(() => {
|
|
31
|
+
this.inputTarget.focus()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
this._loadTopics(creativeId, mainLabel)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
close() {
|
|
38
|
+
if (this._debounceTimer) {
|
|
39
|
+
clearTimeout(this._debounceTimer)
|
|
40
|
+
this._debounceTimer = null
|
|
41
|
+
}
|
|
42
|
+
super.close()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
handleInputKeydown(event) {
|
|
46
|
+
if (this.handleKey(event)) return
|
|
47
|
+
if (event.key === 'Escape') {
|
|
48
|
+
this.close()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_onInput() {
|
|
53
|
+
const q = this.inputTarget.value.toLowerCase()
|
|
54
|
+
if (!q) {
|
|
55
|
+
this.setItems(this._allTopics)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const filtered = this._allTopics.filter(t =>
|
|
59
|
+
(t.label || '').toLowerCase().includes(q)
|
|
60
|
+
)
|
|
61
|
+
this.setItems(filtered)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _loadTopics(creativeId, mainLabel) {
|
|
65
|
+
if (!creativeId) return
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`/creatives/${creativeId}/topics`, {
|
|
68
|
+
headers: { Accept: 'application/json' }
|
|
69
|
+
})
|
|
70
|
+
const data = await response.json()
|
|
71
|
+
const topics = data.topics || []
|
|
72
|
+
|
|
73
|
+
this._allTopics = [
|
|
74
|
+
{ id: '', label: `📋 ${mainLabel}` },
|
|
75
|
+
...topics.map(t => ({ id: t.id, label: `#${t.name}` }))
|
|
76
|
+
]
|
|
77
|
+
this.setItems(this._allTopics)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error('Error loading topics:', error)
|
|
80
|
+
this.setItems([])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
select(item) {
|
|
85
|
+
if (this.onSelectCallback) {
|
|
86
|
+
this.onSelectCallback(item)
|
|
87
|
+
}
|
|
88
|
+
this.close()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
renderItem(item) {
|
|
92
|
+
const text = item.label || ''
|
|
93
|
+
return text
|
|
94
|
+
.replace(/&/g, "&")
|
|
95
|
+
.replace(/</g, "<")
|
|
96
|
+
.replace(/>/g, ">")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
dispatchClose(reason) {
|
|
100
|
+
this.onSelectCallback = null
|
|
101
|
+
super.dispatchClose(reason)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
hasDraggedState,
|
|
25
25
|
} from './state';
|
|
26
26
|
import { createMoveContext, applyMove, revertMove } from './operations';
|
|
27
|
-
import { sendNewOrder, sendLinkedCreative } from '../../lib/api/drag_drop';
|
|
27
|
+
import { sendNewOrder, sendLinkedCreative, sendTopicMove } from '../../lib/api/drag_drop';
|
|
28
28
|
import { initIndicator, showLinkHover, hideLinkHover } from './indicator';
|
|
29
29
|
|
|
30
30
|
const childZoneRatio = 0.3;
|
|
@@ -541,6 +541,17 @@ export function handleDragOver(event) {
|
|
|
541
541
|
clearDragHighlight(lastRow);
|
|
542
542
|
}
|
|
543
543
|
if (!tree || tree.draggable === false) return;
|
|
544
|
+
|
|
545
|
+
// Topic move drag: always show as child drop target
|
|
546
|
+
if (event.dataTransfer.types.includes('application/x-topic-move')) {
|
|
547
|
+
event.preventDefault();
|
|
548
|
+
event.dataTransfer.dropEffect = 'move';
|
|
549
|
+
tree.classList.add('drag-over', 'drag-over-child', 'child-drop-indicator-active');
|
|
550
|
+
tree.classList.remove('drag-over-top', 'drag-over-bottom');
|
|
551
|
+
setLastDragOverRow(tree);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
544
555
|
event.preventDefault();
|
|
545
556
|
event.dataTransfer.dropEffect = 'move';
|
|
546
557
|
|
|
@@ -600,6 +611,36 @@ export function handleDrop(event) {
|
|
|
600
611
|
const targetTree = event.target.closest(DRAGGABLE_SELECTOR);
|
|
601
612
|
const targetId = targetTree ? targetTree.id : '';
|
|
602
613
|
|
|
614
|
+
// Handle topic move drop
|
|
615
|
+
const topicMoveData = event.dataTransfer.getData('application/x-topic-move');
|
|
616
|
+
if (topicMoveData && targetTree) {
|
|
617
|
+
event.preventDefault();
|
|
618
|
+
clearDragHighlight(targetTree);
|
|
619
|
+
clearDragHighlight(getLastDragOverRow());
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const { topicId, sourceCreativeId } = JSON.parse(topicMoveData);
|
|
623
|
+
const targetCreativeId = targetId.replace('creative-', '');
|
|
624
|
+
|
|
625
|
+
if (sourceCreativeId === targetCreativeId) return;
|
|
626
|
+
|
|
627
|
+
sendTopicMove({ topicId, sourceCreativeId, targetCreativeId })
|
|
628
|
+
.then(() => {
|
|
629
|
+
// Dispatch event so topic list refreshes
|
|
630
|
+
window.dispatchEvent(new CustomEvent('collavre:topic-moved', {
|
|
631
|
+
detail: { topicId, sourceCreativeId, targetCreativeId }
|
|
632
|
+
}));
|
|
633
|
+
})
|
|
634
|
+
.catch((error) => {
|
|
635
|
+
console.error('Failed to move topic', error);
|
|
636
|
+
alert(error.message || 'Failed to move topic');
|
|
637
|
+
});
|
|
638
|
+
} catch (error) {
|
|
639
|
+
console.error('Failed to parse topic move data', error);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
603
644
|
// Capture visual state before clearing highlights to ensure WYSIWYG
|
|
604
645
|
const isVisualTop = targetTree && targetTree.classList.contains('drag-over-top');
|
|
605
646
|
const isVisualBottom = targetTree && targetTree.classList.contains('drag-over-bottom');
|
|
@@ -65,6 +65,17 @@ export function unconvert(id) {
|
|
|
65
65
|
})
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function updateMetadata(id, data) {
|
|
69
|
+
const body = new FormData()
|
|
70
|
+
body.append('data', JSON.stringify(data))
|
|
71
|
+
|
|
72
|
+
return csrfFetch(`/creatives/${id}/update_metadata`, {
|
|
73
|
+
method: 'PATCH',
|
|
74
|
+
headers: JSON_HEADERS,
|
|
75
|
+
body,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
const creativesApi = {
|
|
69
80
|
get,
|
|
70
81
|
parentSuggestions,
|
|
@@ -74,6 +85,7 @@ const creativesApi = {
|
|
|
74
85
|
linkExisting,
|
|
75
86
|
destroy,
|
|
76
87
|
unconvert,
|
|
88
|
+
updateMetadata,
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
export default creativesApi
|
|
@@ -5,6 +5,38 @@ function readCsrfToken() {
|
|
|
5
5
|
return document.querySelector('meta[name="csrf-token"]')?.content || null
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Update the page's CSRF meta tag with a fresh token from the server
|
|
10
|
+
* response header. This keeps the client-side token in sync when the
|
|
11
|
+
* session cookie has been re-encrypted (e.g. after the browser was in
|
|
12
|
+
* the background and a GET request refreshed the cookie).
|
|
13
|
+
*/
|
|
14
|
+
export function updateCsrfTokenFromResponse(response) {
|
|
15
|
+
if (typeof document === 'undefined') return
|
|
16
|
+
const freshToken = response.headers.get('X-CSRF-Token')
|
|
17
|
+
if (!freshToken) return
|
|
18
|
+
const meta = document.querySelector('meta[name="csrf-token"]')
|
|
19
|
+
if (meta) meta.content = freshToken
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a fresh CSRF token from the server. Uses a lightweight HEAD
|
|
24
|
+
* request to the current page — the response body is discarded but the
|
|
25
|
+
* X-CSRF-Token header gives us a valid token.
|
|
26
|
+
*/
|
|
27
|
+
export async function refreshCsrfToken() {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(window.location.href, {
|
|
30
|
+
method: 'HEAD',
|
|
31
|
+
credentials: 'same-origin',
|
|
32
|
+
})
|
|
33
|
+
updateCsrfTokenFromResponse(response)
|
|
34
|
+
return readCsrfToken()
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
8
40
|
export default function csrfFetch(input, options = {}) {
|
|
9
41
|
const { headers: incomingHeaders, credentials, ...rest } = options
|
|
10
42
|
const headers = new Headers(incomingHeaders || undefined)
|
|
@@ -18,5 +50,8 @@ export default function csrfFetch(input, options = {}) {
|
|
|
18
50
|
credentials: credentials ?? DEFAULT_CREDENTIALS,
|
|
19
51
|
headers,
|
|
20
52
|
...rest,
|
|
53
|
+
}).then((response) => {
|
|
54
|
+
updateCsrfTokenFromResponse(response)
|
|
55
|
+
return response
|
|
21
56
|
})
|
|
22
57
|
}
|
|
@@ -17,6 +17,23 @@ export function sendNewOrder({ draggedId, draggedIds, targetId, direction }) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function sendTopicMove({ topicId, sourceCreativeId, targetCreativeId }) {
|
|
21
|
+
return csrfFetch(`/creatives/${sourceCreativeId}/topics/${topicId}/move`, {
|
|
22
|
+
method: 'PATCH',
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ target_creative_id: targetCreativeId }),
|
|
27
|
+
}).then((response) => {
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
return response.json().then((data) => {
|
|
30
|
+
throw new Error(data.error || 'Failed to move topic');
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return response.json();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
export function sendLinkedCreative({ draggedId, targetId, direction }) {
|
|
21
38
|
return csrfFetch('/creatives/link_drop', {
|
|
22
39
|
method: 'POST',
|
|
@@ -35,6 +35,11 @@ if (!commandMenuInitialized) {
|
|
|
35
35
|
`
|
|
36
36
|
},
|
|
37
37
|
onSelect: (command) => {
|
|
38
|
+
if (command.type === 'popup' && command.popup_type === 'creative_picker') {
|
|
39
|
+
openCreativePicker(textarea)
|
|
40
|
+
popupMenu.hide()
|
|
41
|
+
return
|
|
42
|
+
}
|
|
38
43
|
insert(command)
|
|
39
44
|
popupMenu.hide()
|
|
40
45
|
textarea.focus()
|
|
@@ -92,6 +97,41 @@ if (!commandMenuInitialized) {
|
|
|
92
97
|
popupMenu.showAt(caretRect)
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
function openCreativePicker(textarea) {
|
|
101
|
+
const modal = document.getElementById('link-creative-modal')
|
|
102
|
+
if (!modal) return
|
|
103
|
+
|
|
104
|
+
const controller = window.Stimulus?.getControllerForElementAndIdentifier(modal, 'link-creative')
|
|
105
|
+
if (!controller) return
|
|
106
|
+
|
|
107
|
+
// Clear the command text (e.g. "/crea", "/creative") from textarea
|
|
108
|
+
const pos = textarea.selectionStart
|
|
109
|
+
const before = textarea.value.slice(0, pos)
|
|
110
|
+
const after = textarea.value.slice(pos)
|
|
111
|
+
const cleaned = before.replace(/^\/\S*\s*/, '')
|
|
112
|
+
textarea.value = cleaned + after
|
|
113
|
+
textarea.setSelectionRange(cleaned.length, cleaned.length)
|
|
114
|
+
|
|
115
|
+
const caretRect = getCaretClientRect(textarea) || textarea.getBoundingClientRect()
|
|
116
|
+
controller.open(
|
|
117
|
+
caretRect,
|
|
118
|
+
(item) => {
|
|
119
|
+
// Insert markdown link at cursor position
|
|
120
|
+
const link = `[${item.label}](/creatives/${item.id})`
|
|
121
|
+
const curPos = textarea.selectionStart
|
|
122
|
+
const beforeCur = textarea.value.slice(0, curPos)
|
|
123
|
+
const afterCur = textarea.value.slice(curPos)
|
|
124
|
+
textarea.value = beforeCur + link + ' ' + afterCur
|
|
125
|
+
const newPos = curPos + link.length + 1
|
|
126
|
+
textarea.setSelectionRange(newPos, newPos)
|
|
127
|
+
textarea.focus()
|
|
128
|
+
},
|
|
129
|
+
() => {
|
|
130
|
+
textarea.focus()
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
95
135
|
textarea.addEventListener('keydown', function (event) {
|
|
96
136
|
if (popupMenu.handleKey(event)) return
|
|
97
137
|
})
|