collavre 0.3.2 → 0.5.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +73 -71
  3. data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
  4. data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
  5. data/app/assets/stylesheets/collavre/creatives.css +101 -51
  6. data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
  7. data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
  8. data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
  9. data/app/assets/stylesheets/collavre/popup.css +57 -27
  10. data/app/assets/stylesheets/collavre/slide_view.css +6 -6
  11. data/app/assets/stylesheets/collavre/user_menu.css +4 -5
  12. data/app/components/collavre/plans_timeline_component.html.erb +2 -2
  13. data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
  14. data/app/controllers/collavre/admin/settings_controller.rb +199 -0
  15. data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
  16. data/app/controllers/collavre/comments_controller.rb +39 -162
  17. data/app/controllers/collavre/creatives_controller.rb +18 -58
  18. data/app/controllers/collavre/users_controller.rb +31 -3
  19. data/app/helpers/collavre/application_helper.rb +97 -0
  20. data/app/helpers/collavre/creatives_helper.rb +10 -202
  21. data/app/javascript/collavre.js +0 -1
  22. data/app/javascript/components/creative_tree_row.js +3 -2
  23. data/app/javascript/controllers/comment_controller.js +309 -4
  24. data/app/javascript/controllers/comments/form_controller.js +52 -0
  25. data/app/javascript/controllers/comments/presence_controller.js +13 -0
  26. data/app/javascript/controllers/creatives/tree_controller.js +2 -1
  27. data/app/javascript/controllers/link_creative_controller.js +29 -3
  28. data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
  29. data/app/javascript/lib/html_code_block_wrapper.js +168 -0
  30. data/app/javascript/lib/utils/markdown.js +2 -1
  31. data/app/javascript/modules/creative_row_editor.js +5 -1
  32. data/app/javascript/utils/emoji_parser.js +21 -0
  33. data/app/jobs/collavre/ai_agent_job.rb +6 -2
  34. data/app/jobs/collavre/cron_action_job.rb +18 -6
  35. data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
  36. data/app/models/collavre/comment/approvable.rb +50 -0
  37. data/app/models/collavre/comment/broadcastable.rb +119 -0
  38. data/app/models/collavre/comment/notifiable.rb +111 -0
  39. data/app/models/collavre/comment.rb +13 -258
  40. data/app/models/collavre/comment_reaction.rb +15 -0
  41. data/app/models/collavre/creative/describable.rb +86 -0
  42. data/app/models/collavre/creative/linkable.rb +77 -0
  43. data/app/models/collavre/creative/permissible.rb +103 -0
  44. data/app/models/collavre/creative.rb +3 -289
  45. data/app/models/collavre/orchestrator_policy.rb +1 -1
  46. data/app/models/collavre/system_setting.rb +27 -1
  47. data/app/models/collavre/user.rb +42 -0
  48. data/app/models/collavre/user_theme.rb +10 -0
  49. data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
  50. data/app/services/collavre/ai_agent/message_builder.rb +129 -0
  51. data/app/services/collavre/ai_agent/review_handler.rb +70 -0
  52. data/app/services/collavre/ai_agent_service.rb +93 -150
  53. data/app/services/collavre/ai_client.rb +23 -4
  54. data/app/services/collavre/auto_theme_generator.rb +168 -50
  55. data/app/services/collavre/command_menu_service.rb +70 -0
  56. data/app/services/collavre/comment_move_service.rb +94 -0
  57. data/app/services/collavre/comments/action_executor.rb +10 -0
  58. data/app/services/collavre/comments/mcp_command.rb +1 -2
  59. data/app/services/collavre/creatives/create_service.rb +86 -0
  60. data/app/services/collavre/creatives/destroy_service.rb +41 -0
  61. data/app/services/collavre/creatives/index_query.rb +3 -0
  62. data/app/services/collavre/markdown_converter.rb +240 -0
  63. data/app/services/collavre/mention_parser.rb +63 -0
  64. data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
  65. data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
  66. data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
  67. data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
  68. data/app/services/collavre/orchestration/scheduler.rb +4 -3
  69. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  70. data/app/services/collavre/system_events/context_builder.rb +1 -6
  71. data/app/services/collavre/tools/creative_batch_service.rb +107 -0
  72. data/app/services/collavre/tools/creative_update_service.rb +17 -12
  73. data/app/services/collavre/tools/cron_create_service.rb +17 -5
  74. data/app/views/admin/shared/_tabs.html.erb +2 -1
  75. data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
  76. data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
  77. data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
  78. data/app/views/collavre/admin/settings/index.html.erb +11 -0
  79. data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
  80. data/app/views/collavre/comments/_comment.html.erb +15 -5
  81. data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
  82. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
  83. data/app/views/collavre/creatives/_share_button.html.erb +0 -52
  84. data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
  85. data/app/views/collavre/creatives/index.html.erb +5 -8
  86. data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
  87. data/app/views/collavre/user_themes/index.html.erb +7 -9
  88. data/app/views/collavre/users/_contact_management.html.erb +2 -1
  89. data/app/views/collavre/users/edit_ai.html.erb +7 -0
  90. data/app/views/collavre/users/index.html.erb +16 -1
  91. data/app/views/collavre/users/new_ai.html.erb +18 -8
  92. data/app/views/collavre/users/passkeys.html.erb +1 -1
  93. data/app/views/collavre/users/show.html.erb +1 -1
  94. data/app/views/layouts/collavre/slide.html.erb +8 -1
  95. data/config/locales/admin.en.yml +88 -0
  96. data/config/locales/admin.ko.yml +88 -0
  97. data/config/locales/ai_agent.en.yml +5 -1
  98. data/config/locales/ai_agent.ko.yml +5 -1
  99. data/config/locales/comments.en.yml +5 -1
  100. data/config/locales/comments.ko.yml +5 -1
  101. data/config/locales/orchestration.en.yml +8 -0
  102. data/config/locales/orchestration.ko.yml +8 -0
  103. data/config/locales/users.en.yml +12 -0
  104. data/config/locales/users.ko.yml +12 -0
  105. data/config/routes.rb +7 -1
  106. data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
  107. data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
  108. data/lib/collavre/engine.rb +25 -0
  109. data/lib/collavre/version.rb +1 -1
  110. metadata +32 -1
@@ -163,23 +163,34 @@ module Collavre
163
163
  if @comment.save
164
164
 
165
165
  # Dispatch system event
166
- ::SystemEvents::Dispatcher.dispatch("comment_created", {
167
- comment: {
168
- id: @comment.id,
169
- content: @comment.content,
170
- user_id: @comment.user_id
171
- },
172
- creative: {
173
- id: @creative.id,
174
- description: @creative.description
175
- },
176
- topic: {
177
- id: @comment.topic_id
178
- },
179
- chat: {
180
- content: @comment.content
181
- }
182
- }) unless @comment.private? || response.present?
166
+ unless @comment.private? || response.present?
167
+ begin
168
+ ::SystemEvents::Dispatcher.dispatch("comment_created", {
169
+ comment: {
170
+ id: @comment.id,
171
+ content: @comment.content,
172
+ user_id: @comment.user_id,
173
+ from_ai: @comment.user&.searchable? || false,
174
+ quoted_comment_id: @comment.quoted_comment_id
175
+ }.compact,
176
+ creative: {
177
+ id: @creative.id,
178
+ description: @creative.description
179
+ },
180
+ topic: {
181
+ id: @comment.topic_id
182
+ },
183
+ chat: {
184
+ content: @comment.content
185
+ }
186
+ })
187
+ rescue => e
188
+ Rails.logger.error(
189
+ "[SystemEvents] Dispatch failed for comment #{@comment.id}: " \
190
+ "#{e.class} #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
191
+ )
192
+ end
193
+ end
183
194
  @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
184
195
  render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
185
196
  else
@@ -189,7 +200,7 @@ module Collavre
189
200
 
190
201
  def update
191
202
  if @comment.user == Current.user
192
- safe_params = comment_params
203
+ safe_params = comment_params.except(:quoted_comment_id, :quoted_text)
193
204
  if safe_params[:topic_id].present? && !@creative.topics.where(id: safe_params[:topic_id]).exists?
194
205
  render json: { error: I18n.t("collavre.comments.invalid_topic") }, status: :unprocessable_entity and return
195
206
  end
@@ -392,87 +403,19 @@ module Collavre
392
403
  head :forbidden and return
393
404
  end
394
405
 
395
- render json: command_menu_items
406
+ render json: CommandMenuService.new(user: Current.user).items
396
407
  end
397
408
 
398
409
  def move
399
- comment_ids = Array(params[:comment_ids]).map(&:presence).compact.map(&:to_i)
400
- if comment_ids.empty?
401
- render json: { error: I18n.t("collavre.comments.move_no_selection") }, status: :unprocessable_entity and return
402
- end
403
-
404
- # Support moving to topic within same creative (target_topic_id only)
405
- # or moving to different creative (target_creative_id)
406
- target_topic_id = params[:target_topic_id]
407
- target_creative_id = params[:target_creative_id]
408
-
409
- # Determine target creative and topic
410
- if target_creative_id.present?
411
- # Moving to different creative
412
- target_creative = Creative.find_by(id: target_creative_id)
413
- if target_creative.nil?
414
- render json: { error: I18n.t("collavre.comments.move_invalid_target") }, status: :unprocessable_entity and return
415
- end
416
- target_origin = target_creative.effective_origin
417
- new_topic_id = nil # Reset topic when moving to different creative
418
- elsif target_topic_id.present? || target_topic_id == ""
419
- # Moving to topic within same creative (empty string means Main/no topic)
420
- target_origin = @creative
421
- new_topic_id = target_topic_id.presence # nil for Main topic
422
-
423
- # Validate topic exists if specified
424
- if new_topic_id.present? && !@creative.topics.exists?(id: new_topic_id)
425
- render json: { error: I18n.t("collavre.comments.move_invalid_topic", default: "Invalid topic") }, status: :unprocessable_entity and return
426
- end
427
- else
428
- render json: { error: I18n.t("collavre.comments.move_invalid_target") }, status: :unprocessable_entity and return
429
- end
430
-
431
- unless @creative.has_permission?(Current.user, :feedback) && target_origin.has_permission?(Current.user, :feedback)
432
- render json: { error: I18n.t("collavre.comments.move_not_allowed") }, status: :forbidden and return
433
- end
434
-
435
- scope = @creative.comments.where(
436
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
437
- false,
438
- Current.user.id,
439
- Current.user.id
410
+ result = CommentMoveService.new(creative: @creative, user: Current.user).call(
411
+ comment_ids: params[:comment_ids],
412
+ target_creative_id: params[:target_creative_id],
413
+ target_topic_id: params[:target_topic_id]
440
414
  )
441
-
442
- comments = scope.where(id: comment_ids).to_a
443
-
444
- if comments.length != comment_ids.length
445
- render json: { error: I18n.t("collavre.comments.move_not_allowed") }, status: :forbidden and return
446
- end
447
-
448
- moved_count = 0
449
- ActiveRecord::Base.transaction do
450
- comments.each do |comment|
451
- # Skip if already in target location
452
- same_creative = comment.creative_id == target_origin.id
453
- same_topic = comment.topic_id.to_s == new_topic_id.to_s
454
-
455
- next if same_creative && same_topic
456
-
457
- original_creative = comment.creative
458
- original_topic_id = comment.topic_id
459
-
460
- if same_creative
461
- # Just update topic within same creative
462
- comment.update!(topic_id: new_topic_id)
463
- else
464
- # Move to different creative
465
- comment.update!(creative: target_origin, topic_id: new_topic_id)
466
- broadcast_move_removal(comment, original_creative)
467
- end
468
- moved_count += 1
469
- end
470
- end
471
-
472
- Comment.broadcast_badges(@creative)
473
- Comment.broadcast_badges(target_origin) unless target_origin == @creative
474
-
475
- render json: { success: true, moved_count: moved_count }
415
+ render json: result
416
+ rescue CommentMoveService::MoveError => e
417
+ status = e.message == I18n.t("collavre.comments.move_not_allowed") ? :forbidden : :unprocessable_entity
418
+ render json: { error: e.message }, status: status
476
419
  rescue ActiveRecord::RecordInvalid => e
477
420
  render json: { error: e.record.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.move_error") }, status: :unprocessable_entity
478
421
  end
@@ -483,63 +426,6 @@ module Collavre
483
426
  @creative = Creative.find(params[:creative_id]).effective_origin
484
427
  end
485
428
 
486
- def command_menu_items
487
- [
488
- {
489
- name: "calendar",
490
- label: "/calendar",
491
- aliases: [ "/cal" ],
492
- description: I18n.t("collavre.comments.command_menu.calendar_description"),
493
- args: I18n.t("collavre.comments.command_menu.calendar_args")
494
- },
495
- {
496
- name: "topic",
497
- label: "/topic",
498
- description: I18n.t("collavre.comments.command_menu.topic_description"),
499
- args: I18n.t("collavre.comments.command_menu.topic_args")
500
- }
501
- ] + mcp_command_items
502
- end
503
-
504
- def mcp_command_items
505
- Collavre::McpService.available_tools(Current.user).filter_map do |tool|
506
- tool_name = tool[:name] || tool["name"]
507
- next unless tool_name
508
-
509
- {
510
- name: tool_name,
511
- label: "/#{tool_name}",
512
- description: tool[:description] || tool["description"],
513
- args: format_command_args(tool[:params] || tool["params"])
514
- }
515
- end
516
- end
517
-
518
- def format_command_args(params)
519
- return if params.blank?
520
-
521
- # Handle array format (from MetaToolService)
522
- if params.is_a?(Array)
523
- return params.map do |param|
524
- name = param[:name] || param["name"]
525
- required = param[:required] || param["required"]
526
- name.to_s + (required ? "*" : "")
527
- end.join(", ")
528
- end
529
-
530
- # Handle JSON Schema format (Hash with :properties)
531
- properties = params[:properties] || params["properties"]
532
- return unless properties.is_a?(Hash)
533
-
534
- required = params[:required] || params["required"] || []
535
- required = Array(required).map(&:to_s)
536
-
537
- properties.keys.map do |key|
538
- key = key.to_s
539
- required.include?(key) ? "#{key}*" : key
540
- end.join(", ")
541
- end
542
-
543
429
  def set_comment
544
430
  @comment = @creative.comments
545
431
  .where(
@@ -552,22 +438,13 @@ module Collavre
552
438
  end
553
439
 
554
440
  def comment_params
555
- params.require(:comment).permit(:content, :private, :topic_id, images: [])
441
+ params.require(:comment).permit(:content, :private, :topic_id, :quoted_comment_id, :quoted_text, images: [])
556
442
  end
557
443
 
558
444
  def can_convert_comment?
559
445
  @comment.user == Current.user || @creative.has_permission?(Current.user, :admin)
560
446
  end
561
447
 
562
- def broadcast_move_removal(comment, original_creative)
563
- return if comment.private?
564
-
565
- Turbo::StreamsChannel.broadcast_remove_to(
566
- [ original_creative, :comments ],
567
- target: view_context.dom_id(comment)
568
- )
569
- end
570
-
571
448
  def build_convert_system_message(creative)
572
449
  title = helpers.strip_tags(creative.description).to_s.strip
573
450
  title = I18n.t("collavre.comments.convert_system_message_default_title") if title.blank?
@@ -176,47 +176,21 @@ module Collavre
176
176
  end
177
177
 
178
178
  def create
179
- @creative = Creative.new(creative_params)
180
- if @creative.parent
181
- @creative.user = @creative.parent.user
182
- else
183
- @creative.user = Current.user
184
- end
185
-
186
- # Rebuild @child_creative from params if present
187
- if params[:child_id].present?
188
- @child_creative = Creative.find_by(id: params[:child_id])
189
- end
190
-
191
- if @creative.save
192
- @child_creative.update(parent: @creative) if @child_creative
193
- if params[:before_id].present?
194
- before_creative = Creative.find_by(id: params[:before_id])
195
- if before_creative && before_creative.parent_id == @creative.parent_id
196
- siblings = @creative.parent ? @creative.parent.children.order(:sequence).to_a : Creative.roots.order(:sequence).to_a
197
- siblings.reject! { |s| s.id == @creative.id }
198
- index = siblings.index { |s| s.id == before_creative.id } || 0
199
- siblings.insert(index, @creative)
200
- siblings.each_with_index { |c, idx| c.update_column(:sequence, idx) }
201
- end
202
- elsif params[:after_id].present?
203
- after_creative = Creative.find_by(id: params[:after_id])
204
- if after_creative && after_creative.parent_id == @creative.parent_id
205
- siblings = @creative.parent ? @creative.parent.children.order(:sequence).to_a : Creative.roots.order(:sequence).to_a
206
- siblings.reject! { |s| s.id == @creative.id }
207
- index = siblings.index { |s| s.id == after_creative.id } || -1
208
- siblings.insert(index + 1, @creative)
209
- siblings.each_with_index { |c, idx| c.update_column(:sequence, idx) }
210
- end
211
- end
212
- if params[:tags].present?
213
- Array(params[:tags]).each do |tag_id|
214
- @creative.tags.create(label_id: tag_id)
215
- end
216
- end
179
+ result = Creatives::CreateService.new(
180
+ creative_params: creative_params,
181
+ user: Current.user,
182
+ child_id: params[:child_id],
183
+ before_id: params[:before_id],
184
+ after_id: params[:after_id],
185
+ tag_ids: params[:tags]
186
+ ).call
187
+
188
+ @creative = result.creative
189
+
190
+ if result.success?
217
191
  render json: { id: @creative.id }
218
192
  else
219
- render json: { errors: @creative.errors.full_messages }, status: :unprocessable_entity
193
+ render json: { errors: result.errors }, status: :unprocessable_entity
220
194
  end
221
195
  end
222
196
 
@@ -275,15 +249,11 @@ module Collavre
275
249
  unless @creative.has_permission?(Current.user, :admin)
276
250
  redirect_to @creative, alert: t("collavre.creatives.errors.no_permission") and return
277
251
  end
278
- if params[:delete_with_children]
279
- # Recursively destroy deletable descendants before deleting parent
280
- destroy_descendants_recursively(@creative, Current.user)
281
- else
282
- # Re-link children to parent
283
- @creative.children.each { |child| child.update(parent: parent) }
284
- end
285
- CreativeShare.where(creative: @creative).destroy_all
286
- @creative.destroy
252
+ Creatives::DestroyService.new(
253
+ creative: @creative,
254
+ user: Current.user,
255
+ delete_with_children: params[:delete_with_children].present?
256
+ ).call
287
257
  end
288
258
 
289
259
  def request_permission
@@ -524,16 +494,6 @@ module Collavre
524
494
  }
525
495
  end
526
496
 
527
- # Recursively destroy all descendants the user can delete
528
- def destroy_descendants_recursively(creative, user)
529
- deletable_children = creative.children_with_permission(user, :admin)
530
- deletable_children.each do |child|
531
- destroy_descendants_recursively(child, user)
532
- CreativeShare.where(creative: child).destroy_all
533
- child.destroy
534
- end
535
- end
536
-
537
497
  def enforce_creatives_login_policy
538
498
  if SystemSetting.creatives_login_required?
539
499
  require_authentication
@@ -2,7 +2,7 @@ module Collavre
2
2
  class UsersController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[new create exists]
4
4
  before_action -> { enforce_auth_provider!(:email) }, only: [ :new, :create ]
5
- before_action :require_system_admin!, only: [ :index, :grant_system_admin, :revoke_system_admin ]
5
+ before_action :require_system_admin!, only: [ :index, :grant_system_admin, :revoke_system_admin, :unlock, :lock ]
6
6
 
7
7
  def new
8
8
  @user = Collavre::User.new
@@ -53,6 +53,14 @@ module Collavre
53
53
 
54
54
  def new_ai
55
55
  @available_tools = load_available_tools
56
+
57
+ if params[:copy_from].present?
58
+ source = Collavre::User.find_by(id: params[:copy_from])
59
+ if source&.ai_user? && source.created_by_id == Current.user.id
60
+ @copy_source = source
61
+ @copy_name = "#{source.name} (copy)"
62
+ end
63
+ end
56
64
  end
57
65
 
58
66
  def create_ai
@@ -75,6 +83,7 @@ module Collavre
75
83
  created_by_id: Current.user.id,
76
84
  routing_expression: params[:routing_expression]
77
85
  )
86
+ @user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?
78
87
 
79
88
  if @user.save
80
89
  Collavre::Contact.ensure(user: Current.user, contact_user: @user)
@@ -112,7 +121,7 @@ module Collavre
112
121
  return
113
122
  end
114
123
 
115
- ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, tools: [])
124
+ ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])
116
125
 
117
126
  if @user.update(ai_params)
118
127
  redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
@@ -137,7 +146,10 @@ module Collavre
137
146
  end
138
147
 
139
148
  scope = if params[:scope] == "contacts" && Current.user
140
- Current.user.contact_users
149
+ # Include both contacts and searchable users (e.g., AI agents with searchable=true)
150
+ contact_ids = Current.user.contact_users.select(:id)
151
+ searchable_ids = Collavre::User.where(searchable: true).select(:id)
152
+ Collavre::User.where(id: contact_ids).or(Collavre::User.where(id: searchable_ids))
141
153
  else
142
154
  Collavre::User.mentionable_for(creative)
143
155
  end
@@ -220,6 +232,22 @@ module Collavre
220
232
  end
221
233
  end
222
234
 
235
+ def unlock
236
+ @user = Collavre::User.find(params[:id])
237
+ @user.unlock_account!
238
+ redirect_to users_path, notice: I18n.t("collavre.users.unlock.success", name: @user.display_name)
239
+ end
240
+
241
+ def lock
242
+ @user = Collavre::User.find(params[:id])
243
+ if @user == Current.user
244
+ redirect_to users_path, alert: I18n.t("collavre.users.lock.cannot_lock_self")
245
+ return
246
+ end
247
+ @user.lock_account!
248
+ redirect_to users_path, notice: I18n.t("collavre.users.lock.success", name: @user.display_name)
249
+ end
250
+
223
251
  def update
224
252
  @user = Collavre::User.find(params[:id])
225
253
  if @user.update(profile_params)
@@ -1,4 +1,101 @@
1
1
  module Collavre
2
2
  module ApplicationHelper
3
+ # Returns the body CSS class for the current user's theme.
4
+ # Custom themes get "light-mode" to disable OS dark mode overrides,
5
+ # ensuring the custom theme controls all colors regardless of OS setting.
6
+ def body_theme_class
7
+ theme = Current.user&.theme
8
+ return "" if theme.blank?
9
+
10
+ case theme
11
+ when "dark"
12
+ "dark-mode"
13
+ when "light"
14
+ "light-mode"
15
+ else
16
+ "light-mode" # Custom theme: neutralize OS dark mode
17
+ end
18
+ end
19
+
20
+ # Renders CSS for admin-configured default themes (light/dark mode)
21
+ # Only applies when the user has no personal theme set
22
+ def default_theme_styles
23
+ return "" if Current.user&.theme.present?
24
+
25
+ light_theme = SystemSetting.default_light_theme
26
+ dark_theme = SystemSetting.default_dark_theme
27
+ return "" unless light_theme || dark_theme
28
+
29
+ styles = []
30
+
31
+ if light_theme
32
+ styles << render_theme_media_query(light_theme, "light")
33
+ end
34
+
35
+ if dark_theme
36
+ styles << render_theme_media_query(dark_theme, "dark")
37
+ end
38
+
39
+ styles.join("\n").html_safe # rubocop:disable Rails/OutputSafety
40
+ end
41
+
42
+ private
43
+
44
+ def render_theme_media_query(theme, mode)
45
+ vars = theme.variables.map { |k, v| "#{k}: #{v} !important;" }.join("\n ")
46
+ legacy = legacy_alias_declarations(theme.variables).map { |k, v| "#{k}: #{v} !important;" }.join("\n ")
47
+ dark_filter = theme.dark? ? "--date-icon-filter: invert(0.8) !important;" : ""
48
+
49
+ <<~CSS
50
+ @media (prefers-color-scheme: #{mode}) {
51
+ body:not(.dark-mode):not(.light-mode) {
52
+ #{vars}
53
+ #{legacy}
54
+ #{dark_filter}
55
+ }
56
+ }
57
+ CSS
58
+ end
59
+
60
+ public
61
+
62
+ # Maps semantic token names to their legacy alias names.
63
+ # When custom themes inject semantic tokens on <body>, the legacy aliases
64
+ # defined on :root still resolve to :root's light values. This map lets
65
+ # the theme injection template also emit legacy aliases so that CSS using
66
+ # old variable names (e.g. --color-section-bg) picks up custom values.
67
+ SEMANTIC_TO_LEGACY = {
68
+ "--surface-bg" => "--color-bg",
69
+ "--surface-nav" => "--color-nav-bg",
70
+ "--surface-section" => "--color-section-bg",
71
+ "--surface-input" => "--color-input-bg",
72
+ "--surface-btn" => "--color-btn-bg",
73
+ "--surface-secondary" => "--color-secondary-background",
74
+ "--text-primary" => "--color-text",
75
+ "--text-muted" => "--color-muted",
76
+ "--text-on-btn" => "--color-btn-text",
77
+ "--text-nav" => "--color-nav-text",
78
+ "--text-nav-btn" => "--color-nav-btn-text",
79
+ "--text-chat-btn" => "--color-chat-btn-text",
80
+ "--text-on-badge" => "--color-badge-text",
81
+ "--text-input" => "--color-input-text",
82
+ "--color-brand" => "--color-complete",
83
+ "--color-active" => "--color-secondary-active",
84
+ "--border-color" => "--color-border",
85
+ "--border-drag-over" => "--color-drag-over",
86
+ "--border-drag-edge" => "--color-drag-over-edge"
87
+ }.freeze
88
+
89
+ # Given a hash of semantic token variables, returns additional legacy
90
+ # alias declarations so old CSS picks up the custom theme values.
91
+ def legacy_alias_declarations(variables)
92
+ aliases = {}
93
+ variables.each do |key, value|
94
+ if (legacy_name = SEMANTIC_TO_LEGACY[key])
95
+ aliases[legacy_name] = value
96
+ end
97
+ end
98
+ aliases
99
+ end
3
100
  end
4
101
  end