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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. 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, "&amp;")
95
+ .replace(/</g, "&lt;")
96
+ .replace(/>/g, "&gt;")
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
  })