collavre 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -0,0 +1,119 @@
1
+ module Collavre
2
+ module UsersController::AiUserManagement
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :set_user_for_ai_actions, only: [ :edit_ai, :update_ai ]
7
+ before_action :verify_ai_user, only: [ :edit_ai, :update_ai ]
8
+ before_action :verify_ai_user_authorization, only: [ :update_ai ]
9
+ end
10
+
11
+ def new_ai
12
+ @available_tools = load_available_tools
13
+
14
+ if params[:copy_from].present?
15
+ source = Collavre::User.find_by(id: params[:copy_from])
16
+ if source&.ai_user? && source.created_by_id == Current.user.id
17
+ @copy_source = source
18
+ @copy_name = "#{source.name} (copy)"
19
+ end
20
+ end
21
+ end
22
+
23
+ def create_ai
24
+ ai_id = params[:ai_id].to_s.strip.downcase
25
+ email = "#{ai_id}@ai.local"
26
+ searchable = ActiveModel::Type::Boolean.new.cast(params.fetch(:searchable, false))
27
+
28
+ @user = Collavre::User.new(
29
+ name: params[:name],
30
+ email: email,
31
+ password: SecureRandom.hex(36),
32
+ system_prompt: params[:system_prompt],
33
+ llm_vendor: params[:llm_vendor].presence || "google",
34
+ llm_model: params[:llm_model],
35
+ llm_api_key: params[:llm_api_key],
36
+ gateway_url: params[:gateway_url],
37
+ tools: params[:tools] || [],
38
+ searchable: searchable,
39
+ email_verified_at: Time.current,
40
+ created_by_id: Current.user.id,
41
+ routing_expression: params[:routing_expression]
42
+ )
43
+ @user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?
44
+
45
+ if @user.save
46
+ Collavre::Contact.ensure(user: Current.user, contact_user: @user)
47
+ share_ai_agent_to_creative(@user, params[:creative_id])
48
+ redirect_to user_path(Current.user, tab: "contacts"), notice: I18n.t("collavre.users.create_ai.success")
49
+ else
50
+ flash.now[:alert] = @user.errors.full_messages.to_sentence
51
+ @available_tools = load_available_tools
52
+ render :new_ai, status: :unprocessable_entity
53
+ end
54
+ end
55
+
56
+ def edit_ai
57
+ @available_tools = load_available_tools
58
+ end
59
+
60
+ def update_ai
61
+ ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])
62
+
63
+ if @user.update(ai_params)
64
+ redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
65
+ else
66
+ @available_tools = load_available_tools
67
+ flash.now[:alert] = @user.errors.full_messages.to_sentence
68
+ render :edit_ai, status: :unprocessable_entity
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def load_available_tools
75
+ Collavre::McpService.available_tools(Current.user).map do |tool|
76
+ {
77
+ name: tool[:name],
78
+ description: tool[:description],
79
+ parameters: tool[:params]
80
+ }
81
+ end
82
+ end
83
+
84
+ def set_user_for_ai_actions
85
+ @user = Collavre::User.find(params[:id])
86
+ end
87
+
88
+ def verify_ai_user
89
+ unless @user.ai_user?
90
+ redirect_to user_path(@user), alert: I18n.t("collavre.users.edit_ai.not_an_ai")
91
+ end
92
+ end
93
+
94
+ def verify_ai_user_authorization
95
+ allowed = Current.user.system_admin? ||
96
+ (@user.ai_user? && @user.created_by_id == Current.user.id)
97
+
98
+ unless allowed
99
+ fallback = user_path(Current.user, tab: "contacts")
100
+ redirect_back fallback_location: fallback, alert: I18n.t("collavre.users.destroy.not_authorized")
101
+ end
102
+ end
103
+
104
+ def share_ai_agent_to_creative(user, creative_id)
105
+ return if creative_id.blank?
106
+
107
+ creative = Collavre::Creative.find_by(id: creative_id)
108
+ return unless creative&.has_permission?(Current.user, :admin)
109
+
110
+ Collavre::CreativeShare.find_or_create_by!(
111
+ creative: creative,
112
+ user: user
113
+ ) do |share|
114
+ share.shared_by = Current.user
115
+ share.permission = :feedback
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,166 @@
1
+ module Collavre
2
+ module UsersController::ContactManagement
3
+ extend ActiveSupport::Concern
4
+
5
+ def search
6
+ term = params[:q].to_s.strip.downcase
7
+
8
+ if term.blank? && params[:scope] != "contacts"
9
+ return render json: []
10
+ end
11
+
12
+ creative = Collavre::Creative.find_by(id: params[:creative_id])
13
+
14
+ if creative.present? && !creative.has_permission?(Current.user, :read)
15
+ head :forbidden and return
16
+ end
17
+
18
+ scope = if params[:scope] == "contacts" && Current.user
19
+ # Include both contacts and searchable users (e.g., AI agents with searchable=true)
20
+ contact_ids = Current.user.contact_users.select(:id)
21
+ searchable_ids = Collavre::User.where(searchable: true).select(:id)
22
+ Collavre::User.where(id: contact_ids).or(Collavre::User.where(id: searchable_ids))
23
+ else
24
+ Collavre::User.mentionable_for(creative)
25
+ end
26
+
27
+ users = scope
28
+ if term.present?
29
+ users = users.where("LOWER(users.email) LIKE :term OR LOWER(users.name) LIKE :term", term: "#{term}%")
30
+ end
31
+
32
+ limit = params[:limit].to_i
33
+ limit = 20 if limit <= 0
34
+ limit = 50 if limit > 50
35
+
36
+ user_ids = users.select(:id).distinct.limit(limit).pluck(:id)
37
+ users = Collavre::User.where(id: user_ids)
38
+ render json: users.map { |u| { id: u.id, name: u.display_name, email: u.email, avatar_url: view_context.user_avatar_url(u, size: 20) } }
39
+ end
40
+
41
+ private
42
+
43
+ def prepare_contacts
44
+ per_page = 20
45
+ @contact_page = [ params[:contact_page].to_i, 1 ].max
46
+
47
+ creative_shares = Collavre::CreativeShare.arel_table
48
+ creatives = Collavre::Creative.arel_table
49
+
50
+ user_creative_origins_sql = Collavre::Creative
51
+ .where(user_id: Current.user.id)
52
+ .select("COALESCE(origin_id, id) AS origin_id")
53
+ .to_sql
54
+
55
+ shared_by_me_scope = Collavre::CreativeShare
56
+ .joins(:creative)
57
+ .where.not(permission: Collavre::CreativeShare.permissions[:no_access])
58
+ .where(
59
+ creative_shares[:shared_by_id].eq(Current.user.id)
60
+ .or(
61
+ creative_shares[:shared_by_id].eq(nil).and(creatives[:user_id].eq(Current.user.id))
62
+ )
63
+ .or(
64
+ creatives[:id].in(Arel.sql("(#{user_creative_origins_sql})"))
65
+ )
66
+ )
67
+
68
+ shared_with_me_scope = Collavre::CreativeShare
69
+ .joins(:creative)
70
+ .where(user_id: Current.user.id)
71
+ .where.not(permission: Collavre::CreativeShare.permissions[:no_access])
72
+
73
+ contact_ids_sql = [
74
+ Current.user.contacts.select("contact_user_id AS user_id").to_sql,
75
+ shared_by_me_scope.select("creative_shares.user_id AS user_id").to_sql,
76
+ shared_with_me_scope.select("COALESCE(creative_shares.shared_by_id, creatives.user_id) AS user_id").to_sql
77
+ ].join(" UNION ")
78
+
79
+ contact_users_relation = Collavre::User.where(
80
+ id: Collavre::User.from("(#{contact_ids_sql}) AS contact_ids").select(:user_id)
81
+ )
82
+
83
+ @total_contacts = contact_users_relation.count
84
+ @total_contact_pages = [ (@total_contacts.to_f / per_page).ceil, 1 ].max
85
+ paged_users = contact_users_relation
86
+ .includes(avatar_attachment: :blob)
87
+ .order(:name, :id)
88
+ .offset((@contact_page - 1) * per_page)
89
+ .limit(per_page)
90
+
91
+ existing_contacts = Current.user.contacts.includes(contact_user: [ avatar_attachment: :blob ]).index_by(&:contact_user_id)
92
+ @contacts = paged_users.map do |user|
93
+ existing_contacts[user.id] || Collavre::Contact.new(user: Current.user, contact_user: user)
94
+ end
95
+
96
+ shares_from_me = shared_by_me_scope
97
+ .where(user_id: paged_users.map(&:id))
98
+ .includes(:creative)
99
+
100
+ @shared_by_me = shares_from_me.group_by(&:user_id).transform_values { |shares| shares.map(&:creative) }
101
+
102
+ shares_to_me = Collavre::CreativeShare
103
+ .joins(:creative)
104
+ .where(user_id: Current.user.id)
105
+ .where.not(permission: Collavre::CreativeShare.permissions[:no_access])
106
+ .where(
107
+ creative_shares[:shared_by_id].in(paged_users.map(&:id))
108
+ .or(creative_shares[:shared_by_id].eq(nil).and(creatives[:user_id].in(paged_users.map(&:id))))
109
+ )
110
+ .includes(:creative)
111
+
112
+ @shared_with_me = shares_to_me.group_by(&:sharer_id)
113
+ .transform_values { |shares| shares.map(&:creative) }
114
+ end
115
+
116
+ def prepare_org_chart
117
+ # 1. Creatives with actual shares relevant to current user
118
+ shared_creative_ids = Collavre::Creative.shared_accessible_ids(Current.user)
119
+
120
+ # 2. Walk up ancestor chains to build full paths (A > B > C)
121
+ all_tree_ids = Set.new(shared_creative_ids)
122
+ Collavre::Creative.where(id: shared_creative_ids).find_each do |creative|
123
+ creative.ancestors.each { |a| all_tree_ids.add(a.id) }
124
+ end
125
+
126
+ # 3. Build the tree
127
+ all_creatives = Collavre::Creative.where(id: all_tree_ids.to_a).order(:sequence, :id)
128
+ @org_chart_roots = all_creatives.select { |c| c.parent_id.nil? || !all_tree_ids.include?(c.parent_id) }
129
+ @org_chart_children = all_creatives.select { |c| c.parent_id.present? && all_tree_ids.include?(c.parent_id) }.group_by(&:parent_id)
130
+
131
+ # 4. Preload shares (including origin shares for linked creatives)
132
+ origin_ids = all_creatives.filter_map(&:origin_id).uniq
133
+ share_lookup_ids = (all_tree_ids.to_a + origin_ids).uniq
134
+ shares = Collavre::CreativeShare
135
+ .where(creative_id: share_lookup_ids)
136
+ .includes(user: [ avatar_attachment: :blob ], shared_by: [ avatar_attachment: :blob ])
137
+ shares_by_creative = shares.group_by(&:creative_id)
138
+
139
+ # Map shares: linked creatives inherit from origin if they have no direct shares
140
+ @org_chart_shares = {}
141
+ all_creatives.each do |c|
142
+ direct = shares_by_creative.fetch(c.id, [])
143
+ if direct.empty? && c.origin_id.present?
144
+ @org_chart_shares[c.id] = shares_by_creative.fetch(c.origin_id, [])
145
+ else
146
+ @org_chart_shares[c.id] = direct
147
+ end
148
+ end
149
+
150
+ # 5. Preload pending invitations
151
+ @org_chart_invitations = Collavre::Invitation
152
+ .where(creative_id: all_tree_ids.to_a, accepted_at: nil)
153
+ .where("expires_at > ?", Time.current)
154
+ .order(created_at: :desc)
155
+ .group_by(&:creative_id)
156
+
157
+ # 6. Unassigned AI Agents: owned by current user but not in any CreativeShare
158
+ assigned_user_ids = shares.map(&:user_id).uniq
159
+ @org_chart_unassigned = Collavre::User.where(created_by_id: Current.user.id)
160
+ .where.not(id: assigned_user_ids)
161
+ .where.not(llm_vendor: [ nil, "" ])
162
+ .includes(avatar_attachment: :blob)
163
+ .order(:name)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,102 @@
1
+ module Collavre
2
+ module UsersController::ProfileAndSettings
3
+ extend ActiveSupport::Concern
4
+
5
+ def show
6
+ @user = Collavre::User.find(params[:id])
7
+ @active_tab = params[:tab].presence || "profile"
8
+ @active_tab = "contacts" if @active_tab == "org_chart"
9
+ @contacts_view = params[:contacts_view].presence || "list"
10
+ if Current.user
11
+ if @contacts_view == "org_chart"
12
+ prepare_org_chart
13
+ else
14
+ prepare_contacts
15
+ end
16
+ else
17
+ @contacts = []
18
+ @shared_by_me = {}
19
+ @shared_with_me = {}
20
+ @total_contact_pages = 1
21
+ @contact_page = 1
22
+ @org_chart_roots = []
23
+ @org_chart_shares = {}
24
+ @org_chart_invitations = {}
25
+ @org_chart_children = {}
26
+ @org_chart_unassigned = []
27
+ end
28
+ end
29
+
30
+ def update
31
+ @user = Collavre::User.find(params[:id])
32
+ if @user.update(profile_params)
33
+ redirect_to user_path(@user), notice: I18n.t("collavre.users.profile_updated")
34
+ else
35
+ render :show, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def notification_settings
40
+ if Current.user.update(notification_settings_params)
41
+ head :no_content
42
+ else
43
+ render json: { errors: Current.user.errors.full_messages }, status: :unprocessable_entity
44
+ end
45
+ end
46
+
47
+ def edit_password
48
+ @user = Collavre::User.find(params[:id])
49
+ end
50
+
51
+ def passkeys
52
+ @user = Collavre::User.find(params[:id])
53
+
54
+ unless @user == Current.user || Current.user.system_admin?
55
+ redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
56
+ end
57
+ end
58
+
59
+ def update_password
60
+ @user = Collavre::User.find(params[:id])
61
+ if @user.authenticate(params[:user][:current_password])
62
+ if @user.update(user_params)
63
+ redirect_to user_path(@user), notice: I18n.t("collavre.users.password_updated")
64
+ else
65
+ flash.now[:alert] = I18n.t("collavre.users.password_update_failed")
66
+ render :edit_password, status: :unprocessable_entity
67
+ end
68
+ else
69
+ @user.errors.add(:current_password, I18n.t("collavre.users.current_password_incorrect"))
70
+ flash.now[:alert] = I18n.t("collavre.users.password_update_failed")
71
+ render :edit_password, status: :unprocessable_entity
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def user_params
78
+ params.require(:user).permit(:email, :password, :password_confirmation, :name)
79
+ end
80
+
81
+ def profile_params
82
+ params.require(:user).permit(
83
+ :avatar,
84
+ :avatar_url,
85
+ :display_level,
86
+ :completion_mark,
87
+ :theme,
88
+ :name,
89
+ :notifications_enabled,
90
+ :calendar_id,
91
+ :timezone,
92
+ :locale
93
+ ).tap do |p|
94
+ p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
95
+ end
96
+ end
97
+
98
+ def notification_settings_params
99
+ params.require(:user).permit(:notifications_enabled)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,63 @@
1
+ module Collavre
2
+ module UsersController::Registration
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ allow_unauthenticated_access only: %i[new create exists]
7
+ before_action -> { enforce_auth_provider!(:email) }, only: [ :new, :create ]
8
+ end
9
+
10
+ def new
11
+ @user = Collavre::User.new
12
+ if params[:invite_token].present?
13
+ @invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
14
+ @user.email = @invitation&.email
15
+ end
16
+ end
17
+
18
+ def create
19
+ @user = Collavre::User.new(user_params)
20
+ Collavre::Invitation.transaction do
21
+ if params[:invite_token].present?
22
+ invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
23
+ if invitation
24
+ @invitation = invitation
25
+ @user.email = invitation.email
26
+ end
27
+ end
28
+ if @user.save
29
+ if invitation
30
+ invitation.update(accepted_at: Time.current)
31
+ Collavre::CreativeShare.create!(
32
+ creative: invitation.creative,
33
+ user: @user,
34
+ permission: invitation.permission,
35
+ shared_by: invitation.inviter
36
+ )
37
+ Collavre::Contact.ensure(user: invitation.inviter, contact_user: @user)
38
+ invitation.creative.create_linked_creative_for_user(@user)
39
+ end
40
+ Collavre::EmailVerificationMailer.verify(@user).deliver_later
41
+ session.delete(:return_to_after_authenticating)
42
+ redirect_to new_session_path, notice: I18n.t("collavre.users.new.success_sign_up")
43
+ else
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
47
+ flash.now[:alert] = I18n.t("collavre.invites.invalid")
48
+ render :new, status: :unprocessable_entity
49
+ end
50
+ end
51
+
52
+ def exists
53
+ user = Collavre::User.find_by(email: params[:email])
54
+ render json: { exists: user.present? }
55
+ end
56
+
57
+ private
58
+
59
+ def user_params
60
+ params.require(:user).permit(:email, :password, :password_confirmation, :name)
61
+ end
62
+ end
63
+ end
@@ -36,6 +36,7 @@ module Collavre
36
36
  styles << render_theme_media_query(dark_theme, "dark")
37
37
  end
38
38
 
39
+ # Safe: CSS generated from admin-configured theme settings (CSS variables only)
39
40
  styles.join("\n").html_safe # rubocop:disable Rails/OutputSafety
40
41
  end
41
42
 
@@ -1,10 +1,5 @@
1
1
  module Collavre
2
2
  module CreativesHelper
3
- # Shared toggle button symbol helper
4
- def toggle_button_symbol(expanded: false)
5
- expanded ? "\u25BC" : "\u25B6" # ▼ or ▶
6
- end
7
-
8
3
  def render_tags(labels, class_name = nil, name_only = false)
9
4
  return "" if labels&.empty? or labels.nil?
10
5
 
@@ -13,8 +8,11 @@ module Collavre
13
8
  suffix = " 🗓#{label.target_date}" if label.type == "Plan" and !name_only
14
9
  index += 1
15
10
  content_tag(:span, class: "tag") do
16
- (index == 1 ? "" : " ").html_safe +
17
- link_to("##{strip_tags(label.name)}", collavre.creatives_path(tags: [ label.id ]), class: class_name ? class_name: "", title: strip_tags(label.name)) + suffix
11
+ safe_join([
12
+ (index == 1 ? "" : " "),
13
+ link_to("##{strip_tags(label.name)}", collavre.creatives_path(tags: [ label.id ]), class: class_name ? class_name: "", title: strip_tags(label.name)),
14
+ suffix
15
+ ].compact)
18
16
  end
19
17
  end)
20
18
  end
@@ -67,9 +65,14 @@ module Collavre
67
65
  class: classes.join(" ")
68
66
  )
69
67
  else
70
- "".html_safe
68
+ safe_join([])
71
69
  end
72
- render_progress_value(progress_value) + comment_part + "<br />".html_safe + (creative.tags ? render_creative_tags(creative) : "".html_safe)
70
+ safe_join([
71
+ render_progress_value(progress_value),
72
+ comment_part,
73
+ tag.br,
74
+ (creative.tags ? render_creative_tags(creative) : safe_join([]))
75
+ ])
73
76
  end
74
77
  end
75
78
 
@@ -103,7 +103,7 @@ module Collavre
103
103
 
104
104
  def render_nav_raw(item)
105
105
  content = resolve_nav_value(item[:content])
106
- return "".html_safe if content.nil?
106
+ return safe_join([]) if content.nil?
107
107
  content.respond_to?(:html_safe?) && content.html_safe? ? content : ERB::Util.html_escape(content)
108
108
  end
109
109
 
@@ -11,7 +11,6 @@ import "./modules/export_to_markdown"
11
11
  import "./modules/plans_menu"
12
12
  import "./modules/inbox_panel"
13
13
  import "./modules/creative_guide"
14
- import "./modules/share_modal"
15
14
  import "./modules/creative_row_editor"
16
15
  import "./modules/slide_view"
17
16
 
@@ -8,7 +8,7 @@ if (!window._streamingCommentIds) window._streamingCommentIds = new Set()
8
8
 
9
9
  // Connects to data-controller="comment"
10
10
  export default class extends Controller {
11
- static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls", "reviewButton", "replaceButton"]
11
+ static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
12
12
 
13
13
  get _commentId() {
14
14
  return this.element.dataset.commentId
@@ -123,10 +123,6 @@ export default class extends Controller {
123
123
  this.handleMouseUp = this.handleMouseUp.bind(this)
124
124
  this.element.addEventListener('mouseup', this.handleMouseUp)
125
125
 
126
- // Bound handlers for review/replace buttons (stored for cleanup)
127
- this._boundReviewClick = this._onReviewClick.bind(this)
128
- this._boundReplaceClick = this._onReplaceClick.bind(this)
129
-
130
126
  this.currentUserId = document.body.dataset.currentUserId
131
127
  const commentAuthorId = this.element.dataset.userId
132
128
  const creativeOwnerId = this.element.dataset.creativeOwnerId
@@ -228,7 +224,6 @@ export default class extends Controller {
228
224
  this._streamingTimeout = null
229
225
  }
230
226
  this.element.removeEventListener('mouseup', this.handleMouseUp)
231
- this._removeSelectionChangeListener()
232
227
  this.hideReviewPopup()
233
228
  if (this._reviewPopupEl) {
234
229
  this._reviewPopupEl.remove()
@@ -291,7 +286,7 @@ export default class extends Controller {
291
286
  const commentId = this.element.dataset.commentId
292
287
  const formController = this.findFormController()
293
288
  if (formController) {
294
- formController.quoteComment(commentId, selectedText)
289
+ formController.appendReviewQuote(commentId, selectedText)
295
290
  }
296
291
  window.getSelection().removeAllRanges()
297
292
  this.hideReviewPopup()
@@ -324,52 +319,43 @@ export default class extends Controller {
324
319
  return this.application.getControllerForElementAndIdentifier(popup, 'comments--form')
325
320
  }
326
321
 
327
- reviewButtonTargetConnected(button) {
328
- button.addEventListener('click', this._boundReviewClick)
329
- }
330
-
331
- reviewButtonTargetDisconnected(button) {
332
- button.removeEventListener('click', this._boundReviewClick)
333
- }
334
-
335
- replaceButtonTargetConnected(button) {
336
- button.addEventListener('click', this._boundReplaceClick)
337
- // Only listen for selectionchange when a replace button exists
338
- this._addSelectionChangeListener()
339
- }
340
-
341
- replaceButtonTargetDisconnected(button) {
342
- button.removeEventListener('click', this._boundReplaceClick)
343
- if (!this.hasReplaceButtonTarget) {
344
- this._removeSelectionChangeListener()
345
- }
346
- }
347
-
348
- _onReviewClick(event) {
322
+ // Stimulus action: click->comment#reviewClick (bound via data-action in template)
323
+ reviewClick(event) {
349
324
  event.preventDefault()
350
- event.stopPropagation()
351
- const commentId = this.element.dataset.commentId
352
- const contentEl = this.element.querySelector('.comment-content')
353
- const fullText = contentEl ? contentEl.textContent.trim() : ''
354
- const formController = this.findFormController()
355
- if (formController && fullText) {
356
- formController.quoteComment(commentId, fullText)
357
- }
358
- }
359
-
360
- _onReplaceClick(event) {
361
- event.preventDefault()
362
- event.stopPropagation()
363
325
  const selectedText = this._getSelectedTextInContent()
364
- if (!selectedText) return
365
-
326
+ if (!selectedText) {
327
+ this._showReviewHint(event.currentTarget)
328
+ return
329
+ }
366
330
  const commentId = this.element.dataset.commentId
367
331
  const formController = this.findFormController()
368
332
  if (formController) {
369
- formController.quoteComment(commentId, selectedText)
333
+ formController.appendReviewQuote(commentId, selectedText)
334
+ const textarea = formController.textareaTarget
335
+ if (textarea) {
336
+ textarea.scrollIntoView({ behavior: 'smooth', block: 'center' })
337
+ textarea.focus()
338
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length
339
+ }
370
340
  }
371
341
  window.getSelection().removeAllRanges()
372
- this._updateReplaceButton()
342
+ }
343
+
344
+ _showReviewHint(button) {
345
+ // Remove any existing hint
346
+ document.querySelectorAll('.review-hint').forEach(el => el.remove())
347
+
348
+ // Show hint popup near the button using fixed positioning
349
+ const rect = button.getBoundingClientRect()
350
+ const hint = document.createElement('div')
351
+ hint.className = 'review-hint'
352
+ hint.textContent = button.dataset.hintText || 'Select text to review'
353
+ hint.style.position = 'fixed'
354
+ hint.style.top = `${rect.top - 8}px`
355
+ hint.style.left = `${rect.left + rect.width / 2}px`
356
+ hint.style.transform = 'translate(-50%, -100%)'
357
+ document.body.appendChild(hint)
358
+ hint.addEventListener('animationend', () => hint.remove(), { once: true })
373
359
  }
374
360
 
375
361
  _getSelectedTextInContent() {
@@ -388,30 +374,7 @@ export default class extends Controller {
388
374
  return text
389
375
  }
390
376
 
391
- _addSelectionChangeListener() {
392
- if (this._selectionChangeActive) return
393
- this._handleSelectionChange = this._handleSelectionChange.bind(this)
394
- document.addEventListener('selectionchange', this._handleSelectionChange)
395
- this._selectionChangeActive = true
396
- }
397
-
398
- _removeSelectionChangeListener() {
399
- if (!this._selectionChangeActive) return
400
- document.removeEventListener('selectionchange', this._handleSelectionChange)
401
- this._selectionChangeActive = false
402
- }
403
-
404
- _handleSelectionChange() {
405
- this._updateReplaceButton()
406
- }
407
-
408
- _updateReplaceButton() {
409
- if (!this.hasReplaceButtonTarget) return
410
- const hasSelection = !!this._getSelectedTextInContent()
411
- this.replaceButtonTargets.forEach((btn) => {
412
- btn.disabled = !hasSelection
413
- })
414
- }
377
+ // Selection change listener and replace button logic removed — unified into review
415
378
 
416
379
  updateReactionsUI(reactionsData) {
417
380
  let reactionsContainer = this.element.querySelector('.comment-reactions')