collavre 0.1.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 (410) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +221 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/stylesheets/collavre/actiontext.css +577 -0
  5. data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
  7. data/app/assets/stylesheets/collavre/creatives.css +559 -0
  8. data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
  9. data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
  10. data/app/assets/stylesheets/collavre/popup.css +160 -0
  11. data/app/assets/stylesheets/collavre/print.css +37 -0
  12. data/app/assets/stylesheets/collavre/slide_view.css +79 -0
  13. data/app/assets/stylesheets/collavre/user_menu.css +34 -0
  14. data/app/channels/collavre/comments_presence_channel.rb +54 -0
  15. data/app/channels/collavre/slide_view_channel.rb +11 -0
  16. data/app/channels/collavre/topics_channel.rb +12 -0
  17. data/app/components/collavre/avatar_component.html.erb +15 -0
  18. data/app/components/collavre/avatar_component.rb +59 -0
  19. data/app/components/collavre/inbox/badge_component.html.erb +6 -0
  20. data/app/components/collavre/inbox/badge_component.rb +18 -0
  21. data/app/components/collavre/plans_timeline_component.html.erb +14 -0
  22. data/app/components/collavre/plans_timeline_component.rb +56 -0
  23. data/app/components/collavre/popup_menu_component.html.erb +6 -0
  24. data/app/components/collavre/popup_menu_component.rb +30 -0
  25. data/app/components/collavre/progress_filter_component.html.erb +5 -0
  26. data/app/components/collavre/progress_filter_component.rb +10 -0
  27. data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
  28. data/app/components/collavre/user_mention_menu_component.rb +8 -0
  29. data/app/controllers/collavre/application_controller.rb +15 -0
  30. data/app/controllers/collavre/attachments_controller.rb +44 -0
  31. data/app/controllers/collavre/calendar_events_controller.rb +15 -0
  32. data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
  33. data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
  34. data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
  35. data/app/controllers/collavre/comments_controller.rb +464 -0
  36. data/app/controllers/collavre/contacts_controller.rb +10 -0
  37. data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
  38. data/app/controllers/collavre/creative_imports_controller.rb +24 -0
  39. data/app/controllers/collavre/creative_plans_controller.rb +69 -0
  40. data/app/controllers/collavre/creative_shares_controller.rb +79 -0
  41. data/app/controllers/collavre/creatives_controller.rb +535 -0
  42. data/app/controllers/collavre/devices_controller.rb +19 -0
  43. data/app/controllers/collavre/email_verifications_controller.rb +16 -0
  44. data/app/controllers/collavre/emails_controller.rb +11 -0
  45. data/app/controllers/collavre/github_auth_controller.rb +25 -0
  46. data/app/controllers/collavre/google_auth_controller.rb +43 -0
  47. data/app/controllers/collavre/inbox_items_controller.rb +64 -0
  48. data/app/controllers/collavre/invites_controller.rb +27 -0
  49. data/app/controllers/collavre/notion_auth_controller.rb +25 -0
  50. data/app/controllers/collavre/passwords_controller.rb +37 -0
  51. data/app/controllers/collavre/plans_controller.rb +110 -0
  52. data/app/controllers/collavre/sessions_controller.rb +57 -0
  53. data/app/controllers/collavre/topics_controller.rb +58 -0
  54. data/app/controllers/collavre/user_themes_controller.rb +58 -0
  55. data/app/controllers/collavre/users_controller.rb +390 -0
  56. data/app/helpers/collavre/application_helper.rb +4 -0
  57. data/app/helpers/collavre/comments_helper.rb +9 -0
  58. data/app/helpers/collavre/creatives_helper.rb +343 -0
  59. data/app/helpers/collavre/navigation_helper.rb +163 -0
  60. data/app/helpers/collavre/user_themes_helper.rb +4 -0
  61. data/app/javascript/collavre.js +26 -0
  62. data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
  63. data/app/javascript/components/LinkPopup.jsx +112 -0
  64. data/app/javascript/components/creative_tree_row.js +503 -0
  65. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
  66. data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
  67. data/app/javascript/controllers/click_target_controller.js +13 -0
  68. data/app/javascript/controllers/comment_controller.js +162 -0
  69. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
  70. data/app/javascript/controllers/comments/form_controller.js +530 -0
  71. data/app/javascript/controllers/comments/list_controller.js +715 -0
  72. data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
  73. data/app/javascript/controllers/comments/popup_controller.js +385 -0
  74. data/app/javascript/controllers/comments/presence_controller.js +311 -0
  75. data/app/javascript/controllers/comments/topics_controller.js +338 -0
  76. data/app/javascript/controllers/common_popup_controller.js +55 -0
  77. data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
  78. data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
  79. data/app/javascript/controllers/creatives/import_controller.js +116 -0
  80. data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
  81. data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
  82. data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
  83. data/app/javascript/controllers/creatives/tree_controller.js +218 -0
  84. data/app/javascript/controllers/index.js +79 -0
  85. data/app/javascript/controllers/link_creative_controller.js +91 -0
  86. data/app/javascript/controllers/popup_menu_controller.js +82 -0
  87. data/app/javascript/controllers/progress_filter_controller.js +35 -0
  88. data/app/javascript/controllers/reaction_picker_controller.js +107 -0
  89. data/app/javascript/controllers/share_invite_controller.js +15 -0
  90. data/app/javascript/controllers/share_user_search_controller.js +121 -0
  91. data/app/javascript/controllers/tabs_controller.js +43 -0
  92. data/app/javascript/creatives/drag_drop/dom.js +170 -0
  93. data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
  94. data/app/javascript/creatives/drag_drop/indicator.js +35 -0
  95. data/app/javascript/creatives/drag_drop/operations.js +116 -0
  96. data/app/javascript/creatives/drag_drop/state.js +31 -0
  97. data/app/javascript/creatives/tree_renderer.js +248 -0
  98. data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
  99. data/app/javascript/lib/api/creatives.js +79 -0
  100. data/app/javascript/lib/api/csrf_fetch.js +22 -0
  101. data/app/javascript/lib/api/drag_drop.js +31 -0
  102. data/app/javascript/lib/api/queue_manager.js +423 -0
  103. data/app/javascript/lib/apply_lexical_styles.js +15 -0
  104. data/app/javascript/lib/common_popup.js +195 -0
  105. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
  106. data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
  107. data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
  108. data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
  109. data/app/javascript/lib/lexical/attachment_payload.js +293 -0
  110. data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
  111. data/app/javascript/lib/lexical/image_node.jsx +159 -0
  112. data/app/javascript/lib/lexical/style_attributes.js +40 -0
  113. data/app/javascript/lib/responsive_images.js +54 -0
  114. data/app/javascript/lib/turbo_stream_actions.js +33 -0
  115. data/app/javascript/lib/utils/markdown.js +23 -0
  116. data/app/javascript/modules/creative_guide.js +53 -0
  117. data/app/javascript/modules/creative_row_editor.js +1841 -0
  118. data/app/javascript/modules/creative_row_swipe.js +43 -0
  119. data/app/javascript/modules/creatives.js +15 -0
  120. data/app/javascript/modules/export_to_markdown.js +34 -0
  121. data/app/javascript/modules/inbox_panel.js +226 -0
  122. data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
  123. data/app/javascript/modules/mention_menu.js +77 -0
  124. data/app/javascript/modules/plans_menu.js +39 -0
  125. data/app/javascript/modules/plans_timeline.js +397 -0
  126. data/app/javascript/modules/share_modal.js +73 -0
  127. data/app/javascript/modules/share_user_popup.js +77 -0
  128. data/app/javascript/modules/slide_view.js +163 -0
  129. data/app/javascript/services/cable.js +32 -0
  130. data/app/javascript/slide_view.js +2 -0
  131. data/app/javascript/utils/caret_position.js +42 -0
  132. data/app/javascript/utils/clipboard.js +40 -0
  133. data/app/jobs/collavre/ai_agent_job.rb +27 -0
  134. data/app/jobs/collavre/inbox_summary_job.rb +24 -0
  135. data/app/jobs/collavre/notion_export_job.rb +30 -0
  136. data/app/jobs/collavre/notion_sync_job.rb +48 -0
  137. data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
  138. data/app/jobs/collavre/permission_cache_job.rb +71 -0
  139. data/app/jobs/collavre/push_notification_job.rb +86 -0
  140. data/app/mailers/collavre/application_mailer.rb +17 -0
  141. data/app/mailers/collavre/creative_mailer.rb +9 -0
  142. data/app/mailers/collavre/email_verification_mailer.rb +20 -0
  143. data/app/mailers/collavre/inbox_mailer.rb +19 -0
  144. data/app/mailers/collavre/invitation_mailer.rb +16 -0
  145. data/app/mailers/collavre/passwords_mailer.rb +10 -0
  146. data/app/models/collavre/activity_log.rb +13 -0
  147. data/app/models/collavre/application_record.rb +5 -0
  148. data/app/models/collavre/calendar_event.rb +20 -0
  149. data/app/models/collavre/comment.rb +307 -0
  150. data/app/models/collavre/comment_presence_store.rb +30 -0
  151. data/app/models/collavre/comment_reaction.rb +11 -0
  152. data/app/models/collavre/comment_read_pointer.rb +26 -0
  153. data/app/models/collavre/contact.rb +23 -0
  154. data/app/models/collavre/creative.rb +413 -0
  155. data/app/models/collavre/creative_expanded_state.rb +11 -0
  156. data/app/models/collavre/creative_share.rb +122 -0
  157. data/app/models/collavre/creative_shares_cache.rb +18 -0
  158. data/app/models/collavre/current.rb +14 -0
  159. data/app/models/collavre/device.rb +13 -0
  160. data/app/models/collavre/email.rb +14 -0
  161. data/app/models/collavre/github_account.rb +10 -0
  162. data/app/models/collavre/github_repository_link.rb +19 -0
  163. data/app/models/collavre/inbox_item.rb +95 -0
  164. data/app/models/collavre/invitation.rb +22 -0
  165. data/app/models/collavre/label.rb +47 -0
  166. data/app/models/collavre/mcp_tool.rb +30 -0
  167. data/app/models/collavre/notion_account.rb +17 -0
  168. data/app/models/collavre/notion_block_link.rb +10 -0
  169. data/app/models/collavre/notion_page_link.rb +19 -0
  170. data/app/models/collavre/plan.rb +20 -0
  171. data/app/models/collavre/session.rb +24 -0
  172. data/app/models/collavre/system_setting.rb +144 -0
  173. data/app/models/collavre/tag.rb +10 -0
  174. data/app/models/collavre/task.rb +10 -0
  175. data/app/models/collavre/task_action.rb +10 -0
  176. data/app/models/collavre/topic.rb +12 -0
  177. data/app/models/collavre/user.rb +174 -0
  178. data/app/models/collavre/user_theme.rb +10 -0
  179. data/app/models/collavre/variation.rb +5 -0
  180. data/app/models/collavre/webauthn_credential.rb +11 -0
  181. data/app/services/collavre/ai_agent_service.rb +193 -0
  182. data/app/services/collavre/ai_client.rb +183 -0
  183. data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
  184. data/app/services/collavre/auto_theme_generator.rb +198 -0
  185. data/app/services/collavre/comment_link_formatter.rb +60 -0
  186. data/app/services/collavre/comments/action_executor.rb +262 -0
  187. data/app/services/collavre/comments/action_validator.rb +58 -0
  188. data/app/services/collavre/comments/calendar_command.rb +97 -0
  189. data/app/services/collavre/comments/command_processor.rb +37 -0
  190. data/app/services/collavre/comments/mcp_command.rb +109 -0
  191. data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
  192. data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
  193. data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
  194. data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
  195. data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
  196. data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
  197. data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
  198. data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
  199. data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
  200. data/app/services/collavre/creatives/importer.rb +47 -0
  201. data/app/services/collavre/creatives/index_query.rb +191 -0
  202. data/app/services/collavre/creatives/path_exporter.rb +131 -0
  203. data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
  204. data/app/services/collavre/creatives/permission_checker.rb +42 -0
  205. data/app/services/collavre/creatives/plan_tagger.rb +53 -0
  206. data/app/services/collavre/creatives/progress_service.rb +89 -0
  207. data/app/services/collavre/creatives/reorderer.rb +187 -0
  208. data/app/services/collavre/creatives/tree_builder.rb +231 -0
  209. data/app/services/collavre/creatives/tree_formatter.rb +36 -0
  210. data/app/services/collavre/gemini_parent_recommender.rb +77 -0
  211. data/app/services/collavre/github/client.rb +112 -0
  212. data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
  213. data/app/services/collavre/github/pull_request_processor.rb +181 -0
  214. data/app/services/collavre/github/webhook_provisioner.rb +130 -0
  215. data/app/services/collavre/google_calendar_service.rb +149 -0
  216. data/app/services/collavre/link_preview_fetcher.rb +230 -0
  217. data/app/services/collavre/markdown_importer.rb +202 -0
  218. data/app/services/collavre/mcp_service.rb +217 -0
  219. data/app/services/collavre/notion_client.rb +231 -0
  220. data/app/services/collavre/notion_creative_exporter.rb +296 -0
  221. data/app/services/collavre/notion_service.rb +249 -0
  222. data/app/services/collavre/ppt_importer.rb +76 -0
  223. data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
  224. data/app/services/collavre/system_events/context_builder.rb +41 -0
  225. data/app/services/collavre/system_events/dispatcher.rb +19 -0
  226. data/app/services/collavre/system_events/router.rb +72 -0
  227. data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
  228. data/app/views/admin/shared/_tabs.html.erb +4 -0
  229. data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
  230. data/app/views/collavre/comments/_comment.html.erb +147 -0
  231. data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
  232. data/app/views/collavre/comments/_list.html.erb +10 -0
  233. data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
  234. data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
  235. data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
  236. data/app/views/collavre/creatives/_add_button.html.erb +20 -0
  237. data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
  238. data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
  239. data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
  240. data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
  241. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
  242. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
  243. data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
  244. data/app/views/collavre/creatives/_share_button.html.erb +55 -0
  245. data/app/views/collavre/creatives/edit.html.erb +4 -0
  246. data/app/views/collavre/creatives/index.html.erb +192 -0
  247. data/app/views/collavre/creatives/new.html.erb +4 -0
  248. data/app/views/collavre/creatives/show.html.erb +29 -0
  249. data/app/views/collavre/creatives/slide_view.html.erb +20 -0
  250. data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
  251. data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
  252. data/app/views/collavre/emails/index.html.erb +19 -0
  253. data/app/views/collavre/emails/show.html.erb +5 -0
  254. data/app/views/collavre/inbox_items/_item.html.erb +18 -0
  255. data/app/views/collavre/inbox_items/_items.html.erb +3 -0
  256. data/app/views/collavre/inbox_items/_list.html.erb +7 -0
  257. data/app/views/collavre/inbox_items/index.html.erb +1 -0
  258. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
  259. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
  260. data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
  261. data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
  262. data/app/views/collavre/invites/show.html.erb +8 -0
  263. data/app/views/collavre/passwords/edit.html.erb +12 -0
  264. data/app/views/collavre/passwords/new.html.erb +17 -0
  265. data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
  266. data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
  267. data/app/views/collavre/sessions/new.html.erb +27 -0
  268. data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
  269. data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
  270. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
  271. data/app/views/collavre/shared/_navigation.html.erb +37 -0
  272. data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
  273. data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
  274. data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
  275. data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
  276. data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
  277. data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
  278. data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
  279. data/app/views/collavre/user_themes/index.html.erb +75 -0
  280. data/app/views/collavre/users/_app_header.html.erb +5 -0
  281. data/app/views/collavre/users/_contact_management.html.erb +77 -0
  282. data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
  283. data/app/views/collavre/users/edit_ai.html.erb +79 -0
  284. data/app/views/collavre/users/edit_password.html.erb +27 -0
  285. data/app/views/collavre/users/index.html.erb +80 -0
  286. data/app/views/collavre/users/new.html.erb +33 -0
  287. data/app/views/collavre/users/new_ai.html.erb +87 -0
  288. data/app/views/collavre/users/passkeys.html.erb +43 -0
  289. data/app/views/collavre/users/show.html.erb +143 -0
  290. data/app/views/inbox/badge_component/_count.html.erb +2 -0
  291. data/app/views/layouts/collavre/slide.html.erb +29 -0
  292. data/config/locales/comments.en.yml +114 -0
  293. data/config/locales/comments.ko.yml +110 -0
  294. data/config/locales/contacts.en.yml +16 -0
  295. data/config/locales/contacts.ko.yml +16 -0
  296. data/config/locales/creatives.en.yml +183 -0
  297. data/config/locales/creatives.ko.yml +164 -0
  298. data/config/locales/invites.en.yml +19 -0
  299. data/config/locales/invites.ko.yml +17 -0
  300. data/config/locales/notifications.en.yml +8 -0
  301. data/config/locales/notifications.ko.yml +8 -0
  302. data/config/locales/plans.en.yml +12 -0
  303. data/config/locales/plans.ko.yml +19 -0
  304. data/config/locales/themes.en.yml +29 -0
  305. data/config/locales/themes.ko.yml +27 -0
  306. data/config/locales/users.en.yml +151 -0
  307. data/config/locales/users.ko.yml +146 -0
  308. data/config/routes.rb +92 -0
  309. data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
  310. data/db/migrate/20250128110017_create_creatives.rb +11 -0
  311. data/db/migrate/20250128120122_create_users.rb +11 -0
  312. data/db/migrate/20250128120123_create_sessions.rb +11 -0
  313. data/db/migrate/20250128123633_create_subscribers.rb +10 -0
  314. data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
  315. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
  316. data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
  317. data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
  318. data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
  319. data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
  320. data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
  321. data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
  322. data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
  323. data/db/migrate/20250530060200_create_tags.rb +9 -0
  324. data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
  325. data/db/migrate/20250531140142_create_labels.rb +12 -0
  326. data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
  327. data/db/migrate/20250601000000_create_comments.rb +10 -0
  328. data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
  329. data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
  330. data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
  331. data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
  332. data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
  333. data/db/migrate/20250611030138_create_invitations.rb +15 -0
  334. data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
  335. data/db/migrate/20250612150000_update_permissions.rb +43 -0
  336. data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
  337. data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
  338. data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
  339. data/db/migrate/20250620004558_create_emails.rb +13 -0
  340. data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
  341. data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
  342. data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
  343. data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
  344. data/db/migrate/20250714190000_create_devices.rb +17 -0
  345. data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
  346. data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
  347. data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
  348. data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
  349. data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
  350. data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
  351. data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
  352. data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
  353. data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
  354. data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
  355. data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
  356. data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
  357. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
  358. data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
  359. data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
  360. data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
  361. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
  362. data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
  363. data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
  364. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
  365. data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
  366. data/db/migrate/20251001000001_create_contacts.rb +71 -0
  367. data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
  368. data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
  369. data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
  370. data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
  371. data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
  372. data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
  373. data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
  374. data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
  375. data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
  376. data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
  377. data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
  378. data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
  379. data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
  380. data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
  381. data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
  382. data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
  383. data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
  384. data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
  385. data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
  386. data/db/migrate/20251223072625_create_user_themes.rb +11 -0
  387. data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
  388. data/db/migrate/20251230113607_refactor_labels.rb +38 -0
  389. data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
  390. data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
  391. data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
  392. data/db/migrate/20260106160643_create_system_settings.rb +11 -0
  393. data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
  394. data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
  395. data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
  396. data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
  397. data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
  398. data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
  399. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
  400. data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
  401. data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
  402. data/lib/collavre/configuration.rb +14 -0
  403. data/lib/collavre/engine.rb +77 -0
  404. data/lib/collavre/user_extensions.rb +29 -0
  405. data/lib/collavre/version.rb +3 -0
  406. data/lib/collavre.rb +26 -0
  407. data/lib/generators/collavre/install/install_generator.rb +105 -0
  408. data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
  409. data/lib/tasks/collavre_assets.rake +15 -0
  410. metadata +591 -0
@@ -0,0 +1,1841 @@
1
+ import creativesApi from '../lib/api/creatives'
2
+ import apiQueue from '../lib/api/queue_manager'
3
+ import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $isRootOrShadowRoot } from 'lexical'
4
+ import { createInlineEditor } from './lexical_inline_editor'
5
+ import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
6
+ // Import Stimulus application from the global window (set by host app)
7
+ const application = window.Stimulus
8
+
9
+ const BULLET_STARTING_LEVEL = 3;
10
+ const HEADING_INDENT_STEP_EM = 0.4;
11
+ const BULLET_INDENT_STEP_PX = 30;
12
+
13
+ let initialized = false;
14
+ let creativeEditClickHandler = null;
15
+ let addCreativeShortcutHandler = null;
16
+
17
+ function deleteAttachment(signedId) {
18
+ if (!signedId) return;
19
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
20
+ fetch(`/attachments/${signedId}`, {
21
+ method: 'DELETE',
22
+ headers: {
23
+ 'X-CSRF-Token': csrfToken,
24
+ },
25
+ }).catch(err => console.error('Error deleting attachment:', err));
26
+ }
27
+
28
+ export function initializeCreativeRowEditor() {
29
+ if (initialized) return;
30
+ initialized = true;
31
+
32
+ document.addEventListener('turbo:load', function () {
33
+ const template = document.getElementById('inline-edit-form');
34
+ if (!template) return;
35
+
36
+ initializeEventListeners();
37
+
38
+ // Listen for attachment deletions from queue manager
39
+ window.addEventListener('api-queue-attachments-deleted', (event) => {
40
+ const attachmentIds = event.detail?.attachmentIds;
41
+ if (attachmentIds && attachmentIds.length > 0) {
42
+ attachmentIds.forEach(deleteAttachment);
43
+ }
44
+ });
45
+
46
+ // Listen for failed requests to prevent silent data loss
47
+ window.addEventListener('api-queue-request-failed', (event) => {
48
+ const { item, error } = event.detail;
49
+ console.error('Queue request failed permanently:', item, error);
50
+
51
+ // Alert the user
52
+ // In a real app, use a toast notification. For now, alert is safe.
53
+ // Suppress 404 errors for PATCH requests, as this likely means the item was deleted
54
+ // and we don't need to alert the user about it.
55
+ const is404 = error && error.toString().includes('404');
56
+ const isPatch = item && item.method === 'PATCH';
57
+
58
+ if (!(is404 && isPatch)) {
59
+ alert(`Failed to save changes. Please check your connection and try again.\nError: ${error}`);
60
+ }
61
+
62
+ // If the failed item matches the current creative, mark it as dirty so it can be retried
63
+ if (form.dataset.creativeId && item.path.includes(form.dataset.creativeId)) {
64
+ console.log('Restoring dirty state for current creative');
65
+ isDirty = true;
66
+ pendingSave = true;
67
+ updateActionButtonStates();
68
+ }
69
+ });
70
+
71
+ // ... rest of initialization
72
+
73
+ const form = document.getElementById('inline-edit-form-element');
74
+ const descriptionInput = document.getElementById('inline-creative-description');
75
+ const editorContainer = template.querySelector('[data-lexical-editor-root]');
76
+ const progressInput = document.getElementById('inline-creative-progress');
77
+ const progressValue = document.getElementById('inline-progress-value');
78
+ const upBtn = document.getElementById('inline-move-up');
79
+ const downBtn = document.getElementById('inline-move-down');
80
+ const addBtn = document.getElementById('inline-add');
81
+ const levelDownBtn = document.getElementById('inline-level-down');
82
+ const levelUpBtn = document.getElementById('inline-level-up');
83
+ const deletePopupToggle = document.getElementById('inline-delete-popup-toggle');
84
+ const deleteBtn = document.getElementById('inline-delete');
85
+ const deleteWithChildrenBtn = document.getElementById('inline-delete-with-children');
86
+ const linkBtn = document.getElementById('inline-link');
87
+ const unlinkBtn = document.getElementById('inline-unlink');
88
+ const unconvertBtn = document.getElementById('inline-unconvert');
89
+ const closeBtn = document.getElementById('inline-close');
90
+ const parentSuggestions = document.getElementById('parent-suggestions');
91
+ const parentSuggestBtn = document.getElementById('inline-recommend-parent');
92
+ const methodInput = document.getElementById('inline-method');
93
+ const parentInput = document.getElementById('inline-parent-id');
94
+ const beforeInput = document.getElementById('inline-before-id');
95
+ const afterInput = document.getElementById('inline-after-id');
96
+ const childInput = document.getElementById('inline-child-id');
97
+ const originIdInput = document.getElementById('inline-origin-id');
98
+
99
+ let lexicalEditor = null;
100
+ if (editorContainer) {
101
+ try {
102
+ lexicalEditor = createInlineEditor(editorContainer, {
103
+ onChange: onLexicalChange,
104
+ onKeyDown: handleEditorKeyDown,
105
+ onUploadStateChange: handleUploadStateChange
106
+ });
107
+ } catch (e) {
108
+ console.error('CreativeRowEditor: Failed to create inline editor', e);
109
+ }
110
+ }
111
+
112
+ // Initialize queue with current user ID to prevent cross-account data leakage
113
+ try {
114
+ const currentUserId = document.body.dataset.currentUserId;
115
+ if (apiQueue) {
116
+ apiQueue.initialize(currentUserId);
117
+ apiQueue.start();
118
+ } else {
119
+ console.error('CreativeRowEditor: apiQueue is undefined');
120
+ }
121
+ } catch (e) {
122
+ console.error('CreativeRowEditor: Error initializing apiQueue:', e);
123
+ }
124
+
125
+ let currentTree = null;
126
+ let currentRowElement = null;
127
+ let saveTimer = null;
128
+ let pendingSave = false;
129
+ let saving = false;
130
+ let savePromise = Promise.resolve();
131
+ let uploadsPending = false;
132
+ let uploadCompletionPromise = null;
133
+ let resolveUploadCompletion = null;
134
+ let addNewInProgress = false;
135
+ let originalContent = '';
136
+ let originalProgress = 0;
137
+ let originalOriginId = '';
138
+ let isDirty = false;
139
+
140
+ function formatProgressDisplay(value) {
141
+ const numeric = Number(value);
142
+ if (Number.isNaN(numeric)) return '0%';
143
+ const percentage = Math.round(numeric * 100);
144
+ return `${percentage}%`;
145
+ }
146
+
147
+ function treeRowElement(node) {
148
+ return node && node.closest ? node.closest('creative-tree-row') : null;
149
+ }
150
+
151
+ function hasDatasetValue(element, key) {
152
+ if (!element || !element.dataset) return false;
153
+ return Object.prototype.hasOwnProperty.call(element.dataset, key);
154
+ }
155
+
156
+ function setRowDatasetValue(row, key, value) {
157
+ if (!row || !row.dataset) return;
158
+ if (value === undefined || value === null) {
159
+ delete row.dataset[key];
160
+ } else {
161
+ row.dataset[key] = String(value);
162
+ }
163
+ }
164
+
165
+ function updateRowFromData(row, data) {
166
+ if (!row || !data) return;
167
+ const descriptionHtml = data.description || '';
168
+ const rawHtml = data.description_raw_html || descriptionHtml;
169
+ row.descriptionHtml = descriptionHtml;
170
+ setRowDatasetValue(row, 'descriptionHtml', descriptionHtml);
171
+ setRowDatasetValue(row, 'descriptionRawHtml', rawHtml);
172
+ if (data.progress_html != null) {
173
+ row.progressHtml = data.progress_html;
174
+ setRowDatasetValue(row, 'progressHtml', data.progress_html);
175
+ }
176
+ if (Object.prototype.hasOwnProperty.call(data, 'progress')) {
177
+ setRowDatasetValue(row, 'progressValue', data.progress ?? '');
178
+ }
179
+ if (Object.prototype.hasOwnProperty.call(data, 'origin_id')) {
180
+ setRowDatasetValue(row, 'originId', data.origin_id ?? '');
181
+ }
182
+ if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
183
+ if (data.has_children) {
184
+ row.setAttribute('has-children', '');
185
+ row.hasChildren = true;
186
+ } else {
187
+ row.removeAttribute('has-children');
188
+ row.hasChildren = false;
189
+ }
190
+ }
191
+ if (typeof row.requestUpdate === 'function') {
192
+ row.requestUpdate();
193
+ }
194
+ }
195
+
196
+ function inlinePayloadFromTree(tree) {
197
+ if (!tree) return null;
198
+ const row = treeRowElement(tree);
199
+ if (!row) return null;
200
+
201
+ // Relax validation - allow loading with partial data for instant UI
202
+ const hasDescription = hasDatasetValue(row, 'descriptionRawHtml') || hasDatasetValue(row, 'descriptionHtml');
203
+ const hasProgress = hasDatasetValue(row, 'progressValue');
204
+
205
+ // Only require ID to be present
206
+ const id = tree.dataset?.id;
207
+ if (!id) return null;
208
+
209
+ const rawHtml = hasDatasetValue(row, 'descriptionRawHtml') ? row.dataset.descriptionRawHtml : row.dataset.descriptionHtml || '';
210
+ const description = row.dataset.descriptionHtml || rawHtml || '';
211
+ const progressValue = hasProgress ? Number(row.dataset.progressValue ?? 0) : 0;
212
+ const parentId = tree.dataset?.parentId || '';
213
+
214
+ return {
215
+ id: id,
216
+ description,
217
+ description_raw_html: rawHtml,
218
+ origin_id: row.dataset?.originId || '',
219
+ parent_id: parentId,
220
+ progress: Number.isNaN(progressValue) ? 0 : progressValue
221
+ };
222
+ }
223
+
224
+ function isHtmlEmpty(html) {
225
+ if (!html) return true;
226
+ const temp = document.createElement('div');
227
+ temp.innerHTML = html;
228
+ if (temp.querySelector('img')) return false;
229
+ return (temp.textContent || '').trim().length === 0;
230
+ }
231
+
232
+ function applyCreativeData(data, tree) {
233
+ if (!data) return;
234
+ const creativeId = data.id;
235
+ if (!creativeId) return;
236
+ form.action = `/creatives/${creativeId}`;
237
+ if (methodInput) methodInput.value = 'patch';
238
+ form.dataset.creativeId = creativeId;
239
+ const content = data.description_raw_html || data.description || '';
240
+ descriptionInput.value = content;
241
+ lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
242
+ pendingSave = false;
243
+ // Track original content for dirty state detection
244
+ originalContent = content;
245
+ isDirty = false;
246
+ const progressNumber = Number(data.progress ?? 0);
247
+ const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
248
+ progressInput.value = normalizedProgress;
249
+ progressValue.textContent = formatProgressDisplay(progressInput.value);
250
+ const fallbackParent = tree?.dataset?.parentId || '';
251
+ parentInput.value = data.parent_id ?? fallbackParent ?? '';
252
+ beforeInput.value = '';
253
+ afterInput.value = '';
254
+ if (childInput) childInput.value = '';
255
+ const originId = data.origin_id || '';
256
+ if (originIdInput) {
257
+ originIdInput.value = originId;
258
+ }
259
+ originalOriginId = originId;
260
+ if (linkBtn) linkBtn.style.display = originId ? 'none' : '';
261
+ if (unlinkBtn) unlinkBtn.style.display = originId ? '' : 'none';
262
+ const effectiveParent = parentInput.value;
263
+ if (unconvertBtn) unconvertBtn.style.display = effectiveParent ? '' : 'none';
264
+ originalProgress = normalizedProgress;
265
+ lexicalEditor.focus();
266
+ updateActionButtonStates();
267
+ }
268
+
269
+ function siblingTreeRow(row, direction) {
270
+ if (!row) return null;
271
+ const step = direction === 'previous' ? 'previousSibling' : 'nextSibling';
272
+ let node = row[step];
273
+ while (node) {
274
+ if (node.nodeType === Node.TEXT_NODE) {
275
+ node = node[step];
276
+ continue;
277
+ }
278
+ if (node.matches?.('creative-tree-row')) return node;
279
+ if (node.classList?.contains?.('creative-children')) {
280
+ node = node[step];
281
+ continue;
282
+ }
283
+ node = node[step];
284
+ }
285
+ return null;
286
+ }
287
+
288
+ function siblingOrderingForRow(row) {
289
+ const beforeRow = siblingTreeRow(row, 'next');
290
+ const afterRow = siblingTreeRow(row, 'previous');
291
+ return {
292
+ beforeId: beforeRow ? creativeIdFrom(beforeRow) : '',
293
+ afterId: afterRow ? creativeIdFrom(afterRow) : ''
294
+ };
295
+ }
296
+
297
+ function treeContainerElement(tree) {
298
+ if (!tree) return null;
299
+ const row = treeRowElement(tree);
300
+ if (row && row.parentNode) return row.parentNode;
301
+ return tree.parentNode;
302
+ }
303
+
304
+ function nodeAfterTreeBlock(tree) {
305
+ if (!tree) return null;
306
+ const row = treeRowElement(tree);
307
+ if (!row) return tree.nextSibling;
308
+ let node = row.nextSibling;
309
+ while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
310
+ const treeId = tree.dataset?.id;
311
+ if (treeId) {
312
+ const childrenContainer = document.getElementById(`creative-children-${treeId}`);
313
+ if (childrenContainer && childrenContainer.parentNode === row.parentNode && node === childrenContainer) {
314
+ node = childrenContainer.nextSibling;
315
+ while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
316
+ }
317
+ }
318
+ return node;
319
+ }
320
+
321
+ function normalizeRowNode(node) {
322
+ if (!node) return null;
323
+ if (node.matches && node.matches('creative-tree-row')) return node;
324
+ if (node.classList && node.classList.contains('creative-tree')) {
325
+ const row = treeRowElement(node);
326
+ return row || node;
327
+ }
328
+ return node;
329
+ }
330
+
331
+ function childrenContainerForTree(tree) {
332
+ if (!tree) return null;
333
+ const treeId = tree.dataset?.id;
334
+ if (treeId) {
335
+ const byId = document.getElementById(`creative-children-${treeId}`);
336
+ if (byId) return byId;
337
+ }
338
+ if (tree.children && tree.children.length > 0) {
339
+ for (const child of tree.children) {
340
+ if (child && child.classList && child.classList.contains('creative-children')) {
341
+ return child;
342
+ }
343
+ }
344
+ }
345
+ const row = treeRowElement(tree);
346
+ if (row) {
347
+ let sibling = row.nextElementSibling;
348
+ while (sibling) {
349
+ if (sibling.matches?.('creative-tree-row')) break;
350
+ if (sibling.classList?.contains('creative-children')) return sibling;
351
+ sibling = sibling.nextElementSibling;
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+
357
+ function buildChildrenLoadUrl(parentId, childLevel, selectMode) {
358
+ const params = new URLSearchParams();
359
+ params.set('level', String(childLevel));
360
+ params.set('select_mode', selectMode ? '1' : '0');
361
+ return `/creatives/${parentId}/children?${params.toString()}`;
362
+ }
363
+
364
+ function ensureChildrenContainer(tree) {
365
+ if (!tree) return null;
366
+ let container = childrenContainerForTree(tree);
367
+ if (container) return container;
368
+ const parentId = tree.dataset?.id;
369
+ if (!parentId) return null;
370
+ container = document.createElement('div');
371
+ container.className = 'creative-children';
372
+ container.id = `creative-children-${parentId}`;
373
+ const parentRow = treeRowElement(tree);
374
+ const parentLevel = readRowLevel(parentRow) || 1;
375
+ const childLevel = parentLevel + 1;
376
+ const selectModeActive = parentRow?.hasAttribute?.('select-mode') ? 1 : 0;
377
+ container.dataset.loadUrl = buildChildrenLoadUrl(parentId, childLevel, selectModeActive);
378
+ container.dataset.expanded = 'true';
379
+ if (container.dataset.loaded) delete container.dataset.loaded;
380
+ const row = treeRowElement(tree);
381
+ const parentContainer = row?.parentNode || tree.parentNode;
382
+ if (parentContainer) {
383
+ const afterRow = row?.nextSibling;
384
+ if (afterRow) {
385
+ parentContainer.insertBefore(container, afterRow);
386
+ } else {
387
+ parentContainer.appendChild(container);
388
+ }
389
+ } else {
390
+ tree.appendChild(container);
391
+ }
392
+ return container;
393
+ }
394
+
395
+ function expandChildrenContainer(container) {
396
+ if (!container) return;
397
+ container.style.display = '';
398
+ if (container.dataset) {
399
+ container.dataset.expanded = 'true';
400
+ }
401
+ }
402
+
403
+ function moveTreeBlock(tree, targetContainer, referenceNode = null) {
404
+ if (!tree || !targetContainer) return;
405
+ const row = treeRowElement(tree);
406
+ if (!row) return;
407
+ const nodesToMove = [row];
408
+ const childContainer = childrenContainerForTree(tree);
409
+ if (childContainer) nodesToMove.push(childContainer);
410
+ nodesToMove.forEach((node) => {
411
+ if (!node) return;
412
+ if (referenceNode) {
413
+ targetContainer.insertBefore(node, referenceNode);
414
+ } else {
415
+ targetContainer.appendChild(node);
416
+ }
417
+ });
418
+ }
419
+
420
+ function listAllTreeNodes() {
421
+ const root = document.getElementById('creatives');
422
+ if (root) return Array.from(root.querySelectorAll('.creative-tree'));
423
+ return Array.from(document.querySelectorAll('.creative-tree'));
424
+ }
425
+
426
+ function findPreviousTree(tree) {
427
+ if (!tree) return null;
428
+ const nodes = listAllTreeNodes();
429
+ const index = nodes.indexOf(tree);
430
+ if (index <= 0) return null;
431
+ const currentLevel = getTreeLevel(tree);
432
+ for (let i = index - 1; i >= 0; i--) {
433
+ const candidate = nodes[i];
434
+ if (!candidate) continue;
435
+ const candidateLevel = getTreeLevel(candidate);
436
+ if (candidateLevel === currentLevel) return candidate;
437
+ if (candidateLevel < currentLevel) return null;
438
+ }
439
+ return null;
440
+ }
441
+
442
+ function getTreeLevel(tree) {
443
+ if (!tree) return 1;
444
+ const levelValue = Number(tree.dataset?.level);
445
+ if (!Number.isNaN(levelValue) && levelValue > 0) {
446
+ return levelValue;
447
+ }
448
+ const row = treeRowElement(tree);
449
+ return readRowLevel(row) || 1;
450
+ }
451
+
452
+ function updateTreeLevels(tree, delta) {
453
+ if (!tree || !delta) return;
454
+ const currentLevel = Number(tree.dataset?.level) || 1;
455
+ const nextLevel = Math.max(1, currentLevel + delta);
456
+ tree.dataset.level = String(nextLevel);
457
+ const row = treeRowElement(tree);
458
+ if (row) {
459
+ row.setAttribute('level', nextLevel);
460
+ row.level = nextLevel;
461
+ row.requestUpdate?.();
462
+ }
463
+ const container = childrenContainerForTree(tree);
464
+ if (!container) return;
465
+ Array.from(container.children || []).forEach((childRow) => {
466
+ if (!childRow.matches?.('creative-tree-row')) return;
467
+ const childTree = childRow.querySelector('.creative-tree');
468
+ if (childTree) {
469
+ updateTreeLevels(childTree, delta);
470
+ }
471
+ });
472
+ }
473
+
474
+ function setTreeLevel(tree, targetLevel) {
475
+ if (!tree || typeof targetLevel !== 'number') return;
476
+ const currentLevel = Number(tree.dataset?.level) || 1;
477
+ const delta = targetLevel - currentLevel;
478
+ if (delta === 0) return;
479
+ updateTreeLevels(tree, delta);
480
+ }
481
+
482
+ function updateParentChildrenState(parentId) {
483
+ if (!parentId) return;
484
+ const parentTree = document.getElementById(`creative-${parentId}`);
485
+ if (!parentTree) return;
486
+ const parentRow = treeRowElement(parentTree);
487
+ if (!parentRow) return;
488
+ const container = childrenContainerForTree(parentTree);
489
+ const hasChildren = Boolean(container && container.querySelector('creative-tree-row'));
490
+ if (hasChildren) {
491
+ parentRow.setAttribute('has-children', '');
492
+ parentRow.hasChildren = true;
493
+ expandChildrenContainer(container);
494
+ } else {
495
+ parentRow.removeAttribute('has-children');
496
+ parentRow.hasChildren = false;
497
+ if (container) container.style.display = 'none';
498
+ }
499
+ parentRow.requestUpdate?.();
500
+ }
501
+
502
+ function persistStructureChange(newParentId, { beforeId = '', afterId = '' } = {}) {
503
+ parentInput.value = newParentId || '';
504
+ beforeInput.value = beforeId || '';
505
+ afterInput.value = afterId || '';
506
+ if (childInput) childInput.value = '';
507
+ pendingSave = true;
508
+ scheduleSave();
509
+ }
510
+
511
+ function readRowLevel(row) {
512
+ if (!row) return null;
513
+ if (row.isTitle) return 0;
514
+ if (row.getAttribute) {
515
+ const levelAttr = row.getAttribute('level');
516
+ if (levelAttr) {
517
+ const parsed = Number(levelAttr);
518
+ if (!Number.isNaN(parsed)) return parsed;
519
+ }
520
+ }
521
+ if (typeof row.level === 'number') {
522
+ return row.level;
523
+ }
524
+ if (row.level) {
525
+ const parsed = Number(row.level);
526
+ if (!Number.isNaN(parsed)) return parsed;
527
+ }
528
+ const tree = row.querySelector ? row.querySelector('.creative-tree') : null;
529
+ if (tree && tree.dataset?.level) {
530
+ const parsed = Number(tree.dataset.level);
531
+ if (!Number.isNaN(parsed)) return parsed;
532
+ }
533
+ return 1;
534
+ }
535
+
536
+ function computeNewRowLevel(parentId, referenceNode, afterId) {
537
+ if (parentId) {
538
+ const parentRow = document.querySelector(`creative-tree-row[creative-id="${parentId}"]`);
539
+ if (parentRow) {
540
+ return readRowLevel(parentRow) + 1;
541
+ }
542
+ const parentTree = document.getElementById(`creative-${parentId}`);
543
+ if (parentTree?.dataset?.level) {
544
+ const parsed = Number(parentTree.dataset.level);
545
+ if (!Number.isNaN(parsed)) return parsed + 1;
546
+ }
547
+ console.log('use default level 2')
548
+ return 2;
549
+ }
550
+ const normalized = normalizeRowNode(referenceNode) || (afterId ? treeRowElement(document.getElementById(`creative-${afterId}`)) : null);
551
+ return readRowLevel(normalized);
552
+ }
553
+
554
+ function editorPaddingForLevel(level) {
555
+ if (typeof level !== 'number' || Number.isNaN(level) || level <= 1) {
556
+ return '0px';
557
+ }
558
+ if (level <= BULLET_STARTING_LEVEL) {
559
+ const emValue = (level - 1) * HEADING_INDENT_STEP_EM;
560
+ return emValue ? `${emValue}em` : '0px';
561
+ }
562
+ const pxValue = (level - BULLET_STARTING_LEVEL) * BULLET_INDENT_STEP_PX;
563
+ return `${pxValue}px`;
564
+ }
565
+
566
+ function syncInlineEditorPadding(source) {
567
+ if (!template) return;
568
+ let level = null;
569
+ if (typeof source === 'number') {
570
+ level = source;
571
+ } else if (source) {
572
+ level = readRowLevel(source);
573
+ }
574
+ const paddingValue = editorPaddingForLevel(level);
575
+ template.style.paddingLeft = paddingValue;
576
+ }
577
+
578
+ function removeTreeElement(tree) {
579
+ if (!tree) return;
580
+ const row = treeRowElement(tree);
581
+ if (row) {
582
+ row.remove();
583
+ } else if (tree.remove) {
584
+ tree.remove();
585
+ }
586
+ }
587
+
588
+ function getUploadCompletion() {
589
+ if (!uploadCompletionPromise) {
590
+ uploadCompletionPromise = new Promise(resolve => {
591
+ resolveUploadCompletion = resolve;
592
+ });
593
+ }
594
+ return uploadCompletionPromise;
595
+ }
596
+
597
+ function updateActionButtonStates() {
598
+ const hasCurrent = Boolean(currentTree);
599
+ const trees = hasCurrent ? listAllTreeNodes() : [];
600
+ const index = hasCurrent ? trees.indexOf(currentTree) : -1;
601
+ const hasCreativeId = Boolean(form.dataset?.creativeId);
602
+
603
+ if (upBtn) upBtn.disabled = !(hasCurrent && index > 0);
604
+ if (downBtn) downBtn.disabled = !(hasCurrent && index >= 0 && index < trees.length - 1);
605
+
606
+ let canLevelDown = false;
607
+ if (hasCurrent) {
608
+ const previousTree = findPreviousTree(currentTree);
609
+ const previousId = previousTree?.dataset?.id;
610
+ canLevelDown = Boolean(previousTree && previousId && previousId !== currentTree.dataset?.parentId);
611
+ }
612
+ if (levelDownBtn) levelDownBtn.disabled = !canLevelDown;
613
+
614
+ let canLevelUp = false;
615
+ if (hasCurrent) {
616
+ const parentId = currentTree.dataset?.parentId;
617
+ const parentTree = parentId ? document.getElementById(`creative-${parentId}`) : null;
618
+ const targetContainer = parentTree ? treeContainerElement(parentTree) : null;
619
+ canLevelUp = Boolean(parentId && parentTree && targetContainer);
620
+ }
621
+ if (levelUpBtn) levelUpBtn.disabled = !canLevelUp;
622
+
623
+ if (deletePopupToggle) deletePopupToggle.disabled = !hasCreativeId;
624
+ if (deleteBtn) deleteBtn.disabled = !hasCreativeId;
625
+ if (deleteWithChildrenBtn) deleteWithChildrenBtn.disabled = !hasCreativeId;
626
+ if (linkBtn) linkBtn.disabled = !hasCreativeId || linkBtn.style.display === 'none';
627
+ if (unlinkBtn) unlinkBtn.disabled = !hasCreativeId || unlinkBtn.style.display === 'none';
628
+ if (unconvertBtn) {
629
+ const unconvertVisible = unconvertBtn.style.display !== 'none';
630
+ const hasParent = Boolean(parentInput.value);
631
+ unconvertBtn.disabled = !(hasCreativeId && hasParent && unconvertVisible);
632
+ }
633
+
634
+ if (addBtn) addBtn.disabled = !hasCurrent;
635
+ if (closeBtn) closeBtn.disabled = uploadsPending || !hasCurrent;
636
+ }
637
+
638
+ function waitForUploads() {
639
+ return uploadsPending ? getUploadCompletion() : Promise.resolve();
640
+ }
641
+
642
+ function handleUploadStateChange(pending) {
643
+ uploadsPending = Boolean(pending);
644
+ if (closeBtn) closeBtn.disabled = uploadsPending;
645
+ if (template) {
646
+ if (uploadsPending) {
647
+ template.dataset.uploading = 'true';
648
+ } else {
649
+ delete template.dataset.uploading;
650
+ }
651
+ }
652
+ if (uploadsPending) {
653
+ getUploadCompletion();
654
+ } else if (resolveUploadCompletion) {
655
+ resolveUploadCompletion();
656
+ uploadCompletionPromise = null;
657
+ resolveUploadCompletion = null;
658
+ }
659
+ updateActionButtonStates();
660
+ }
661
+
662
+ function attachTemplate(tree) {
663
+ if (!tree) return;
664
+ const childrenContainer = tree.querySelector('.creative-children');
665
+ if (childrenContainer && childrenContainer.parentNode === tree) {
666
+ tree.insertBefore(template, childrenContainer);
667
+ } else {
668
+ tree.appendChild(template);
669
+ }
670
+ }
671
+
672
+ async function handleEditButtonClick(tree) {
673
+ if (!tree) return;
674
+
675
+ if (currentTree === tree) {
676
+ await hideCurrent();
677
+ return;
678
+ }
679
+ if (currentTree) {
680
+ await hideCurrent(false);
681
+ }
682
+ currentTree = tree;
683
+ currentRowElement = treeRowElement(tree);
684
+ syncInlineEditorPadding(currentRowElement);
685
+ hideRow(tree);
686
+ tree.draggable = false;
687
+ attachTemplate(tree);
688
+ template.style.display = 'block';
689
+ loadCreative(tree);
690
+ updateActionButtonStates();
691
+ }
692
+
693
+ function initializeEventListeners() {
694
+ if (!creativeEditClickHandler) {
695
+ creativeEditClickHandler = function (e) {
696
+ const tree = e.detail?.treeElement || e.detail?.button?.closest('.creative-tree');
697
+ if (!tree) return;
698
+ e.preventDefault();
699
+ handleEditButtonClick(tree);
700
+ };
701
+ document.addEventListener('creative-edit-click', creativeEditClickHandler);
702
+ }
703
+
704
+ if (!addCreativeShortcutHandler) {
705
+ addCreativeShortcutHandler = function (event) {
706
+ if (event.defaultPrevented || event.isComposing) return;
707
+ if (event.key !== 'Enter' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
708
+ const target = event.target;
709
+ const interactiveSelector = 'input, textarea, select, button, a, [contenteditable="true"], [data-lexical-editor-root]';
710
+ if (target && target.closest && target.closest(interactiveSelector)) return;
711
+ if (target && target.isContentEditable) return;
712
+ const addButton = document.querySelector('.creative-actions-row .add-creative-btn, .creative-actions-row .new-root-creative-btn');
713
+ if (!addButton) return;
714
+ event.preventDefault();
715
+ addButton.click();
716
+ };
717
+ document.addEventListener('keydown', addCreativeShortcutHandler);
718
+ }
719
+
720
+ document.body.addEventListener('click', function (e) {
721
+ // Delegated event for .edit-inline-btn
722
+ const editBtn = e.target.closest('.edit-inline-btn');
723
+ if (editBtn) {
724
+ e.preventDefault();
725
+ const tree = editBtn.closest('.creative-tree');
726
+ if (!tree) return;
727
+ handleEditButtonClick(tree);
728
+ return; // Event handled
729
+ }
730
+
731
+ // Delegated event for .add-creative-btn
732
+ const addBtn = e.target.closest('.add-creative-btn:not(#inline-add):not(#inline-level-down):not(#inline-level-up)');
733
+ if (addBtn) {
734
+ e.preventDefault();
735
+ if (template.style.display === 'block') {
736
+ hideCurrent();
737
+ return;
738
+ }
739
+ const tree = addBtn.closest('.creative-tree');
740
+ let parentId, container, insertBefore, beforeId = '';
741
+ if (tree) {
742
+ parentId = tree.dataset.id;
743
+ container = tree.querySelector('.creative-children');
744
+ if (!container) {
745
+ container = document.createElement('div');
746
+ container.className = 'creative-children';
747
+ container.id = 'creative-children-' + parentId;
748
+ tree.appendChild(container);
749
+ }
750
+ insertBefore = container.firstElementChild;
751
+ beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
752
+ } else {
753
+ parentId = addBtn.dataset.parentId || '';
754
+ const rootContainer = document.getElementById('creatives');
755
+ container = rootContainer;
756
+ insertBefore = rootContainer.firstElementChild;
757
+ beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
758
+ }
759
+ startNew(parentId, container, insertBefore, beforeId);
760
+ return; // Event handled
761
+ }
762
+
763
+
764
+ // Delegated event for .new-root-creative-btn
765
+ const newRootBtn = e.target.closest('.new-root-creative-btn');
766
+ if (newRootBtn) {
767
+ e.preventDefault();
768
+ const container = document.getElementById('creatives');
769
+ if (!container) return;
770
+
771
+ if (template.style.display === 'block') {
772
+ hideCurrent();
773
+ return;
774
+ }
775
+ const insertBefore = container.firstElementChild;
776
+ const beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
777
+ startNew('', container, insertBefore, beforeId);
778
+ return; // Event handled
779
+ }
780
+
781
+ // Delegated event for .append-parent-btn
782
+ const appendParentBtn = e.target.closest('.append-parent-btn');
783
+ if (appendParentBtn) {
784
+ e.preventDefault();
785
+ const targetId = appendParentBtn.dataset.childId;
786
+ const target = document.getElementById('creative-' + targetId);
787
+ if (!target) return;
788
+ const container = target.parentNode;
789
+ startNew(
790
+ container.id.startsWith('creative-children-') ? container.id.replace('creative-children-', '') : '',
791
+ container,
792
+ target,
793
+ targetId,
794
+ '',
795
+ targetId
796
+ );
797
+ return; // Event handled
798
+ }
799
+ });
800
+ }
801
+
802
+ function hideRow(tree) {
803
+ const row = tree.querySelector('.creative-row');
804
+ if (row) row.style.display = 'none';
805
+ }
806
+
807
+ function showRow(tree) {
808
+ const row = tree.querySelector('.creative-row');
809
+ if (row) row.style.display = '';
810
+ }
811
+
812
+ function creativeTreeElement(node) {
813
+ if (!node) return null;
814
+ if (node.classList && node.classList.contains('creative-tree')) return node;
815
+ if (node.querySelector) {
816
+ const inner = node.querySelector('.creative-tree');
817
+ if (inner) return inner;
818
+ }
819
+ return null;
820
+ }
821
+
822
+ function creativeIdFrom(node) {
823
+ const treeEl = creativeTreeElement(node);
824
+ if (treeEl && treeEl.dataset) {
825
+ return treeEl.dataset.id || '';
826
+ }
827
+ if (node?.getAttribute) {
828
+ return node.getAttribute('creative-id') || node.getAttribute('data-id') || '';
829
+ }
830
+ return '';
831
+ }
832
+
833
+ function insertRow(tree, data) {
834
+ if (tree.querySelector('.creative-row')) return;
835
+ const row = document.createElement('div');
836
+ row.className = 'creative-row';
837
+ row.style.display = 'none';
838
+ // TODO: remove DRY, use template or copy existing dom
839
+ row.innerHTML = `
840
+ <div class="creative-row-start">
841
+ <div class="creative-row-actions">
842
+ <button type="button" class="creative-action-btn edit-inline-btn" data-creative-id="${data.id}">
843
+ <!-- ok -->
844
+ </button>
845
+ <div class="creative-divider" style="width: 6px;"></div>
846
+ </div>
847
+ <a class="unstyled-link" href="/creatives/${data.id}">${data.description || ''}</a>
848
+ </div>
849
+ <div class="creative-row-end"><span class="creative-progress-incomplete">0%</span></div>`;
850
+ tree.insertBefore(row, tree.firstChild);
851
+ }
852
+
853
+ function refreshRow(tree) {
854
+ if (!tree) return;
855
+ const id = tree.dataset?.id;
856
+ if (!id) return;
857
+ const rowEl = treeRowElement(tree);
858
+ creativesApi.get(id)
859
+ .then(data => {
860
+ updateRowFromData(rowEl, data);
861
+ });
862
+ }
863
+
864
+ function refreshChildren(tree) {
865
+ const container = tree.querySelector('.creative-children');
866
+ if (!container) { return Promise.resolve(); }
867
+ const url = container.dataset.loadUrl;
868
+ if (!url) { return Promise.resolve(); }
869
+ return creativesApi.loadChildren(url)
870
+ .then(data => {
871
+ const nodes = Array.isArray(data?.creatives) ? data.creatives : [];
872
+ renderCreativeTree(container, nodes, { replace: false });
873
+ container.dataset.loaded = 'true';
874
+ dispatchCreativeTreeUpdated(container);
875
+ });
876
+ }
877
+
878
+ function saveForm(tree = currentTree, parentId = parentInput.value) {
879
+ return waitForUploads().then(function () {
880
+ if (saving) return savePromise;
881
+ clearTimeout(saveTimer);
882
+
883
+ if (isHtmlEmpty(descriptionInput.value)) {
884
+ pendingSave = false;
885
+ return Promise.resolve();
886
+ }
887
+
888
+ const method = methodInput.value === 'patch' ? 'PATCH' : 'POST';
889
+ pendingSave = false;
890
+ if (!form.action) return Promise.resolve();
891
+ saving = true;
892
+
893
+ // Capture values being saved to update dirty state on success
894
+ const savedContent = descriptionInput.value;
895
+ const savedProgress = progressInput.value;
896
+ const savedOriginId = originIdInput ? originIdInput.value : '';
897
+
898
+ savePromise = creativesApi.save(form.action, method, form).then(function (r) {
899
+ if (!r.ok) return r;
900
+ return r.text().then(function (text) {
901
+ try { return text ? JSON.parse(text) : {}; } catch (e) { return {}; }
902
+ }).then(function (data) {
903
+ // Update dirty state to reflect successful save
904
+ originalContent = savedContent;
905
+ originalProgress = savedProgress;
906
+ originalOriginId = savedOriginId;
907
+
908
+ // If current values match what was just saved, clear dirty flag
909
+ if (descriptionInput.value === savedContent &&
910
+ progressInput.value === savedProgress &&
911
+ originIdInput.value === savedOriginId) {
912
+ isDirty = false;
913
+ }
914
+
915
+ if (method === 'POST' && data.id) {
916
+ form.action = `/creatives/${data.id}`;
917
+ methodInput.value = 'patch';
918
+ form.dataset.creativeId = data.id;
919
+ if (tree) {
920
+ tree.id = `creative-${data.id}`;
921
+ tree.dataset.id = data.id;
922
+ tree.dataset.parentId = parentId || '';
923
+ const rowEl = treeRowElement(tree) || currentRowElement;
924
+ if (rowEl) {
925
+ rowEl.setAttribute('creative-id', data.id);
926
+ rowEl.creativeId = data.id;
927
+ const creativeLink = `/creatives/${data.id}`;
928
+ rowEl.setAttribute('link-url', creativeLink);
929
+ rowEl.linkUrl = creativeLink;
930
+ const levelValue = tree.dataset.level;
931
+ if (levelValue) {
932
+ rowEl.setAttribute('level', levelValue);
933
+ rowEl.level = Number(levelValue);
934
+ }
935
+ if (parentId) {
936
+ rowEl.setAttribute('parent-id', parentId);
937
+ rowEl.parentId = parentId;
938
+ rowEl.removeAttribute('is-root');
939
+ rowEl.isRoot = false;
940
+ } else {
941
+ rowEl.removeAttribute('parent-id');
942
+ rowEl.parentId = null;
943
+ rowEl.setAttribute('is-root', '');
944
+ rowEl.isRoot = true;
945
+ }
946
+ rowEl.canWrite = true;
947
+ rowEl.setAttribute('can-write', '');
948
+ rowEl.requestUpdate?.();
949
+ }
950
+ insertRow(tree, data);
951
+ }
952
+ const parentTree = parentId ? document.getElementById(`creative-${parentId}`) : null;
953
+ if (parentTree) refreshRow(parentTree);
954
+ } else if (method === 'PATCH') {
955
+ if (tree) refreshRow(tree);
956
+ }
957
+
958
+ // Delete removed attachments after successful save
959
+ if (lexicalEditor && typeof lexicalEditor.getDeletedAttachments === 'function') {
960
+ const deletedIds = lexicalEditor.getDeletedAttachments();
961
+ if (deletedIds && deletedIds.length > 0) {
962
+ deletedIds.forEach(deleteAttachment);
963
+ }
964
+ }
965
+ updateActionButtonStates();
966
+ });
967
+ }).finally(function () {
968
+ saving = false;
969
+ });
970
+ return savePromise;
971
+ });
972
+ }
973
+
974
+ function hideCurrent(event) {
975
+ if (event?.preventDefault) {
976
+ event.preventDefault();
977
+ }
978
+ if (!currentTree) return Promise.resolve();
979
+ const tree = currentTree;
980
+ const parentId = parentInput.value;
981
+ const wasNew = !form.dataset.creativeId;
982
+ currentTree = null;
983
+ currentRowElement = null;
984
+ tree.draggable = true;
985
+ updateActionButtonStates();
986
+
987
+ const finalizeHide = function () {
988
+ template.style.display = 'none';
989
+ const p = (pendingSave || saving) ? saveForm(tree, parentId) : Promise.resolve();
990
+ return p.then(() => {
991
+ if (wasNew && !form.dataset.creativeId) {
992
+ removeTreeElement(tree);
993
+ } else if (!tree.querySelector('.creative-row')) {
994
+ const parentTree = parentId ? document.getElementById(`creative-${parentId}`) : null;
995
+ if (parentTree) {
996
+ refreshChildren(parentTree);
997
+ }
998
+ } else {
999
+ showRow(tree);
1000
+ refreshRow(tree);
1001
+ }
1002
+ });
1003
+ };
1004
+
1005
+ if (uploadsPending) {
1006
+ return waitForUploads().then(finalizeHide);
1007
+ }
1008
+
1009
+ return finalizeHide();
1010
+ }
1011
+
1012
+ function loadCreative(tree) {
1013
+ if (!tree) return;
1014
+ const id = tree.dataset?.id;
1015
+ if (!id) return;
1016
+
1017
+ // Try to use cached data from the row first for instant loading
1018
+ // BUT only if we have actual content in the dataset
1019
+ // We must check the DOM element directly because inlinePayloadFromTree defaults missing values
1020
+ const row = treeRowElement(tree);
1021
+ const hasDescription = hasDatasetValue(row, 'descriptionRawHtml') || hasDatasetValue(row, 'descriptionHtml');
1022
+ const hasProgress = hasDatasetValue(row, 'progressValue');
1023
+
1024
+ const inlineData = inlinePayloadFromTree(tree);
1025
+
1026
+ // CRITICAL: Require BOTH description AND progress to be present in the dataset
1027
+ // If either is missing, inlinePayloadFromTree defaults it (e.g. progress=0),
1028
+ // which would overwrite the real value on the server if we saved it.
1029
+ if (inlineData && inlineData.id && hasDescription && hasProgress) {
1030
+ console.log('✅ Using cached data for creative', id, '- NO API CALL');
1031
+ applyCreativeData(inlineData, tree);
1032
+ return;
1033
+ }
1034
+
1035
+ // Fallback: if no cached data or incomplete data, fetch from API
1036
+ // This happens for lazily loaded children or rows without inline_editor_payload
1037
+ console.warn('⚠️ Incomplete or missing cached data for creative', id, '- making API call');
1038
+ creativesApi.get(id)
1039
+ .then(data => {
1040
+ updateRowFromData(treeRowElement(tree), data);
1041
+ applyCreativeData(data, tree);
1042
+ });
1043
+ }
1044
+
1045
+ function beforeNewOrMove(wasNew, prev, prevParent) {
1046
+ const needsSave = pendingSave || wasNew || saving;
1047
+ const p = needsSave ? saveForm(prev, prevParent) : Promise.resolve();
1048
+ return p.then(() => {
1049
+ if (wasNew && !form.dataset.creativeId) {
1050
+ removeTreeElement(prev);
1051
+ } else {
1052
+ showRow(prev);
1053
+ refreshRow(prev);
1054
+ }
1055
+ });
1056
+ }
1057
+
1058
+ /**
1059
+ * Queue save if content has been modified
1060
+ * This allows UI operations to proceed without waiting for API response
1061
+ * IMPORTANT: Waits for pending uploads to complete before queueing
1062
+ * @param {Element} tree - The tree element whose row should be updated (defaults to currentTree)
1063
+ */
1064
+ async function queueSaveIfDirty(tree = currentTree) {
1065
+ // Check both isDirty (text changes) and pendingSave (progress/structure changes)
1066
+ if (!isDirty && !pendingSave) return;
1067
+
1068
+ const creativeId = form.dataset?.creativeId;
1069
+ if (!creativeId) return;
1070
+
1071
+ // CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
1072
+ // to a different creative while we're waiting for uploads
1073
+ let currentContent = descriptionInput.value;
1074
+ let currentProgress = progressInput.value;
1075
+ const currentParentId = tree.dataset.parentId || '';
1076
+ const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
1077
+ const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
1078
+ const startCreativeId = creativeId;
1079
+
1080
+ // Prevent saving empty content, matching saveForm behavior
1081
+ // This avoids overwriting existing descriptions with empty strings during quick navigation
1082
+ if (isHtmlEmpty(currentContent)) {
1083
+ pendingSave = false;
1084
+ return;
1085
+ }
1086
+
1087
+ // CRITICAL: Wait for uploads to complete before queueing
1088
+ // But we already captured the values above, so switching editors won't affect us
1089
+ await waitForUploads();
1090
+
1091
+ // If we are still on the same creative (e.g. move awaited us), refresh the content
1092
+ // This ensures we capture the final HTML with signed IDs instead of blob URLs
1093
+ if (form.dataset.creativeId === startCreativeId) {
1094
+ currentContent = descriptionInput.value;
1095
+ currentProgress = progressInput.value;
1096
+ }
1097
+
1098
+ // Build request body
1099
+ // Note: before_id and after_id must be top-level params, not nested under creative[]
1100
+ // because CreativesController reads params[:before_id] and params[:after_id] for positioning
1101
+ const body = {
1102
+ 'creative[description]': currentContent,
1103
+ 'creative[progress]': currentProgress
1104
+ };
1105
+
1106
+ // Always include parent_id, even if empty (for moving to root)
1107
+ body['creative[parent_id]'] = currentParentId;
1108
+
1109
+ if (currentBeforeId) {
1110
+ body['before_id'] = currentBeforeId; // Top-level, not creative[before_id]
1111
+ }
1112
+ if (currentAfterId) {
1113
+ body['after_id'] = currentAfterId; // Top-level, not creative[after_id]
1114
+ }
1115
+
1116
+ // Update row dataset immediately to keep cached data fresh
1117
+ // IMPORTANT: Use the passed tree parameter, not currentTree, because currentTree
1118
+ // may have already been updated to point to a different creative
1119
+ if (tree) {
1120
+ const row = treeRowElement(tree);
1121
+ if (row) {
1122
+ row.dataset.descriptionHtml = currentContent;
1123
+ row.descriptionHtml = currentContent;
1124
+ row.dataset.descriptionRawHtml = currentContent;
1125
+ row.dataset.progressValue = String(currentProgress);
1126
+ if (currentParentId) {
1127
+ tree.dataset.parentId = currentParentId;
1128
+ row.parentId = currentParentId;
1129
+ }
1130
+ // Trigger Lit component re-render to show updated values
1131
+ row.requestUpdate?.();
1132
+ }
1133
+ }
1134
+
1135
+ // Capture deleted attachments to delete AFTER successful save
1136
+ // Store as data (not callback) so it can be serialized to localStorage
1137
+ let deletedAttachmentIds = null;
1138
+ if (lexicalEditor && typeof lexicalEditor.getDeletedAttachments === 'function') {
1139
+ deletedAttachmentIds = lexicalEditor.getDeletedAttachments();
1140
+ // Only include if there are actual IDs to delete
1141
+ if (deletedAttachmentIds && deletedAttachmentIds.length === 0) {
1142
+ deletedAttachmentIds = null;
1143
+ }
1144
+ }
1145
+
1146
+ // Queue the save request
1147
+ // Store deletedAttachmentIds as data, not as callback, so it can be serialized
1148
+ apiQueue.enqueue({
1149
+ path: `/creatives/${creativeId}`,
1150
+ method: 'PATCH',
1151
+ body: body,
1152
+ dedupeKey: `creative_${creativeId}`,
1153
+ deletedAttachmentIds: deletedAttachmentIds // Store as data for serialization
1154
+ });
1155
+ // console.warn('apiQueue.enqueue disabled for debugging');
1156
+
1157
+ // Reset dirty state
1158
+ originalContent = currentContent;
1159
+ isDirty = false;
1160
+ pendingSave = false;
1161
+ clearTimeout(saveTimer);
1162
+ }
1163
+
1164
+ async function move(delta) {
1165
+ if (!currentTree) return;
1166
+ const trees = Array.from(document.querySelectorAll('.creative-tree'));
1167
+ const index = trees.indexOf(currentTree);
1168
+ if (index === -1) return;
1169
+ const target = trees[index + delta];
1170
+ if (!target) return;
1171
+
1172
+ const prev = currentTree;
1173
+ const wasNew = !form.dataset.creativeId;
1174
+ const prevParent = parentInput.value;
1175
+
1176
+ // Queue save if dirty (non-blocking unless uploading)
1177
+ // CRITICAL: Pass 'prev' tree explicitly because currentTree will be updated immediately after
1178
+ if (!wasNew) {
1179
+ if (uploadsPending) {
1180
+ // If uploading, we MUST wait for the upload to finish and the save to capture the new URL
1181
+ // otherwise we risk saving the blob URL and losing the attachment
1182
+ await queueSaveIfDirty(prev);
1183
+ } else {
1184
+ queueSaveIfDirty(prev);
1185
+ }
1186
+ }
1187
+
1188
+ // Update UI immediately
1189
+ currentTree = target;
1190
+ currentRowElement = treeRowElement(target);
1191
+ syncInlineEditorPadding(currentRowElement);
1192
+ hideRow(target);
1193
+ attachTemplate(target);
1194
+ template.style.display = 'block';
1195
+
1196
+ // Handle new creative cleanup or show previous row
1197
+ const focusAfterMove = () => {
1198
+ if (delta < 0 && lexicalEditor.focusAtStart) {
1199
+ lexicalEditor.focusAtStart();
1200
+ } else {
1201
+ lexicalEditor.focus();
1202
+ }
1203
+ };
1204
+
1205
+ if (wasNew) {
1206
+ // For new creatives, still need to save or cleanup
1207
+ beforeNewOrMove(wasNew, prev, prevParent).then(() => {
1208
+ loadCreative(target);
1209
+ focusAfterMove();
1210
+ });
1211
+ } else {
1212
+ // For existing creatives, show the row and refresh if needed
1213
+ if (prev.querySelector('.creative-row')) {
1214
+ showRow(prev);
1215
+ }
1216
+ loadCreative(target);
1217
+ focusAfterMove();
1218
+ }
1219
+ updateActionButtonStates();
1220
+ }
1221
+
1222
+ async function addNew() {
1223
+ if (!currentTree) return;
1224
+
1225
+ // Prevent multiple simultaneous calls to addNew (e.g., from Lexical onChange + keyboard event)
1226
+ if (addNewInProgress) {
1227
+ return;
1228
+ }
1229
+ addNewInProgress = true;
1230
+ setTimeout(() => { addNewInProgress = false; }, 300);
1231
+
1232
+ const prev = currentTree;
1233
+ const wasNew = !form.dataset.creativeId;
1234
+ const prevParent = parentInput.value;
1235
+
1236
+ // Queue save if dirty (non-blocking unless uploading)
1237
+ // CRITICAL: Pass 'prev' tree explicitly
1238
+ if (!wasNew) {
1239
+ if (uploadsPending) {
1240
+ await queueSaveIfDirty(prev);
1241
+ } else {
1242
+ queueSaveIfDirty(prev);
1243
+ }
1244
+ }
1245
+
1246
+ const handleAddNew = () => {
1247
+ const prevCreativeId = prev.dataset.id;
1248
+
1249
+ const childContainer = document.getElementById('creative-children-' + prevCreativeId);
1250
+ const isCollapsed = childContainer && childContainer.style.display === 'none';
1251
+ const firstChild = childContainer && childContainer.querySelector('.creative-tree');
1252
+ let parentId, container, insertBefore,
1253
+ beforeId = '', afterId = '';
1254
+ if (firstChild && !isCollapsed) {
1255
+ parentId = prevCreativeId;
1256
+ container = childContainer;
1257
+ insertBefore = normalizeRowNode(firstChild);
1258
+ beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
1259
+ } else {
1260
+ parentId = prev.dataset.parentId;
1261
+ container = treeContainerElement(prev);
1262
+ afterId = prev.dataset.id;
1263
+ insertBefore = nodeAfterTreeBlock(prev);
1264
+ }
1265
+ startNew(parentId, container, insertBefore, beforeId, afterId);
1266
+ };
1267
+
1268
+ if (wasNew) {
1269
+ // For new creatives, still need to save or cleanup
1270
+ beforeNewOrMove(wasNew, prev, prevParent).then(handleAddNew).finally(() => {
1271
+ addNewInProgress = false;
1272
+ });
1273
+ } else {
1274
+ // For existing creatives, show the row if it exists and proceed
1275
+ if (prev.querySelector('.creative-row')) {
1276
+ showRow(prev);
1277
+ }
1278
+ handleAddNew();
1279
+ addNewInProgress = false;
1280
+ }
1281
+ }
1282
+
1283
+ async function addChild() {
1284
+ if (!currentTree) return;
1285
+ const prev = currentTree;
1286
+ const wasNew = !form.dataset.creativeId;
1287
+ const prevParent = parentInput.value;
1288
+
1289
+ // Queue save if dirty (non-blocking unless uploading)
1290
+ // CRITICAL: Pass 'prev' tree explicitly
1291
+ if (!wasNew) {
1292
+ if (uploadsPending) {
1293
+ await queueSaveIfDirty(prev);
1294
+ } else {
1295
+ queueSaveIfDirty(prev);
1296
+ }
1297
+ }
1298
+
1299
+ const handleAddChild = () => {
1300
+ const parentId = prev.dataset.id;
1301
+ let container = document.getElementById('creative-children-' + parentId);
1302
+ if (!container) {
1303
+ container = document.createElement('div');
1304
+ container.className = 'creative-children';
1305
+ container.id = 'creative-children-' + parentId;
1306
+ prev.appendChild(container);
1307
+ }
1308
+ const insertBefore = container.firstElementChild;
1309
+ const beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
1310
+ startNew(parentId, container, insertBefore, beforeId);
1311
+ };
1312
+
1313
+ if (wasNew) {
1314
+ // For new creatives, still need to save or cleanup
1315
+ beforeNewOrMove(wasNew, prev, prevParent).then(handleAddChild);
1316
+ } else {
1317
+ // For existing creatives, show the row if it exists and proceed
1318
+ if (prev.querySelector('.creative-row')) {
1319
+ showRow(prev);
1320
+ }
1321
+ handleAddChild();
1322
+ }
1323
+ }
1324
+
1325
+ function levelDown() {
1326
+ if (!currentTree) return;
1327
+ const previousTree = findPreviousTree(currentTree);
1328
+ if (!previousTree) return;
1329
+ const previousId = previousTree.dataset?.id;
1330
+ if (!previousId || previousId === currentTree.dataset?.id) return;
1331
+ if (currentTree.dataset?.parentId === previousId) return;
1332
+ const targetContainer = ensureChildrenContainer(previousTree);
1333
+ if (!targetContainer) return;
1334
+ expandChildrenContainer(targetContainer);
1335
+ const oldParentId = currentTree.dataset?.parentId || '';
1336
+ moveTreeBlock(currentTree, targetContainer);
1337
+ currentTree.dataset.parentId = previousId;
1338
+ if (currentRowElement) {
1339
+ currentRowElement.setAttribute('parent-id', previousId);
1340
+ currentRowElement.parentId = previousId;
1341
+ currentRowElement.removeAttribute('is-root');
1342
+ currentRowElement.isRoot = false;
1343
+ currentRowElement.requestUpdate?.();
1344
+ }
1345
+ updateParentChildrenState(previousId);
1346
+ if (oldParentId) updateParentChildrenState(oldParentId);
1347
+ const newLevel = getTreeLevel(previousTree) + 1;
1348
+ setTreeLevel(currentTree, newLevel);
1349
+ syncInlineEditorPadding(newLevel);
1350
+ const row = treeRowElement(currentTree);
1351
+ const ordering = siblingOrderingForRow(row);
1352
+ persistStructureChange(previousId, ordering);
1353
+ lexicalEditor.focus();
1354
+ updateActionButtonStates();
1355
+ }
1356
+
1357
+ function levelUp() {
1358
+ if (!currentTree) return;
1359
+ const parentId = currentTree.dataset?.parentId;
1360
+ if (!parentId) return;
1361
+ const parentTree = document.getElementById(`creative-${parentId}`);
1362
+ if (!parentTree) return;
1363
+ const targetContainer = treeContainerElement(parentTree);
1364
+ if (!targetContainer) return;
1365
+ const insertionPoint = nodeAfterTreeBlock(parentTree);
1366
+ moveTreeBlock(currentTree, targetContainer, insertionPoint || null);
1367
+ const grandParentId = parentTree.dataset?.parentId || '';
1368
+ if (grandParentId) {
1369
+ currentTree.dataset.parentId = grandParentId;
1370
+ } else {
1371
+ delete currentTree.dataset.parentId;
1372
+ }
1373
+ if (currentRowElement) {
1374
+ if (grandParentId) {
1375
+ currentRowElement.setAttribute('parent-id', grandParentId);
1376
+ currentRowElement.parentId = grandParentId;
1377
+ currentRowElement.removeAttribute('is-root');
1378
+ currentRowElement.isRoot = false;
1379
+ } else {
1380
+ currentRowElement.removeAttribute('parent-id');
1381
+ currentRowElement.parentId = null;
1382
+ currentRowElement.setAttribute('is-root', '');
1383
+ currentRowElement.isRoot = true;
1384
+ }
1385
+ currentRowElement.requestUpdate?.();
1386
+ }
1387
+ updateParentChildrenState(parentId);
1388
+ updateParentChildrenState(grandParentId);
1389
+ if (targetContainer.classList?.contains('creative-children')) {
1390
+ expandChildrenContainer(targetContainer);
1391
+ }
1392
+ const grandParentTree = grandParentId ? document.getElementById(`creative-${grandParentId}`) : null;
1393
+ const newLevel = grandParentTree ? getTreeLevel(grandParentTree) + 1 : 1;
1394
+ setTreeLevel(currentTree, newLevel);
1395
+ syncInlineEditorPadding(newLevel);
1396
+ const row = treeRowElement(currentTree);
1397
+ const ordering = siblingOrderingForRow(row);
1398
+ persistStructureChange(grandParentId, ordering);
1399
+ lexicalEditor.focus();
1400
+ updateActionButtonStates();
1401
+ }
1402
+
1403
+ function deleteCurrent(withChildren) {
1404
+ if (!currentTree || !form.dataset.creativeId) return;
1405
+ const id = form.dataset.creativeId;
1406
+ const tree = currentTree;
1407
+ const trees = Array.from(document.querySelectorAll('.creative-tree'));
1408
+ const index = trees.indexOf(tree);
1409
+ const nextId = trees[index + 1] ? trees[index + 1].dataset.id : null;
1410
+ const parentId = tree.dataset.parentId;
1411
+
1412
+ // CRITICAL: Remove any pending saves for this creative from the queue
1413
+ // This prevents a race condition where a queued save fires after deletion,
1414
+ // resulting in a 404 error and an alert to the user.
1415
+ if (apiQueue) {
1416
+ apiQueue.removeByDedupeKey(`creative_${id}`);
1417
+ }
1418
+
1419
+ creativesApi.destroy(id, withChildren).then(() => {
1420
+ const parentTree = parentId ? document.getElementById(`creative-${parentId}`) : null;
1421
+ const childrenTree = document.getElementById("creative-children-" + id)
1422
+ if (!withChildren && childrenTree && parentTree) {
1423
+ refreshChildren(parentTree).then(() => {
1424
+ if (parentTree) refreshRow(parentTree);
1425
+ });
1426
+ } else {
1427
+ document.getElementById("creative-children-" + id)?.remove();
1428
+ }
1429
+ move(1);
1430
+ removeTreeElement(tree);
1431
+ });
1432
+ }
1433
+
1434
+ function linkExistingCreative() {
1435
+ if (!currentTree || !form.dataset.creativeId) return;
1436
+
1437
+ const modal = document.getElementById('link-creative-modal')
1438
+ const controller = application.getControllerForElementAndIdentifier(modal, 'link-creative')
1439
+ if (controller) {
1440
+ controller.open(linkBtn?.getBoundingClientRect(), (item) => {
1441
+ creativesApi.linkExisting(form.dataset.creativeId, item.id).then(() => {
1442
+ refreshChildren(currentTree).then(() => refreshRow(currentTree));
1443
+ });
1444
+ });
1445
+ }
1446
+ }
1447
+
1448
+ function resetOriginTracking() {
1449
+ if (originIdInput) originIdInput.value = '';
1450
+ originalOriginId = '';
1451
+ if (linkBtn) linkBtn.style.display = '';
1452
+ if (unlinkBtn) unlinkBtn.style.display = 'none';
1453
+ }
1454
+
1455
+ function startNew(parentId, container, insertBefore, beforeId = '', afterId = '', childId = '') {
1456
+ resetOriginTracking();
1457
+ const performStart = () => {
1458
+ let targetContainer = container || document.getElementById('creatives');
1459
+ if (targetContainer && targetContainer.matches && targetContainer.matches('creative-tree-row')) {
1460
+ targetContainer = targetContainer.parentNode;
1461
+ } else if (targetContainer && targetContainer.classList && targetContainer.classList.contains('creative-tree')) {
1462
+ const resolved = treeContainerElement(targetContainer);
1463
+ if (resolved) targetContainer = resolved;
1464
+ }
1465
+
1466
+ let referenceNode = insertBefore;
1467
+ if (referenceNode && referenceNode.classList && referenceNode.classList.contains('creative-tree')) {
1468
+ const normalized = normalizeRowNode(referenceNode);
1469
+ if (normalized) referenceNode = normalized;
1470
+ }
1471
+
1472
+ const level = computeNewRowLevel(parentId, referenceNode, afterId);
1473
+
1474
+ const rowComponent = document.createElement('creative-tree-row');
1475
+ rowComponent.level = level;
1476
+ rowComponent.setAttribute('level', level);
1477
+ const iconSource = document.querySelector('creative-tree-row[data-edit-icon-html]') || document.getElementById('creatives');
1478
+ if (iconSource) {
1479
+ if (iconSource.dataset.editIconHtml) {
1480
+ rowComponent.dataset.editIconHtml = iconSource.dataset.editIconHtml;
1481
+ rowComponent.editIconHtml = iconSource.dataset.editIconHtml;
1482
+ }
1483
+ if (iconSource.dataset.editOffIconHtml) {
1484
+ rowComponent.dataset.editOffIconHtml = iconSource.dataset.editOffIconHtml;
1485
+ rowComponent.editOffIconHtml = iconSource.dataset.editOffIconHtml;
1486
+ }
1487
+ }
1488
+ if (parentId) {
1489
+ rowComponent.parentId = parentId;
1490
+ rowComponent.setAttribute('parent-id', parentId);
1491
+ rowComponent.removeAttribute('is-root');
1492
+ rowComponent.isRoot = false;
1493
+ } else {
1494
+ rowComponent.parentId = null;
1495
+ rowComponent.setAttribute('is-root', '');
1496
+ rowComponent.isRoot = true;
1497
+ }
1498
+ rowComponent.canWrite = true;
1499
+ rowComponent.setAttribute('can-write', '');
1500
+ rowComponent.hasChildren = false;
1501
+ rowComponent.removeAttribute('has-children');
1502
+ rowComponent.expanded = true;
1503
+ rowComponent.setAttribute('expanded', '');
1504
+ rowComponent.dataset.descriptionHtml = '';
1505
+ rowComponent.dataset.progressHtml = '';
1506
+
1507
+ if (referenceNode) {
1508
+ targetContainer.insertBefore(rowComponent, referenceNode);
1509
+ } else {
1510
+ targetContainer.appendChild(rowComponent);
1511
+ }
1512
+
1513
+ const finalizeSetup = () => {
1514
+ const newTree = rowComponent.querySelector('.creative-tree');
1515
+ if (!newTree || currentTree === newTree) return;
1516
+ newTree.dataset.parentId = parentId || '';
1517
+ newTree.dataset.level = String(level);
1518
+ newTree.draggable = false;
1519
+ hideRow(newTree);
1520
+ if (parentId) {
1521
+ const parentRow = document.querySelector(`creative-tree-row[creative-id="${parentId}"]`);
1522
+ if (parentRow) {
1523
+ parentRow.setAttribute('has-children', '');
1524
+ parentRow.hasChildren = true;
1525
+ parentRow.requestUpdate?.();
1526
+ }
1527
+ }
1528
+ currentTree = newTree;
1529
+ currentRowElement = rowComponent;
1530
+ syncInlineEditorPadding(level);
1531
+ attachTemplate(newTree);
1532
+ template.style.display = 'block';
1533
+ form.action = '/creatives';
1534
+ methodInput.value = '';
1535
+ form.dataset.creativeId = '';
1536
+ parentInput.value = parentId || '';
1537
+ beforeInput.value = beforeId || '';
1538
+ afterInput.value = afterId || '';
1539
+ if (childInput) childInput.value = childId || '';
1540
+ resetOriginTracking();
1541
+ descriptionInput.value = '';
1542
+ lexicalEditor.reset(`new-${Date.now()}`);
1543
+ progressInput.value = 0;
1544
+ progressValue.textContent = formatProgressDisplay(0);
1545
+ if (unconvertBtn) unconvertBtn.style.display = 'none';
1546
+ pendingSave = false;
1547
+ lexicalEditor.focus();
1548
+ updateActionButtonStates();
1549
+ if (parentSuggestions) {
1550
+ parentSuggestions.style.display = 'none';
1551
+ parentSuggestions.innerHTML = '';
1552
+ }
1553
+ };
1554
+
1555
+ if (rowComponent.updateComplete) {
1556
+ rowComponent.updateComplete.then(finalizeSetup);
1557
+ } else {
1558
+ requestAnimationFrame(finalizeSetup);
1559
+ }
1560
+ };
1561
+
1562
+ if (currentTree) {
1563
+ return Promise.resolve(hideCurrent(false)).then(performStart);
1564
+ }
1565
+
1566
+ return performStart();
1567
+ }
1568
+
1569
+ function scheduleSave() {
1570
+ pendingSave = true;
1571
+ clearTimeout(saveTimer);
1572
+ saveTimer = setTimeout(saveForm, 5000);
1573
+ }
1574
+
1575
+ function onLexicalChange(html) {
1576
+ descriptionInput.value = html;
1577
+ // Mark as dirty if content changed from original
1578
+ isDirty = (html !== originalContent);
1579
+ scheduleSave();
1580
+ }
1581
+
1582
+ function handleEditorKeyDown(event, editorInstance) {
1583
+ if (!editorInstance) return;
1584
+ if (event.key === 'Escape') {
1585
+ event.preventDefault();
1586
+ hideCurrent();
1587
+ return;
1588
+ }
1589
+ if (event.key === 'Enter' && event.altKey) {
1590
+ event.preventDefault();
1591
+ addChild();
1592
+ return;
1593
+ }
1594
+ if (event.key === 'Enter' && event.shiftKey) {
1595
+ event.preventDefault();
1596
+ addNew();
1597
+ return;
1598
+ }
1599
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === '.' || event.key === '>')) {
1600
+ event.preventDefault();
1601
+ levelDown();
1602
+ return;
1603
+ }
1604
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === ',' || event.key === '<')) {
1605
+ event.preventDefault();
1606
+ levelUp();
1607
+ return;
1608
+ }
1609
+ const normalizedKey = typeof event.key === 'string' ? event.key.toLowerCase() : '';
1610
+ const isArrowUp = event.key === 'ArrowUp';
1611
+ const isArrowDown = event.key === 'ArrowDown';
1612
+ const isCtrlP = normalizedKey === 'p' && (event.ctrlKey || event.metaKey);
1613
+ const isCtrlN = normalizedKey === 'n' && (event.ctrlKey || event.metaKey);
1614
+
1615
+ if (!(isArrowUp || isArrowDown || isCtrlP || isCtrlN)) return;
1616
+
1617
+ let atStart = false;
1618
+ let atEnd = false;
1619
+ editorInstance.getEditorState().read(() => {
1620
+ const selection = $getSelection();
1621
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
1622
+ const [start, end] = $getCharacterOffsets(selection);
1623
+ atStart = start === 0 && end === 0;
1624
+ atEnd = isSelectionAtDocumentEnd(selection);
1625
+ });
1626
+
1627
+ if ((isArrowUp || isCtrlP) && atStart) {
1628
+ event.preventDefault();
1629
+ // Don't call saveForm() here - move() handles async saving via queueSaveIfDirty
1630
+ move(-1);
1631
+ requestAnimationFrame(() => lexicalEditor.focus());
1632
+ return;
1633
+ }
1634
+
1635
+ if ((isArrowDown || isCtrlN) && atEnd) {
1636
+ event.preventDefault();
1637
+ // Don't call saveForm() here - move() handles async saving via queueSaveIfDirty
1638
+ move(1);
1639
+ requestAnimationFrame(() => lexicalEditor.focus());
1640
+ }
1641
+ }
1642
+
1643
+ function isSelectionAtDocumentEnd(selection) {
1644
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
1645
+
1646
+ const focus = selection.focus;
1647
+ let node = focus.getNode();
1648
+ if (!node) return false;
1649
+
1650
+ const offset = focus.offset;
1651
+ if ($isTextNode(node)) {
1652
+ if (offset !== node.getTextContentSize()) return false;
1653
+ } else if (typeof node.getChildrenSize === 'function') {
1654
+ if (offset !== node.getChildrenSize()) return false;
1655
+ } else {
1656
+ // Fallback for nodes without children size (e.g., line breaks)
1657
+ const textSize = node.getTextContentSize?.() ?? 0;
1658
+ if (offset !== textSize) return false;
1659
+ }
1660
+
1661
+ while (node && !$isRootOrShadowRoot(node)) {
1662
+ if (node.getNextSibling()) return false;
1663
+ node = node.getParent();
1664
+ }
1665
+
1666
+ return !!node && $isRootOrShadowRoot(node);
1667
+ }
1668
+
1669
+ progressInput.addEventListener('input', function () {
1670
+ progressValue.textContent = formatProgressDisplay(progressInput.value);
1671
+ scheduleSave();
1672
+ });
1673
+
1674
+ if (parentSuggestBtn && parentSuggestions) {
1675
+ parentSuggestBtn.addEventListener('click', function () {
1676
+ const originalLabel = parentSuggestBtn.textContent;
1677
+ parentSuggestBtn.disabled = true;
1678
+ parentSuggestBtn.textContent = `${originalLabel}...`;
1679
+ parentSuggestions.innerHTML = '<option>...</option>';
1680
+ parentSuggestions.style.display = 'block';
1681
+
1682
+ saveForm()
1683
+ .then(function () {
1684
+ const id = form.dataset.creativeId;
1685
+ if (!id) {
1686
+ parentSuggestions.style.display = 'none';
1687
+ return;
1688
+ }
1689
+ return creativesApi.parentSuggestions(id).then(function (data) {
1690
+ parentSuggestions.innerHTML = '';
1691
+ if (data && data.length) {
1692
+ data.forEach(function (s) {
1693
+ const opt = document.createElement('option');
1694
+ opt.value = s.id;
1695
+ opt.textContent = s.path;
1696
+ parentSuggestions.appendChild(opt);
1697
+ });
1698
+ parentSuggestions.style.display = 'block';
1699
+ } else {
1700
+ parentSuggestions.style.display = 'none';
1701
+ }
1702
+ });
1703
+ })
1704
+ .finally(function () {
1705
+ parentSuggestBtn.textContent = originalLabel;
1706
+ parentSuggestBtn.disabled = false;
1707
+ });
1708
+ });
1709
+ }
1710
+
1711
+ if (parentSuggestions) {
1712
+ parentSuggestions.addEventListener('change', function () {
1713
+ if (!this.value) return;
1714
+ parentInput.value = this.value;
1715
+ const targetId = this.value;
1716
+ saveForm().then(function () {
1717
+ window.location.href = `/creatives/${targetId}`;
1718
+ });
1719
+ });
1720
+ }
1721
+
1722
+ if (closeBtn) {
1723
+ closeBtn.addEventListener('click', hideCurrent);
1724
+ }
1725
+
1726
+ upBtn.addEventListener('click', function () {
1727
+ // Don't call saveForm() here - move() handles async saving via queueSaveIfDirty
1728
+ move(-1);
1729
+ });
1730
+ downBtn.addEventListener('click', function () {
1731
+ // Don't call saveForm() here - move() handles async saving via queueSaveIfDirty
1732
+ move(1);
1733
+ });
1734
+
1735
+ if (addBtn) {
1736
+ addBtn.addEventListener('click', addNew);
1737
+ }
1738
+
1739
+ if (levelDownBtn) {
1740
+ levelDownBtn.addEventListener('click', levelDown);
1741
+ }
1742
+
1743
+ if (levelUpBtn) {
1744
+ levelUpBtn.addEventListener('click', levelUp);
1745
+ }
1746
+
1747
+ if (deleteBtn) {
1748
+ deleteBtn.addEventListener('click', function () {
1749
+ if (confirm(deleteBtn.dataset.confirm)) deleteCurrent(false);
1750
+ });
1751
+ }
1752
+
1753
+ if (deleteWithChildrenBtn) {
1754
+ deleteWithChildrenBtn.addEventListener('click', function () {
1755
+ if (confirm(deleteWithChildrenBtn.dataset.confirm)) deleteCurrent(true);
1756
+ });
1757
+ }
1758
+
1759
+ // Expose for testing
1760
+ window.creativeRowEditor = {
1761
+ setUploadsPending: (pending) => {
1762
+ uploadsPending = pending;
1763
+ if (pending) {
1764
+ uploadCompletionPromise = new Promise((resolve) => {
1765
+ resolveUploadCompletion = resolve;
1766
+ });
1767
+ } else if (resolveUploadCompletion) {
1768
+ resolveUploadCompletion();
1769
+ uploadCompletionPromise = null;
1770
+ resolveUploadCompletion = null;
1771
+ }
1772
+ handleUploadStateChange(pending);
1773
+ },
1774
+ resolveUploadCompletion: () => {
1775
+ if (resolveUploadCompletion) {
1776
+ resolveUploadCompletion();
1777
+ uploadsPending = false;
1778
+ uploadCompletionPromise = null;
1779
+ resolveUploadCompletion = null;
1780
+ handleUploadStateChange(false);
1781
+ }
1782
+ },
1783
+ isUploadPending: () => uploadsPending
1784
+ };
1785
+
1786
+ if (linkBtn) {
1787
+ linkBtn.addEventListener('click', linkExistingCreative);
1788
+ }
1789
+
1790
+ if (unlinkBtn) {
1791
+ unlinkBtn.addEventListener('click', function () {
1792
+ if (confirm(unlinkBtn.dataset.confirm)) deleteCurrent(false);
1793
+ });
1794
+ }
1795
+
1796
+ if (unconvertBtn) {
1797
+ unconvertBtn.addEventListener('click', function () {
1798
+ const creativeId = form.dataset.creativeId;
1799
+ if (!creativeId) return;
1800
+ const confirmText = unconvertBtn.dataset.confirm;
1801
+ if (confirmText && !confirm(confirmText)) return;
1802
+ const errorMessage = unconvertBtn.dataset.error || 'Failed to unconvert.';
1803
+ unconvertBtn.disabled = true;
1804
+ saveForm()
1805
+ .then(function (saveResponse) {
1806
+ if (saveResponse && saveResponse.ok === false) {
1807
+ return saveResponse
1808
+ .json()
1809
+ .catch(function () { return {}; })
1810
+ .then(function (data) {
1811
+ alert(data && data.error ? data.error : errorMessage);
1812
+ const error = new Error('Save failed');
1813
+ error._handled = true;
1814
+ throw error;
1815
+ });
1816
+ }
1817
+ return creativesApi.unconvert(creativeId);
1818
+ })
1819
+ .then(function (response) {
1820
+ if (response.ok) {
1821
+ location.reload();
1822
+ return;
1823
+ }
1824
+ return response
1825
+ .json()
1826
+ .catch(function () { return {}; })
1827
+ .then(function (data) {
1828
+ alert(data && data.error ? data.error : errorMessage);
1829
+ });
1830
+ })
1831
+ .catch(function (error) {
1832
+ if (error && error._handled) return;
1833
+ alert(errorMessage);
1834
+ })
1835
+ .finally(function () {
1836
+ unconvertBtn.disabled = false;
1837
+ });
1838
+ });
1839
+ }
1840
+ });
1841
+ }