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
@@ -0,0 +1,107 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+
5
+ module Tools
6
+ class CreativeBatchService
7
+ extend T::Sig
8
+ extend ToolMeta
9
+
10
+ tool_name "creative_batch_service"
11
+ tool_description "Execute multiple Creative operations (create, update, delete) in a single batch call. " \
12
+ "All operations run inside a transaction — if any operation fails, the entire batch is rolled back.\n\n" \
13
+ "This tool requires approval before execution.\n\n" \
14
+ "Each operation is a hash with an 'action' key ('create', 'update', or 'delete') plus action-specific fields."
15
+
16
+ def self.requires_approval?
17
+ true
18
+ end
19
+
20
+ tool_param :operations, description: "Array of operation objects. Each object must have an 'action' key.\n\n" \
21
+ "For 'create': { action: 'create', parent_id: <int>, description: <string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
22
+ "For 'update': { action: 'update', id: <int>, description: <string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
23
+ "For 'delete': { action: 'delete', id: <int> }\n\n" \
24
+ "Fields other than 'action' and 'id'/'parent_id' are optional.", required: true
25
+
26
+ class BatchRollbackError < StandardError
27
+ attr_reader :results
28
+
29
+ def initialize(results)
30
+ @results = results
31
+ super("Batch operation failed")
32
+ end
33
+ end
34
+
35
+ sig { params(operations: T::Array[T::Hash[String, T.untyped]]).returns(T::Hash[Symbol, T.untyped]) }
36
+ def call(operations:)
37
+ raise "Current.user is required" unless Current.user
38
+
39
+ results = []
40
+
41
+ ApplicationRecord.transaction do
42
+ operations.each_with_index do |op, idx|
43
+ op = op.transform_keys(&:to_s)
44
+ action = op["action"]
45
+
46
+ result = case action
47
+ when "create" then execute_create(op)
48
+ when "update" then execute_update(op)
49
+ when "delete" then execute_delete(op)
50
+ else
51
+ { error: "Unknown action '#{action}'" }
52
+ end
53
+
54
+ result[:index] = idx
55
+ result[:action] = action
56
+ results << result
57
+
58
+ raise BatchRollbackError, results if result[:error]
59
+ end
60
+ end
61
+
62
+ { success: true, results: results }
63
+ rescue BatchRollbackError => e
64
+ failed = e.results.find { |r| r[:error] }
65
+ { success: false, error: "Operation #{failed[:index]} (#{failed[:action]}) failed: #{failed[:error]}", results: e.results }
66
+ end
67
+
68
+ private
69
+
70
+ def execute_create(op)
71
+ service = CreativeCreateService.new
72
+ service.call(
73
+ description: op["description"] || "",
74
+ parent_id: op["parent_id"]&.to_i,
75
+ progress: op["progress"]&.to_f,
76
+ after_id: op["after_id"]&.to_i,
77
+ before_id: op["before_id"]&.to_i
78
+ )
79
+ end
80
+
81
+ def execute_update(op)
82
+ id = op["id"]&.to_i
83
+ return { error: "id is required for update" } unless id&.positive?
84
+
85
+ service = CreativeUpdateService.new
86
+ service.call(
87
+ id: id,
88
+ description: op["description"],
89
+ progress: op.key?("progress") ? op["progress"]&.to_f : nil,
90
+ parent_id: op.key?("parent_id") ? op["parent_id"]&.to_i : nil
91
+ )
92
+ end
93
+
94
+ def execute_delete(op)
95
+ id = op["id"]&.to_i
96
+ return { error: "id is required for delete" } unless id&.positive?
97
+
98
+ creative = Creative.find_by(id: id)
99
+ return { error: "Creative not found", id: id } unless creative
100
+ return { error: "No write permission on this Creative", id: id } unless creative.has_permission?(Current.user, :write)
101
+
102
+ creative.destroy!
103
+ { success: true, id: id, deleted: true }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -7,11 +7,11 @@ module Tools
7
7
  extend ToolMeta
8
8
 
9
9
  tool_name "creative_update_service"
10
- tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Update progress (0.0 to 1.0, where 1.0 = complete)\n- Move a Creative to a different parent\n\nNote: Setting progress to 1.0 on a parent will also complete all descendants."
10
+ tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Mark a leaf Creative as complete (progress = 1.0)\n- Move a Creative to a different parent\n\nProgress constraints:\n- Only 1.0 (100%) is allowed partial progress updates are not supported\n- Only leaf Creatives (with no children) can have their progress updated\n- Parent Creative progress is automatically calculated from children\n\nUse creative_retrieval_service to find the correct Creative before updating."
11
11
 
12
12
  tool_param :id, description: "The ID of the Creative to update.", required: true
13
13
  tool_param :description, description: "New content/title for the Creative. Accepts HTML format. If omitted, description remains unchanged.", required: false
14
- tool_param :progress, description: "New progress value (0.0 to 1.0). Setting to 1.0 marks as complete.", required: false
14
+ tool_param :progress, description: "Set to 1.0 to mark a leaf Creative as complete. Only 1.0 is allowed; partial progress and updates on parent Creatives are rejected.", required: false
15
15
  tool_param :parent_id, description: "New parent Creative ID to move this Creative under. Use null/0 to make it a root Creative.", required: false
16
16
 
17
17
  sig { params(id: Integer, description: T.nilable(String), progress: T.nilable(Float), parent_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
@@ -29,7 +29,6 @@ module Tools
29
29
 
30
30
  # Get the effective origin for updating content
31
31
  base = creative.effective_origin
32
- previous_progress = base.progress
33
32
 
34
33
  updates = {}
35
34
  parent_updates = {}
@@ -41,7 +40,19 @@ module Tools
41
40
 
42
41
  # Handle progress update
43
42
  if progress.present?
44
- updates[:progress] = progress.to_f.clamp(0.0, 1.0)
43
+ progress_value = progress.to_f.clamp(0.0, 1.0)
44
+
45
+ # Only 100% (1.0) progress updates are allowed via tools
46
+ unless progress_value == 1.0
47
+ return { error: "Only progress of 1.0 (100%) is allowed. Partial progress updates are not supported.", id: id }
48
+ end
49
+
50
+ # Only leaf creatives (no children) can have progress updated directly
51
+ if base.children.exists?
52
+ return { error: "Cannot update progress on a parent Creative. Only leaf Creatives (with no children) can be marked complete.", id: id }
53
+ end
54
+
55
+ updates[:progress] = progress_value
45
56
  end
46
57
 
47
58
  # Handle parent change (on the creative itself, not base)
@@ -77,14 +88,8 @@ module Tools
77
88
  if updates.present?
78
89
  success &&= base.update(updates)
79
90
 
80
- # If progress set to 1.0 and has children, complete all descendants
81
- requested_progress = updates[:progress]
82
- if success && requested_progress.present? && requested_progress >= 1 && previous_progress.to_f < 1
83
- if base.children.exists?
84
- base.self_and_descendants.where(origin_id: nil)
85
- .update_all(progress: 1.0, updated_at: Time.current)
86
- end
87
- end
91
+ # Note: progress updates are only allowed on leaf Creatives (validated above).
92
+ # Parent progress is automatically calculated from children.
88
93
  end
89
94
 
90
95
  if success
@@ -10,7 +10,7 @@ module Tools
10
10
  tool_description "Create a new recurring scheduled job. The job will periodically post a message to a creative's topic, triggering agent orchestration. Schedule uses cron syntax (e.g., '*/5 * * * *' for every 5 minutes, '0 9 * * *' for daily at 9am)."
11
11
 
12
12
  tool_param :creative_id, description: "The creative ID to post recurring messages to.", required: true
13
- tool_param :topic_id, description: "The topic ID within the creative to post to.", required: true
13
+ tool_param :topic_name, description: "The topic name within the creative to post to. Use 'Main' for the main topic (topic_id = nil).", required: true
14
14
  tool_param :schedule, description: "Cron schedule expression (e.g., '0 9 * * *' for daily at 9am, '*/30 * * * *' for every 30 minutes).", required: true
15
15
  tool_param :message, description: "The message content to post on each execution. This triggers the agent orchestration pipeline.", required: true
16
16
  tool_param :description, description: "Human-readable description of what this cron job does.", required: false
@@ -18,13 +18,13 @@ module Tools
18
18
  sig do
19
19
  params(
20
20
  creative_id: Integer,
21
- topic_id: Integer,
21
+ topic_name: String,
22
22
  schedule: String,
23
23
  message: String,
24
24
  description: T.nilable(String)
25
25
  ).returns(T::Hash[Symbol, T.untyped])
26
26
  end
27
- def call(creative_id:, topic_id:, schedule:, message:, description: nil)
27
+ def call(creative_id:, topic_name:, schedule:, message:, description: nil)
28
28
  raise "Current.user is required" unless Current.user
29
29
 
30
30
  creative = Creative.find_by(id: creative_id)
@@ -33,8 +33,8 @@ module Tools
33
33
  return { error: "No write permission on this Creative", id: creative_id }
34
34
  end
35
35
 
36
- topic = Topic.find_by(id: topic_id, creative_id: creative.effective_origin.id)
37
- return { error: "Topic not found or does not belong to this creative", topic_id: topic_id } unless topic
36
+ topic_id = resolve_topic_id(creative, topic_name)
37
+ return topic_id if topic_id.is_a?(Hash) && topic_id[:error]
38
38
 
39
39
  parsed = Fugit.parse(schedule)
40
40
  unless parsed.is_a?(Fugit::Cron)
@@ -65,9 +65,21 @@ module Tools
65
65
  schedule: task.schedule,
66
66
  description: task.description,
67
67
  creative_id: creative_id,
68
+ topic_name: topic_name,
68
69
  next_run: task.next_time&.iso8601
69
70
  }
70
71
  end
72
+
73
+ private
74
+
75
+ def resolve_topic_id(creative, topic_name)
76
+ return nil if topic_name.casecmp("main").zero?
77
+
78
+ topic = Topic.find_by(name: topic_name, creative_id: creative.effective_origin.id)
79
+ return { error: "Topic '#{topic_name}' not found for this creative" } unless topic
80
+
81
+ topic.id
82
+ end
71
83
  end
72
84
  end
73
85
  end
@@ -1,5 +1,6 @@
1
1
  <div class="tab-list">
2
- <%= link_to t('admin.tabs.system'), main_app.admin_path, class: "tab-button #{'active' if controller_name == 'settings'}" %>
2
+ <%= link_to t('admin.tabs.system'), collavre.admin_settings_path, class: "tab-button #{'active' if controller_name == 'settings' && action_name == 'index'}" %>
3
+ <%= link_to t('admin.tabs.uiux'), collavre.admin_uiux_path, class: "tab-button #{'active' if controller_name == 'settings' && action_name == 'uiux'}" %>
3
4
  <%= link_to t('admin.tabs.users'), collavre.users_path, class: "tab-button #{'active' if controller_name == 'users'}" %>
4
5
  <%= link_to t('admin.tabs.orchestration'), collavre.admin_orchestration_path, class: "tab-button #{'active' if controller_name == 'orchestration'}" %>
5
6
  </div>
@@ -49,6 +49,17 @@
49
49
  <li><code>topic_max_concurrent_jobs</code> — <%= t('admin.orchestration.opt_topic_max_concurrent') %></li>
50
50
  </ul>
51
51
 
52
+ <p style="margin-bottom: 0.75em;"><strong><%= t('admin.orchestration.collaboration_options') %></strong></p>
53
+ <ul style="margin-left: 1.5em; margin-bottom: 1em;">
54
+ <li><code>a2a_focus_instruction</code> — <%= t('admin.orchestration.opt_a2a_focus') %></li>
55
+ <li><code>a2a_completion_instruction</code> — <%= t('admin.orchestration.opt_a2a_completion') %></li>
56
+ <li><code>a2a_followup_instruction</code> — <%= t('admin.orchestration.opt_a2a_followup') %></li>
57
+ <li><code>mention_rule</code> — <%= t('admin.orchestration.opt_mention_rule') %></li>
58
+ <li><code>confidence_rule</code> — <%= t('admin.orchestration.opt_confidence_rule') %></li>
59
+ <li><code>escalation_rule</code> — <%= t('admin.orchestration.opt_escalation_rule') %></li>
60
+ <li><code>review_rule</code> — <%= t('admin.orchestration.opt_review_rule') %></li>
61
+ </ul>
62
+
52
63
  <p style="margin-bottom: 0.75em;"><strong><%= t('admin.orchestration.scope_types') %></strong></p>
53
64
  <ul style="margin-left: 1.5em;">
54
65
  <li><code>Creative</code> — <%= t('admin.orchestration.scope_creative') %></li>
@@ -0,0 +1,138 @@
1
+ <section class="tab-panel active">
2
+ <%= tag.div(flash[:alert], class: "flash-alert") if flash[:alert] %>
3
+ <%= form_with url: collavre.admin_settings_path, method: :patch, local: true, html: { class: 'profile-form' } do |f| %>
4
+ <%# system tab %>
5
+ <div>
6
+ <%= f.label :help_link, t('admin.settings.help_link') %>
7
+ <%= f.text_field :help_link, value: @help_link, placeholder: "https://..." %>
8
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
9
+ <%= t('admin.settings.help_link_hint') %>
10
+ </div>
11
+ </div>
12
+
13
+ <div style="margin-top: 1.5em;">
14
+ <label class="checkbox-label" style="display: flex; align-items: center; gap: 0.5em;">
15
+ <%= f.check_box :mcp_tool_approval, checked: @mcp_tool_approval %>
16
+ <span><%= t('admin.settings.mcp_tool_approval') %></span>
17
+ </label>
18
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em; margin-left: 1.8em;">
19
+ <%= t('admin.settings.mcp_tool_approval_hint') %>
20
+ </div>
21
+ </div>
22
+
23
+ <div style="margin-top: 1.5em;">
24
+ <label class="checkbox-label" style="display: flex; align-items: center; gap: 0.5em;">
25
+ <%= f.check_box :creatives_login_required, checked: @creatives_login_required %>
26
+ <span><%= t('admin.settings.creatives_login_required') %></span>
27
+ </label>
28
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em; margin-left: 1.8em;">
29
+ <%= t('admin.settings.creatives_login_required_hint') %>
30
+ </div>
31
+ </div>
32
+
33
+ <div style="margin-top: 1.5em;">
34
+ <%= f.label :home_page_path, t('admin.settings.home_page_path') %>
35
+ <%= f.text_field :home_page_path, value: @home_page_path, placeholder: "/" %>
36
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
37
+ <%= t('admin.settings.home_page_path_hint') %>
38
+ </div>
39
+ </div>
40
+
41
+ <div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
42
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;"><%= t('admin.settings.account_lockout') %></h3>
43
+ <div style="display: flex; gap: 2em; flex-wrap: wrap;">
44
+ <div style="flex: 1; min-width: 200px;">
45
+ <%= f.label :max_login_attempts, t('admin.settings.max_login_attempts') %>
46
+ <%= f.number_field :max_login_attempts, value: @max_login_attempts, min: 1, max: 100, style: "width: 100%;" %>
47
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
48
+ <%= t('admin.settings.max_login_attempts_hint') %>
49
+ </div>
50
+ </div>
51
+ <div style="flex: 1; min-width: 200px;">
52
+ <%= f.label :lockout_duration_minutes, t('admin.settings.lockout_duration_minutes') %>
53
+ <%= f.number_field :lockout_duration_minutes, value: @lockout_duration_minutes, min: 1, max: 1440, style: "width: 100%;" %>
54
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
55
+ <%= t('admin.settings.lockout_duration_minutes_hint') %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
62
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;"><%= t('admin.settings.password_policy') %></h3>
63
+ <div style="max-width: 300px;">
64
+ <%= f.label :password_min_length, t('admin.settings.password_min_length') %>
65
+ <%= f.number_field :password_min_length, value: @password_min_length, min: SystemSetting::DEFAULT_PASSWORD_MIN_LENGTH, max: 72, style: "width: 100%;" %>
66
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
67
+ <%= t('admin.settings.password_min_length_hint') %>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
73
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;"><%= t('admin.settings.session_timeout') %></h3>
74
+ <div style="max-width: 300px;">
75
+ <%= f.label :session_timeout_minutes, t('admin.settings.session_timeout_minutes') %>
76
+ <%= f.number_field :session_timeout_minutes, value: @session_timeout_minutes, min: 0, max: 10080, style: "width: 100%;" %>
77
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
78
+ <%= t('admin.settings.session_timeout_minutes_hint') %>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
84
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;"><%= t('admin.settings.rate_limiting') %></h3>
85
+
86
+ <div style="margin-bottom: 1.5em;">
87
+ <h4 style="font-size: 1em; margin-bottom: 0.75em; color: var(--color-text-muted);"><%= t('admin.settings.password_reset_rate') %></h4>
88
+ <div style="display: flex; gap: 2em; flex-wrap: wrap;">
89
+ <div style="flex: 1; min-width: 200px;">
90
+ <%= f.label :password_reset_rate_limit, t('admin.settings.rate_limit_requests') %>
91
+ <%= f.number_field :password_reset_rate_limit, value: @password_reset_rate_limit, min: 1, max: 1000, style: "width: 100%;" %>
92
+ </div>
93
+ <div style="flex: 1; min-width: 200px;">
94
+ <%= f.label :password_reset_rate_period_minutes, t('admin.settings.rate_limit_period_minutes') %>
95
+ <%= f.number_field :password_reset_rate_period_minutes, value: @password_reset_rate_period_minutes, min: 1, max: 1440, style: "width: 100%;" %>
96
+ </div>
97
+ </div>
98
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
99
+ <%= t('admin.settings.password_reset_rate_hint') %>
100
+ </div>
101
+ </div>
102
+
103
+ <div>
104
+ <h4 style="font-size: 1em; margin-bottom: 0.75em; color: var(--color-text-muted);"><%= t('admin.settings.api_rate') %></h4>
105
+ <div style="display: flex; gap: 2em; flex-wrap: wrap;">
106
+ <div style="flex: 1; min-width: 200px;">
107
+ <%= f.label :api_rate_limit, t('admin.settings.rate_limit_requests') %>
108
+ <%= f.number_field :api_rate_limit, value: @api_rate_limit, min: 1, max: 10000, style: "width: 100%;" %>
109
+ </div>
110
+ <div style="flex: 1; min-width: 200px;">
111
+ <%= f.label :api_rate_period_minutes, t('admin.settings.rate_limit_period_minutes') %>
112
+ <%= f.number_field :api_rate_period_minutes, value: @api_rate_period_minutes, min: 1, max: 60, style: "width: 100%;" %>
113
+ </div>
114
+ </div>
115
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
116
+ <%= t('admin.settings.api_rate_hint') %>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
122
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;"><%= t('admin.settings.auth_providers') %></h3>
123
+ <div style="display: flex; flex-direction: column; gap: 0.8em;">
124
+ <% Rails.application.config.auth_providers.sort_by { |p| p[:priority] }.each do |provider| %>
125
+ <label class="checkbox-label" style="display: flex; align-items: center; gap: 0.5em;">
126
+ <%= check_box_tag "auth_providers[]", provider[:key], @enabled_auth_providers.include?(provider[:key].to_s), id: "auth_provider_#{provider[:key]}" %>
127
+ <span><%= t(provider[:name], default: provider[:name].to_s.humanize) %></span>
128
+ </label>
129
+ <% end %>
130
+ </div>
131
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.5em;">
132
+ <%= t('admin.settings.auth_providers_hint', default: "Select allowed authentication methods. Warning: Disabling all may lock you out.") %>
133
+ </div>
134
+ </div>
135
+
136
+ <%= f.submit t('admin.settings.save') %>
137
+ <% end %>
138
+ </section>
@@ -0,0 +1,44 @@
1
+ <section class="tab-panel active">
2
+ <%= tag.div(flash[:alert], class: "flash-alert") if flash[:alert] %>
3
+ <%= form_with url: collavre.admin_uiux_path, method: :patch, local: true, html: { class: 'profile-form' } do |f| %>
4
+
5
+ <div>
6
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;"><%= t('admin.settings.default_themes') %></h3>
7
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-bottom: 1.5em;">
8
+ <%= t('admin.settings.default_themes_hint') %>
9
+ </div>
10
+
11
+ <div style="display: flex; gap: 2em; flex-wrap: wrap;">
12
+ <div style="flex: 1; min-width: 250px;">
13
+ <%= label_tag :default_light_theme_id, t('admin.settings.default_light_theme') %>
14
+ <%= select_tag :default_light_theme_id,
15
+ options_for_select(
16
+ [[t('admin.settings.theme_system_default'), '']] +
17
+ @available_themes.map { |t| [t.name, t.id] },
18
+ @default_light_theme_id.to_s
19
+ ),
20
+ style: "width: 100%;" %>
21
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
22
+ <%= t('admin.settings.default_light_theme_hint') %>
23
+ </div>
24
+ </div>
25
+
26
+ <div style="flex: 1; min-width: 250px;">
27
+ <%= label_tag :default_dark_theme_id, t('admin.settings.default_dark_theme') %>
28
+ <%= select_tag :default_dark_theme_id,
29
+ options_for_select(
30
+ [[t('admin.settings.theme_system_default'), '']] +
31
+ @available_themes.map { |t| [t.name, t.id] },
32
+ @default_dark_theme_id.to_s
33
+ ),
34
+ style: "width: 100%;" %>
35
+ <div class="help-text" style="font-size: 0.85em; color: var(--color-text-muted); margin-top: 0.25em;">
36
+ <%= t('admin.settings.default_dark_theme_hint') %>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ <%= f.submit t('admin.settings.save') %>
43
+ <% end %>
44
+ </section>
@@ -0,0 +1,11 @@
1
+ <% content_for :head do %>
2
+ <%= stylesheet_link_tag 'collavre/comments_popup', media: 'all' %>
3
+ <% end %>
4
+
5
+ <h1 class="no-top-margin"><%= t('admin.title') %></h1>
6
+
7
+ <%= render "admin/shared/tabs" %>
8
+
9
+ <div class="tab-panels">
10
+ <%= render "collavre/admin/settings/system_tab" %>
11
+ </div>
@@ -0,0 +1,11 @@
1
+ <% content_for :head do %>
2
+ <%= stylesheet_link_tag 'collavre/comments_popup', media: 'all' %>
3
+ <% end %>
4
+
5
+ <h1 class="no-top-margin"><%= t('admin.title') %></h1>
6
+
7
+ <%= render "admin/shared/tabs" %>
8
+
9
+ <div class="tab-panels">
10
+ <%= render "collavre/admin/settings/uiux_tab" %>
11
+ </div>
@@ -2,7 +2,7 @@
2
2
  <% current_topic_id = local_assigns[:current_topic_id] %>
3
3
  <% has_pending_action = comment.action.present? && comment.action_executed_at.blank? %>
4
4
  <% approver_id = comment.approver_id %>
5
- <div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>">
5
+ <div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>" data-ai-user="<%= comment.user&.ai_user? %>" data-streaming="<%= local_assigns.fetch(:streaming, false) %>">
6
6
  <div class="comment-select">
7
7
  <input type="checkbox"
8
8
  class="comment-select-checkbox"
@@ -11,14 +11,11 @@
11
11
  </div>
12
12
  <div>
13
13
  <%= render Collavre::AvatarComponent.new(user: comment.user, size: 20, classes: 'avatar comment-avatar') %>
14
- <% system_prefix = "#{t('collavre.comments.system_user')}:" %>
15
14
  <% display_name =
16
15
  if comment.user.present?
17
16
  comment.user.display_name
18
- elsif comment.content.to_s.strip.start_with?(system_prefix)
19
- t('collavre.comments.system_user')
20
17
  else
21
- t('collavre.comments.gemini')
18
+ t('collavre.comments.system_user')
22
19
  end %>
23
20
  <% timestamp = comment.created_at.in_time_zone %>
24
21
  <strong><%= display_name %></strong>
@@ -59,6 +56,14 @@
59
56
  <button class="edit-comment-btn comment-owner-only" data-comment-target="ownerButton" data-comment-id="<%= comment.id %>" data-comment-content="<%= comment.content %>" data-comment-private="<%= comment.private? %>" title="<%= t('collavre.comments.update_comment') %>">
60
57
  <%= t('collavre.comments.edit_button') %>
61
58
  </button>
59
+ <% if comment.user&.ai_user? %>
60
+ <button class="review-comment-btn" data-comment-target="reviewButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.review_button') %>">
61
+ <%= t('collavre.comments.review_button') %>
62
+ </button>
63
+ <button class="replace-comment-btn" data-comment-target="replaceButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.replace_button') %>" disabled>
64
+ <%= t('collavre.comments.replace_button') %>
65
+ </button>
66
+ <% end %>
62
67
  <button class="copy-comment-link-btn" data-comment-id="<%= comment.id %>" data-comment-url="<%= collavre.creative_comment_url(comment.creative, comment, Rails.application.config.action_mailer.default_url_options) %>" title="<%= t('collavre.comments.copy_link_button') %>">
63
68
  <%= t('collavre.comments.copy_link_button') %>
64
69
  </button>
@@ -66,6 +71,11 @@
66
71
  <%= t('collavre.comments.delete_button') %>
67
72
  </button>
68
73
  </div>
74
+ <% if comment.quoted_text.present? %>
75
+ <div class="comment-quoted-block">
76
+ <blockquote class="comment-quoted-text"><%= comment.quoted_text %></blockquote>
77
+ </div>
78
+ <% end %>
69
79
  <div class="comment-content"><%= comment.content %></div>
70
80
 
71
81
 
@@ -31,7 +31,8 @@
31
31
  data-move-error-text="<%= t('collavre.comments.move_error') %>"
32
32
  data-hint-drag-topic-text="<%= t('collavre.comments.hint_drag_topic') %>"
33
33
  data-hint-move-button-text="<%= t('collavre.comments.hint_move_button') %>"
34
- data-add-participant-text="<%= t('collavre.comments.add_participant') %>">
34
+ data-add-participant-text="<%= t('collavre.comments.add_participant') %>"
35
+ data-review-button-text="<%= t('collavre.comments.review_button') %>">
35
36
  <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
36
37
  <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
37
38
  <div class="comments-popup-header">
@@ -50,7 +51,7 @@
50
51
  <%= svg_tag "exit-fullscreen.svg", class: "comments-popup-action-icon" %>
51
52
  </span>
52
53
  </button>
53
- <button id="close-comments-btn" data-comments--popup-target="closeButton" class="comments-popup-action comments-popup-close" type="button">&times;</button>
54
+ <button id="close-comments-btn" data-comments--popup-target="closeButton" class="popup-close-btn" type="button">&times;</button>
54
55
  </div>
55
56
  </div>
56
57
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
@@ -62,6 +63,12 @@
62
63
  <div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
63
64
  <div id="typing-indicator" data-comments--presence-target="typingIndicator"></div>
64
65
  <form id="new-comment-form" data-comments--popup-target="form" data-comments--form-target="form" style="display:none;">
66
+ <input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
67
+ <input type="hidden" name="comment[quoted_text]" data-comments--form-target="quotedText" value="" />
68
+ <div class="comment-quote-indicator" data-comments--form-target="quoteIndicator" style="display:none;">
69
+ <span class="comment-quote-indicator-text" data-comments--form-target="quoteIndicatorText"></span>
70
+ <button type="button" class="comment-quote-cancel" data-action="click->comments--form#cancelQuote" title="<%= t('app.cancel') %>">&times;</button>
71
+ </div>
65
72
  <textarea class="shared-input-surface" name="comment[content]" data-comments--form-target="textarea" data-comments--presence-target="textarea" data-comments--mention-menu-target="textarea" rows="2" enterkeyhint="send"></textarea>
66
73
  <div class="comment-bottom">
67
74
  <input type="file" id="comment-images" name="comment[images][]" accept="image/*" multiple data-comments--form-target="imageInput" style="display:none;" />
@@ -10,9 +10,6 @@
10
10
  <button type="button" class="popup-menu-item" data-controller="click-target" data-click-target-id-value="share-creative-btn" data-action="click->click-target#trigger"><%= t('collavre.creatives.index.share') %></button>
11
11
  <% end %>
12
12
  <% if can_manage_integrations %>
13
- <%# Legacy hardcoded integrations %>
14
- <button type="button" class="popup-menu-item" data-controller="click-target" data-click-target-id-value="github-integration-btn" data-action="click->click-target#trigger"><%= t('collavre.creatives.index.integrations', default: '연동') %> - Github</button>
15
- <%# Dynamically registered integrations %>
16
13
  <% Collavre::IntegrationRegistry.each do |integration| %>
17
14
  <% if integration.enabled_for?(current_creative) %>
18
15
  <button type="button"
@@ -1,55 +1,3 @@
1
1
  <button id="share-creative-btn" class="btn btn-primary desktop-only">
2
2
  <span aria-hidden="true"><%= svg_tag 'share.svg', class: 'icon-up', width: 22, height: 20 %></span>
3
3
  </button>
4
- <!-- Share Creative Modal -->
5
- <div id="share-creative-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;align-items:center;justify-content:center;" data-creative-id="<%= (@parent_creative || @creative).id %>">
6
- <div class="popup-box" style="min-width:320px;max-width:90vw;">
7
- <button id="close-share-modal" class="popup-close-btn">&times;</button>
8
- <h2><%= t('collavre.creatives.index.share_creative') %></h2>
9
- <form id="share-creative-form" action="<%= collavre.creative_creative_shares_path(@parent_creative || @creative) %>" method="post" data-controller="share-invite">
10
- <%= csrf_meta_tags %>
11
- <div style="margin-bottom:1em; position:relative;"
12
- data-controller="share-user-search"
13
- data-share-user-search-creative-id-value="<%= (@parent_creative || @creative).id %>"
14
- data-share-user-search-scope-value="contacts">
15
- <label for="share-user-email"><%= t('collavre.creatives.index.user_email') %></label>
16
- <input type="email" name="user_email" id="share-user-email" style="width:100%;"
17
- data-share-invite-target="email"
18
- data-share-user-search-target="input"
19
- data-action="blur->share-invite#check input->share-user-search#input focus->share-user-search#focus keydown->share-user-search#handleKey"
20
- autocomplete="off" />
21
- <%= render Collavre::UserMentionMenuComponent.new(menu_id: 'share-user-suggestions') %>
22
- </div>
23
- <div style="margin-bottom:1em;">
24
- <select name="permission" id="share-permission" style="width:100%;">
25
- <option value="no_access"><%= t('collavre.creatives.index.permission_no_access') %></option>
26
- <option value="read"><%= t('collavre.creatives.index.permission_read') %></option>
27
- <option value="feedback"><%= t('collavre.creatives.index.permission_feedback') %></option>
28
- <option value="write"><%= t('collavre.creatives.index.permission_write') %></option>
29
- <option value="admin"><%= t('collavre.creatives.index.permission_admin') %></option>
30
- </select>
31
- </div>
32
- <button type="submit" class="btn btn-primary" data-share-invite-target="submit" data-share="<%= t('collavre.creatives.index.share') %>" data-invite="<%= t('collavre.creatives.index.invite') %>"><%= t('collavre.creatives.index.share') %></button>
33
- <button type="button" id="creative-invite-link" class="btn btn-secondary" data-creative-id="<%= (@parent_creative || @creative).id %>" data-no-access-message="<%= t('collavre.creatives.index.invite_link_no_access') %>" data-copied-template="<%= t('collavre.creatives.index.invite_link_copied_permission', permission: '__PERMISSION__') %>"><%= t('collavre.creatives.index.invite_link') %></button>
34
- </form>
35
- <% if @shared_list.any? %>
36
- <div style="margin-top:1em;">
37
- <strong><%= t('collavre.creatives.index.shared_with') %>:</strong>
38
- <ul class="share-grid">
39
- <% @shared_list.each do |share| %>
40
- <li>
41
- <span>
42
- <%= render Collavre::AvatarComponent.new(user: share.user, size: 20, classes: 'avatar share-avatar') %>
43
- </span>
44
- <span><%= share.user&.display_name || (share.user_id.nil? ? t('collavre.creatives.index.public_share') : t('collavre.creatives.index.unknown_user')) %></span>
45
- <span><%= t("collavre.creatives.index.permission_#{share.permission}") %></span>
46
- <span>
47
- <%= button_to '×', collavre.creative_creative_share_path(@parent_creative || @creative, share), method: :delete, form: { data: { turbo_confirm: t('collavre.creatives.index.are_you_sure_delete_share') } }, class: 'delete-share-btn', style: 'padding:0 0.5em;' %>
48
- </span>
49
- </li>
50
- <% end %>
51
- </ul>
52
- </div>
53
- <% end %>
54
- </div>
55
- </div>