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,31 @@
1
+ import csrfFetch from './csrf_fetch';
2
+
3
+ export function sendNewOrder({ draggedId, draggedIds, targetId, direction }) {
4
+ const payload = { target_id: targetId, direction };
5
+ if (Array.isArray(draggedIds) && draggedIds.length > 0) {
6
+ payload.dragged_ids = draggedIds;
7
+ } else {
8
+ payload.dragged_id = draggedId;
9
+ }
10
+
11
+ return csrfFetch('/creatives/reorder', {
12
+ method: 'POST',
13
+ headers: {
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ body: JSON.stringify(payload),
17
+ });
18
+ }
19
+
20
+ export function sendLinkedCreative({ draggedId, targetId, direction }) {
21
+ return csrfFetch('/creatives/link_drop', {
22
+ method: 'POST',
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ },
26
+ body: JSON.stringify({ dragged_id: draggedId, target_id: targetId, direction }),
27
+ }).then((response) => {
28
+ if (!response.ok) throw new Error('Failed to create linked creative');
29
+ return response.json();
30
+ });
31
+ }
@@ -0,0 +1,423 @@
1
+ import csrfFetch from './csrf_fetch'
2
+
3
+ const STORAGE_KEY = 'api_queue'
4
+ const MAX_RETRIES = 3
5
+
6
+ /**
7
+ * API Queue Manager
8
+ * Manages asynchronous API requests with localStorage persistence,
9
+ * sequential processing, retry logic, and deduplication.
10
+ */
11
+ class ApiQueueManager {
12
+ constructor() {
13
+ this.queue = []
14
+ this.failedItems = []
15
+ this.processing = false
16
+ this.storageKey = 'api_queue_guest' // Default fallback
17
+ this.setupNetworkListeners()
18
+ }
19
+
20
+ /**
21
+ * Initialize the queue for a specific user
22
+ * @param {string|number} userId - Current user ID
23
+ */
24
+ initialize(userId) {
25
+ this.userId = userId
26
+ this.storageKey = userId ? `api_queue_${userId}` : 'api_queue_guest'
27
+ this.loadFromLocalStorage()
28
+ this.loadFailedFromLocalStorage()
29
+ }
30
+
31
+ /**
32
+ * Load pending requests from localStorage
33
+ */
34
+ loadFromLocalStorage() {
35
+ try {
36
+ const stored = localStorage.getItem(this.storageKey)
37
+ if (stored) {
38
+ this.queue = JSON.parse(stored)
39
+ // Note: We don't auto-start processing here anymore.
40
+ // The consumer (creative_row_editor.js) must call start() explicitly
41
+ // after registering event listeners to avoid missing events.
42
+ } else {
43
+ this.queue = []
44
+ }
45
+ } catch (error) {
46
+ console.error('Failed to load API queue from localStorage:', error)
47
+ this.queue = []
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Load failed requests from localStorage so we don't lose them after reload
53
+ */
54
+ loadFailedFromLocalStorage() {
55
+ try {
56
+ const stored = localStorage.getItem(`${this.storageKey}_failed`)
57
+ if (stored) {
58
+ this.failedItems = JSON.parse(stored)
59
+ } else {
60
+ this.failedItems = []
61
+ }
62
+ } catch (error) {
63
+ console.error('Failed to load failed API queue from localStorage:', error)
64
+ this.failedItems = []
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Start processing the queue
70
+ * Should be called after event listeners are registered
71
+ */
72
+ start() {
73
+ if (this.queue.length > 0) {
74
+ this.processQueue()
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Save queue to localStorage
80
+ * Items with onSuccess callbacks are excluded because functions cannot be serialized
81
+ * Items with deletedAttachmentIds are included because they're serializable data
82
+ */
83
+ saveToLocalStorage() {
84
+ try {
85
+ // Filter out onSuccess callbacks (non-serializable)
86
+ // but keep the items themselves
87
+ const serializableQueue = this.queue.map(item => {
88
+ // eslint-disable-next-line no-unused-vars
89
+ const { onSuccess, ...serializableItem } = item
90
+ return serializableItem
91
+ })
92
+ localStorage.setItem(this.storageKey, JSON.stringify(serializableQueue))
93
+ } catch (error) {
94
+ console.error('Failed to save API queue to localStorage:', error)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Persist failed items separately so they can be surfaced to the user later
100
+ */
101
+ saveFailedToLocalStorage() {
102
+ try {
103
+ localStorage.setItem(`${this.storageKey}_failed`, JSON.stringify(this.failedItems))
104
+ } catch (error) {
105
+ console.error('Failed to save failed API queue to localStorage:', error)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Setup network status listeners
111
+ */
112
+ setupNetworkListeners() {
113
+ window.addEventListener('online', () => {
114
+ console.log('Network online - processing queue')
115
+ this.processQueue()
116
+ })
117
+
118
+ window.addEventListener('offline', () => {
119
+ console.log('Network offline - queue will resume when online')
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Add a request to the queue
125
+ * @param {Object} request - Request configuration
126
+ * @param {string} request.path - API path
127
+ * @param {string} request.method - HTTP method (GET, POST, PATCH, DELETE)
128
+ * @param {Object} request.params - URL parameters
129
+ * @param {Object} request.body - Request body
130
+ * @param {string} request.dedupeKey - Optional key for deduplication
131
+ * @param {Function} request.onSuccess - Optional callback to run after successful request
132
+ * @returns {string} Request ID
133
+ */
134
+ enqueue(request) {
135
+ // Find and merge callbacks and attachment IDs from existing requests with the same dedupeKey
136
+ let existingCallbacks = []
137
+ let existingAttachmentIds = []
138
+ if (request.dedupeKey) {
139
+ // CRITICAL: Skip the first item if processing is active
140
+ // The first item might be currently executing in processQueue
141
+ // Removing it would cause shift() to remove the wrong item
142
+ const startIndex = this.processing ? 1 : 0
143
+ const existingItems = this.queue.slice(startIndex).filter(item => item.dedupeKey === request.dedupeKey)
144
+
145
+ existingItems.forEach(item => {
146
+ if (typeof item.onSuccess === 'function') {
147
+ existingCallbacks.push(item.onSuccess)
148
+ }
149
+ if (item.deletedAttachmentIds && item.deletedAttachmentIds.length > 0) {
150
+ existingAttachmentIds.push(...item.deletedAttachmentIds)
151
+ }
152
+ })
153
+
154
+ // Remove existing requests with the same dedupeKey
155
+ // CRITICAL: Keep the first item if processing is active
156
+ if (this.processing) {
157
+ const firstItem = this.queue[0]
158
+ this.queue = [firstItem, ...this.queue.slice(1).filter(item => item.dedupeKey !== request.dedupeKey)]
159
+ } else {
160
+ this.queue = this.queue.filter(item => item.dedupeKey !== request.dedupeKey)
161
+ }
162
+ }
163
+
164
+ // Merge attachment IDs
165
+ let mergedAttachmentIds = null
166
+ if (request.deletedAttachmentIds && request.deletedAttachmentIds.length > 0) {
167
+ existingAttachmentIds.push(...request.deletedAttachmentIds)
168
+ }
169
+ if (existingAttachmentIds.length > 0) {
170
+ // Remove duplicates
171
+ mergedAttachmentIds = [...new Set(existingAttachmentIds)]
172
+ }
173
+
174
+ // Merge new callback with existing callbacks
175
+ let mergedCallback = null
176
+ if (existingCallbacks.length > 0 || request.onSuccess) {
177
+ mergedCallback = () => {
178
+ // Run all existing callbacks first
179
+ existingCallbacks.forEach(cb => {
180
+ try {
181
+ cb()
182
+ } catch (error) {
183
+ console.error('Merged callback failed:', error)
184
+ }
185
+ })
186
+ // Then run the new callback
187
+ if (typeof request.onSuccess === 'function') {
188
+ try {
189
+ request.onSuccess()
190
+ } catch (error) {
191
+ console.error('New callback failed:', error)
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ const queueItem = {
198
+ id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
199
+ path: request.path,
200
+ method: request.method || 'GET',
201
+ params: request.params || null,
202
+ body: request.body || null,
203
+ dedupeKey: request.dedupeKey || null,
204
+ deletedAttachmentIds: mergedAttachmentIds,
205
+ onSuccess: mergedCallback,
206
+ timestamp: Date.now(),
207
+ retries: 0
208
+ }
209
+
210
+ this.queue.push(queueItem)
211
+ this.saveToLocalStorage()
212
+
213
+ // Start processing if not already processing
214
+ this.processQueue()
215
+
216
+ return queueItem.id
217
+ }
218
+
219
+ /**
220
+ * Process all queued requests sequentially
221
+ */
222
+ async processQueue() {
223
+ if (this.processing || this.queue.length === 0) {
224
+ return
225
+ }
226
+
227
+ this.processing = true
228
+
229
+ while (this.queue.length > 0) {
230
+ const item = this.queue[0]
231
+
232
+ try {
233
+ await this.executeRequest(item)
234
+ // Success - handle cleanup actions
235
+
236
+ // Dispatch event for attachment cleanup if needed
237
+ if (item.deletedAttachmentIds && item.deletedAttachmentIds.length > 0) {
238
+ window.dispatchEvent(new CustomEvent('api-queue-attachments-deleted', {
239
+ detail: { attachmentIds: item.deletedAttachmentIds }
240
+ }))
241
+ }
242
+
243
+ // Call onSuccess callback if provided (for non-serializable actions)
244
+ if (typeof item.onSuccess === 'function') {
245
+ try {
246
+ item.onSuccess()
247
+ } catch (callbackError) {
248
+ console.error('onSuccess callback failed:', callbackError)
249
+ }
250
+ }
251
+
252
+ // Remove from queue
253
+ this.queue.shift()
254
+ this.saveToLocalStorage()
255
+ } catch (error) {
256
+ console.error('API request failed:', error, item)
257
+
258
+ // Retry logic
259
+ if (item.retries < MAX_RETRIES) {
260
+ item.retries++
261
+ // Move to end of queue for retry
262
+ this.queue.shift()
263
+ this.queue.push(item)
264
+ this.saveToLocalStorage()
265
+ } else {
266
+ // Max retries exceeded - move to failedItems for visibility
267
+ console.error('Max retries exceeded, moving request to failed items:', item)
268
+ const failedItem = { ...item, failedAt: Date.now(), lastError: error?.message }
269
+ this.failedItems.push(failedItem)
270
+ this.saveFailedToLocalStorage()
271
+ this.queue.shift()
272
+ this.saveToLocalStorage()
273
+ this.handleFailedRequest(failedItem, error)
274
+ }
275
+
276
+ // If network error, stop processing and wait for online event
277
+ if (!navigator.onLine) {
278
+ console.log('Network offline - pausing queue processing')
279
+ break
280
+ }
281
+ }
282
+ }
283
+
284
+ this.processing = false
285
+ }
286
+
287
+ /**
288
+ * Execute a single API request
289
+ * @param {Object} item - Queue item
290
+ * @returns {Promise<Response>}
291
+ */
292
+ async executeRequest(item) {
293
+ let url = item.path
294
+
295
+ // Add query parameters if present
296
+ if (item.params) {
297
+ const params = new URLSearchParams()
298
+ Object.keys(item.params).forEach(key => {
299
+ params.append(key, item.params[key])
300
+ })
301
+ const queryString = params.toString()
302
+ if (queryString) {
303
+ url = `${url}?${queryString}`
304
+ }
305
+ }
306
+
307
+ const options = {
308
+ method: item.method,
309
+ headers: {
310
+ 'Accept': 'application/json'
311
+ }
312
+ }
313
+
314
+ // Add body for POST/PATCH/PUT requests
315
+ if (item.body && ['POST', 'PATCH', 'PUT'].includes(item.method)) {
316
+ // If body is FormData, use it (or clone it if needed, but direct use is usually fine)
317
+ // We can iterate entries to be safe and consistent with previous fix intent
318
+ if (item.body instanceof FormData) {
319
+ const formData = new FormData()
320
+ // FormData.entries() iterator
321
+ for (const [key, value] of item.body.entries()) {
322
+ formData.append(key, value)
323
+ }
324
+ options.body = formData
325
+ }
326
+ // If body is a plain object, convert to FormData
327
+ else if (item.body && typeof item.body === 'object') {
328
+ const formData = new FormData()
329
+ Object.keys(item.body).forEach(key => {
330
+ const value = item.body[key]
331
+ if (value !== null && value !== undefined) {
332
+ formData.append(key, value)
333
+ }
334
+ })
335
+ options.body = formData
336
+ } else {
337
+ options.body = item.body
338
+ }
339
+ }
340
+
341
+ const response = await csrfFetch(url, options)
342
+
343
+ if (!response.ok) {
344
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
345
+ }
346
+
347
+ return response
348
+ }
349
+
350
+ /**
351
+ * Handle permanently failed requests
352
+ * @param {Object} item - Failed queue item
353
+ * @param {Error} error - Error object
354
+ */
355
+ handleFailedRequest(item, error) {
356
+ // Log to console or send to error tracking service
357
+ console.error('Request permanently failed:', {
358
+ item,
359
+ error: error.message,
360
+ timestamp: new Date().toISOString()
361
+ })
362
+
363
+ // Could dispatch a custom event for UI notification
364
+ window.dispatchEvent(new CustomEvent('api-queue-request-failed', {
365
+ detail: { item, error }
366
+ }))
367
+ }
368
+
369
+ /**
370
+ * Clear all queued requests
371
+ */
372
+ clear() {
373
+ this.queue = []
374
+ this.failedItems = []
375
+ this.saveToLocalStorage()
376
+ this.saveFailedToLocalStorage()
377
+ }
378
+
379
+ /**
380
+ * Remove items with a specific dedupe key
381
+ * @param {string} dedupeKey - Key to remove
382
+ */
383
+ removeByDedupeKey(dedupeKey) {
384
+ if (!dedupeKey) return
385
+
386
+ // Filter out items with the matching key
387
+ // CRITICAL: If processing is active, we must NOT remove the first item
388
+ // as it is currently being executed. Removing it would cause state inconsistency.
389
+ if (this.processing && this.queue.length > 0) {
390
+ const firstItem = this.queue[0]
391
+ // If the currently processing item matches, we can't stop it,
392
+ // but we can remove subsequent queued items.
393
+ const rest = this.queue.slice(1).filter(item => item.dedupeKey !== dedupeKey)
394
+ this.queue = [firstItem, ...rest]
395
+ } else {
396
+ this.queue = this.queue.filter(item => item.dedupeKey !== dedupeKey)
397
+ }
398
+ this.saveToLocalStorage()
399
+ }
400
+
401
+ /**
402
+ * Get current queue status
403
+ * @returns {Object} Queue status
404
+ */
405
+ getStatus() {
406
+ return {
407
+ queueLength: this.queue.length,
408
+ processing: this.processing,
409
+ failedLength: this.failedItems.length,
410
+ items: this.queue.map(item => ({
411
+ id: item.id,
412
+ path: item.path,
413
+ method: item.method,
414
+ retries: item.retries,
415
+ timestamp: item.timestamp
416
+ }))
417
+ }
418
+ }
419
+ }
420
+
421
+ // Export singleton instance
422
+ export const apiQueue = new ApiQueueManager()
423
+ export default apiQueue
@@ -0,0 +1,15 @@
1
+ import {syncLexicalStyleAttributes} from "./lexical/style_attributes"
2
+
3
+ export function applyLexicalStyles(root = document) {
4
+ syncLexicalStyleAttributes(root)
5
+ }
6
+
7
+ const handleLoad = (event) => {
8
+ const target = event?.target instanceof Element ? event.target : document
9
+ applyLexicalStyles(target)
10
+ }
11
+
12
+ document.addEventListener("turbo:load", handleLoad)
13
+ document.addEventListener("turbo:frame-load", handleLoad)
14
+
15
+ applyLexicalStyles(document)
@@ -0,0 +1,195 @@
1
+ export default class CommonPopup {
2
+ constructor(element, { listElement, onSelect, renderItem, onClose, closeOnOutsideClick = true } = {}) {
3
+ this.element = element
4
+ this.listElement = listElement || element?.querySelector('[data-popup-list]') || element?.querySelector('ul')
5
+ this.onSelect = onSelect || (() => { })
6
+ this.renderItem = renderItem || ((item) => item?.label || '')
7
+ this.onClose = onClose
8
+ this.closeOnOutsideClick = closeOnOutsideClick
9
+ this.items = []
10
+ this.activeIndex = -1
11
+
12
+ this.handleOutsideClick = this.handleOutsideClick.bind(this)
13
+ }
14
+
15
+ showAt(anchorRect) {
16
+ if (!this.element) return
17
+
18
+ this.element.style.display = 'block'
19
+ this.element.style.visibility = 'hidden'
20
+
21
+ requestAnimationFrame(() => {
22
+ this.updatePosition(anchorRect)
23
+ this.element.style.visibility = 'visible'
24
+ })
25
+
26
+ if (this.closeOnOutsideClick) {
27
+ document.addEventListener('mousedown', this.handleOutsideClick)
28
+ document.addEventListener('touchstart', this.handleOutsideClick)
29
+ }
30
+ }
31
+
32
+ updatePosition(anchorRect) {
33
+ if (!this.element) return
34
+
35
+ const scrollX = window.scrollX || window.pageXOffset || 0
36
+ const scrollY = window.scrollY || window.pageYOffset || 0
37
+ const offsetParent = this.element.offsetParent
38
+ const parentRect = offsetParent?.getBoundingClientRect?.() || { left: 0, top: 0 }
39
+ const parentScrollX = offsetParent?.scrollLeft || 0
40
+ const parentScrollY = offsetParent?.scrollTop || 0
41
+ const boundsPadding = 8
42
+ const rect = anchorRect || this.element.getBoundingClientRect()
43
+
44
+ let viewportLeft = (rect?.left || 0)
45
+ let viewportTop = (rect?.bottom || 0) + 4
46
+
47
+ const { offsetWidth: width, offsetHeight: height } = this.element
48
+ const maxLeft = window.innerWidth - width - boundsPadding
49
+ const maxTop = window.innerHeight - height - boundsPadding
50
+
51
+ viewportLeft = Math.max(boundsPadding, Math.min(viewportLeft, maxLeft))
52
+ viewportTop = Math.max(boundsPadding, Math.min(viewportTop, maxTop))
53
+
54
+ const left = viewportLeft - parentRect.left + parentScrollX
55
+ const top = viewportTop - parentRect.top + parentScrollY
56
+
57
+ this.element.style.left = `${left}px`
58
+ this.element.style.top = `${top}px`
59
+ }
60
+
61
+ setItems(items = []) {
62
+ this.items = items
63
+ if (!this.listElement) return
64
+
65
+ this.listElement.innerHTML = ''
66
+ items.forEach((item, index) => {
67
+ const li = document.createElement('li')
68
+ li.className = 'common-popup-item'
69
+ li.dataset.index = String(index)
70
+ li.innerHTML = this.renderItem(item, index)
71
+ li.addEventListener('mouseenter', () => this.setActiveIndex(index))
72
+ li.addEventListener('mousedown', (event) => event.preventDefault())
73
+ li.addEventListener('click', () => this.handleItemSelect(index))
74
+
75
+ // Handle touch events for mobile stability (prevent keyboard dismissal/double-tap issues)
76
+ let touchStartY = null
77
+ li.addEventListener('touchstart', (e) => {
78
+ touchStartY = e.touches[0].clientY
79
+ }, { passive: false })
80
+
81
+ li.addEventListener('touchend', (e) => {
82
+ if (touchStartY === null) return
83
+ const diffY = Math.abs(e.changedTouches[0].clientY - touchStartY)
84
+ if (diffY < 10) { // Threshold for tap vs scroll
85
+ e.preventDefault()
86
+ this.handleItemSelect(index)
87
+ }
88
+ touchStartY = null
89
+ })
90
+
91
+ this.listElement.appendChild(li)
92
+ })
93
+
94
+ this.activeIndex = items.length > 0 ? 0 : -1
95
+ this.updateActiveItem()
96
+ }
97
+
98
+ handleItemSelect(index) {
99
+ if (index < 0 || index >= this.items.length) return
100
+ this.activeIndex = index
101
+ this.updateActiveItem()
102
+ const item = this.items[index]
103
+ if (item) {
104
+ this.onSelect(item)
105
+ }
106
+ }
107
+
108
+ setActiveIndex(index) {
109
+ if (this.items.length === 0) {
110
+ this.activeIndex = -1
111
+ this.updateActiveItem()
112
+ return
113
+ }
114
+
115
+ if (index < 0) {
116
+ this.activeIndex = this.items.length - 1
117
+ } else {
118
+ this.activeIndex = index % this.items.length
119
+ }
120
+ this.updateActiveItem()
121
+ }
122
+
123
+ updateActiveItem() {
124
+ if (!this.listElement) return
125
+ const items = Array.from(this.listElement.children)
126
+ items.forEach((item, index) => {
127
+ item.classList.toggle('active', index === this.activeIndex)
128
+ })
129
+
130
+ const activeItem = items[this.activeIndex]
131
+ if (activeItem && activeItem.scrollIntoView) {
132
+ activeItem.scrollIntoView({ block: 'nearest' })
133
+ }
134
+ }
135
+
136
+ handleKey(event) {
137
+ if (!this.isOpen() || this.items.length === 0) return false
138
+
139
+ const key = event.key
140
+ const isCtrl = event.ctrlKey || event.metaKey
141
+ const lowered = key?.toLowerCase?.() || key
142
+
143
+ if (key === 'Tab' || key === 'Enter') {
144
+ event.preventDefault()
145
+ this.handleItemSelect(this.activeIndex)
146
+ return true
147
+ }
148
+
149
+ if (key === 'ArrowDown' || (isCtrl && lowered === 'n')) {
150
+ event.preventDefault()
151
+ this.setActiveIndex(this.activeIndex + 1)
152
+ return true
153
+ }
154
+
155
+ if (key === 'ArrowUp' || (isCtrl && lowered === 'p')) {
156
+ event.preventDefault()
157
+ this.setActiveIndex(this.activeIndex - 1)
158
+ return true
159
+ }
160
+
161
+ if (key === 'Escape') {
162
+ this.hide('escape')
163
+ return true
164
+ }
165
+
166
+ return false
167
+ }
168
+
169
+ hide(reason = 'manual') {
170
+ if (!this.element || !this.isOpen()) return
171
+ this.element.style.display = 'none'
172
+ this.element.style.visibility = ''
173
+ this.items = []
174
+ this.activeIndex = -1
175
+ this.updateActiveItem()
176
+
177
+ document.removeEventListener('mousedown', this.handleOutsideClick)
178
+ document.removeEventListener('touchstart', this.handleOutsideClick)
179
+
180
+ if (typeof this.onClose === 'function') {
181
+ this.onClose(reason)
182
+ }
183
+ }
184
+
185
+ handleOutsideClick(event) {
186
+ if (!this.element) return
187
+ if (!this.element.contains(event.target)) {
188
+ this.hide('outside')
189
+ }
190
+ }
191
+
192
+ isOpen() {
193
+ return this.element && this.element.style.display === 'block'
194
+ }
195
+ }