collavre 0.16.0 → 0.20.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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/comments_popup.css +65 -5
- data/app/assets/stylesheets/collavre/creatives.css +5 -2
- data/app/controllers/collavre/admin/settings_controller.rb +9 -0
- data/app/controllers/collavre/attachments_controller.rb +1 -1
- data/app/controllers/collavre/comment_read_pointers_controller.rb +2 -2
- data/app/controllers/collavre/comments/snapshots_controller.rb +2 -7
- data/app/controllers/collavre/comments_controller.rb +23 -25
- data/app/controllers/collavre/creatives_controller.rb +22 -11
- data/app/controllers/collavre/emails_controller.rb +2 -0
- data/app/controllers/collavre/google_auth_controller.rb +1 -1
- data/app/controllers/collavre/inbox_items_controller.rb +1 -1
- data/app/controllers/collavre/invites_controller.rb +3 -0
- data/app/controllers/collavre/tasks_controller.rb +1 -1
- data/app/controllers/collavre/topics_controller.rb +27 -85
- data/app/controllers/concerns/collavre/comments/comment_scoping.rb +3 -0
- data/app/controllers/concerns/collavre/creative_permission_guard.rb +32 -0
- data/app/controllers/concerns/collavre/integration_setup.rb +17 -0
- data/app/helpers/collavre/application_helper.rb +30 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +7 -3
- data/app/javascript/components/creative_tree_row.js +21 -1
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +34 -0
- data/app/javascript/controllers/comment_controller.js +17 -0
- data/app/javascript/controllers/comments/form_controller.js +7 -4
- data/app/javascript/controllers/comments/list_controller.js +43 -4
- data/app/javascript/controllers/comments/popup_controller.js +45 -12
- data/app/javascript/controllers/comments/presence_controller.js +8 -0
- data/app/javascript/controllers/comments/topics_controller.js +50 -31
- data/app/javascript/creatives/tree_renderer.js +1 -0
- data/app/javascript/lib/__tests__/chat_history.test.js +31 -0
- data/app/javascript/lib/chat_history.js +12 -2
- data/app/javascript/modules/command_args_form.js +8 -0
- data/app/javascript/modules/creative_row_editor.js +12 -17
- data/app/javascript/modules/integration_wizard.js +162 -0
- data/app/jobs/collavre/compress_job.rb +1 -0
- data/app/jobs/collavre/creative_broadcast_job.rb +4 -1
- data/app/jobs/collavre/merge_comments_job.rb +1 -0
- data/app/jobs/collavre/trigger_loop_check_job.rb +1 -0
- data/app/jobs/collavre/trigger_loop_verify_job.rb +1 -0
- data/app/models/collavre/calendar_event.rb +0 -4
- data/app/models/collavre/comment/broadcastable.rb +1 -1
- data/app/models/collavre/comment.rb +17 -2
- data/app/models/collavre/comment_snapshot.rb +0 -1
- data/app/models/collavre/creative/describable.rb +10 -1
- data/app/models/collavre/creative/realtime_broadcastable.rb +17 -5
- data/app/models/collavre/creative.rb +43 -1
- data/app/models/collavre/current.rb +1 -1
- data/app/models/collavre/inbox_item.rb +0 -4
- data/app/models/collavre/system_setting.rb +10 -1
- data/app/models/collavre/task.rb +17 -8
- data/app/models/collavre/user.rb +11 -1
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +0 -8
- data/app/services/collavre/ai_agent/message_builder.rb +32 -15
- data/app/services/collavre/ai_agent/response_finalizer.rb +2 -1
- data/app/services/collavre/ai_agent/session_context_resolver.rb +50 -0
- data/app/services/collavre/ai_agent_service.rb +14 -2
- data/app/services/collavre/ai_client.rb +13 -0
- data/app/services/collavre/command_menu_service.rb +23 -3
- data/app/services/collavre/comments/command_processor.rb +1 -1
- data/app/services/collavre/comments/mcp_command.rb +27 -8
- data/app/services/collavre/comments/mcp_command_builder.rb +4 -3
- data/app/services/collavre/creatives/tree_builder.rb +3 -0
- data/app/services/collavre/google_calendar_service.rb +32 -6
- data/app/services/collavre/markdown_converter.rb +15 -20
- data/app/services/collavre/mcp_service.rb +4 -4
- data/app/services/collavre/orchestration/agent_orchestrator.rb +2 -2
- data/app/services/collavre/tools/creative_batch_service.rb +5 -1
- data/app/services/collavre/tools/cron_create_service.rb +9 -6
- data/app/services/collavre/topic_branch_service.rb +2 -2
- data/app/views/collavre/admin/settings/_system_tab.html.erb +11 -0
- data/app/views/collavre/comments/_comment.html.erb +7 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/config/locales/admin.en.yml +3 -0
- data/config/locales/admin.ko.yml +3 -0
- data/config/locales/comments.en.yml +6 -1
- data/config/locales/comments.ko.yml +6 -1
- data/db/migrate/20251126040752_add_description_to_creatives.rb +1 -1
- data/db/migrate/20260415000000_create_main_topics_for_existing_creatives.rb +69 -0
- data/lib/collavre/version.rb +1 -1
- metadata +21 -4
- data/app/jobs/collavre/permission_cache_cleanup_job.rb +0 -36
- data/app/models/collavre/variation.rb +0 -5
- data/app/services/collavre/creatives/path_exporter.rb +0 -131
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
class TopicsController < ApplicationController
|
|
3
|
+
include Collavre::CreativePermissionGuard
|
|
4
|
+
|
|
3
5
|
before_action :set_creative
|
|
6
|
+
before_action :require_creative_read!, only: %i[next_name]
|
|
7
|
+
before_action :require_creative_admin!, only: %i[update destroy move reorder]
|
|
8
|
+
before_action :require_creative_write!, only: %i[create archive unarchive set_primary_agent]
|
|
4
9
|
|
|
5
10
|
def index
|
|
6
11
|
is_owner = @creative.user == Current.user
|
|
@@ -18,6 +23,7 @@ module Collavre
|
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
system_topic_id = @creative.inbox? ? @creative.topics.find_by(name: Creative::SYSTEM_TOPIC_NAME)&.id : nil
|
|
26
|
+
main_topic_id = @creative.main_topic(fallback_user: Current.user).id
|
|
21
27
|
|
|
22
28
|
render json: {
|
|
23
29
|
topics: active_topics.map { |t| topic_json(t) },
|
|
@@ -26,15 +32,12 @@ module Collavre
|
|
|
26
32
|
can_create_topic: can_create_topic,
|
|
27
33
|
last_topic_id: last_topic_id,
|
|
28
34
|
is_inbox: @creative.inbox?,
|
|
29
|
-
system_topic_id: system_topic_id
|
|
35
|
+
system_topic_id: system_topic_id,
|
|
36
|
+
main_topic_id: main_topic_id
|
|
30
37
|
}
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def create
|
|
34
|
-
unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
|
|
35
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
36
|
-
end
|
|
37
|
-
|
|
38
41
|
topic = @creative.topics.build(topic_params)
|
|
39
42
|
topic.user = Current.user
|
|
40
43
|
|
|
@@ -56,10 +59,7 @@ module Collavre
|
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
broadcast_data = agent ? topic_json_with_agent(topic, agent) : topic.slice(:id, :name)
|
|
59
|
-
|
|
60
|
-
@creative,
|
|
61
|
-
{ action: "created", topic: broadcast_data, user_id: Current.user.id }
|
|
62
|
-
)
|
|
62
|
+
broadcast_topic_event("created", topic: broadcast_data, user_id: Current.user.id)
|
|
63
63
|
render json: topic, status: :created
|
|
64
64
|
else
|
|
65
65
|
render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
|
|
@@ -70,54 +70,35 @@ module Collavre
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def next_name
|
|
73
|
-
unless @creative.has_permission?(Current.user, :read) || @creative.user == Current.user
|
|
74
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
75
|
-
end
|
|
76
|
-
|
|
77
73
|
render json: { name: generate_next_topic_name }
|
|
78
74
|
end
|
|
79
75
|
|
|
80
76
|
def update
|
|
81
|
-
unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
|
|
82
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
83
|
-
end
|
|
84
|
-
|
|
85
77
|
topic = @creative.topics.find(params[:id])
|
|
86
78
|
|
|
87
79
|
if topic.update(topic_params)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
{ action: "updated", topic: topic.slice(:id, :name) }
|
|
91
|
-
)
|
|
92
|
-
render json: topic
|
|
80
|
+
broadcast_topic_event("updated", topic: topic_json(topic))
|
|
81
|
+
render json: topic_json(topic)
|
|
93
82
|
else
|
|
94
83
|
render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
|
|
95
84
|
end
|
|
96
85
|
end
|
|
97
86
|
|
|
98
87
|
def destroy
|
|
99
|
-
|
|
100
|
-
|
|
88
|
+
topic = @creative.topics.find(params[:id])
|
|
89
|
+
|
|
90
|
+
if topic.name == Creative::MAIN_TOPIC_NAME
|
|
91
|
+
render json: { error: I18n.t("collavre.topics.cannot_delete_main") }, status: :unprocessable_entity and return
|
|
101
92
|
end
|
|
102
93
|
|
|
103
|
-
topic = @creative.topics.find(params[:id])
|
|
104
94
|
topic_id = topic.id
|
|
105
|
-
|
|
106
|
-
# last_topic_id is nullified by DB FK (on_delete: :nullify) and model dependent: :nullify
|
|
107
95
|
topic.destroy
|
|
108
96
|
|
|
109
|
-
|
|
110
|
-
@creative,
|
|
111
|
-
{ action: "deleted", topic_id: topic_id }
|
|
112
|
-
)
|
|
97
|
+
broadcast_topic_event("deleted", topic_id: topic_id)
|
|
113
98
|
head :no_content
|
|
114
99
|
end
|
|
115
100
|
|
|
116
101
|
def move
|
|
117
|
-
unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
|
|
118
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
119
|
-
end
|
|
120
|
-
|
|
121
102
|
topic = @creative.topics.find(params[:id])
|
|
122
103
|
target_creative = Creative.find(params[:target_creative_id]).effective_origin
|
|
123
104
|
|
|
@@ -135,53 +116,29 @@ module Collavre
|
|
|
135
116
|
topic.update!(creative: target_creative)
|
|
136
117
|
end
|
|
137
118
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
{ action: "deleted", topic_id: topic.id }
|
|
141
|
-
)
|
|
142
|
-
TopicsChannel.broadcast_to(
|
|
143
|
-
target_creative,
|
|
144
|
-
{ action: "created", topic: topic.slice(:id, :name) }
|
|
145
|
-
)
|
|
119
|
+
broadcast_topic_event("deleted", topic_id: topic.id)
|
|
120
|
+
broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
|
|
146
121
|
|
|
147
122
|
render json: { success: true, topic: topic.slice(:id, :name), target_creative_id: target_creative.id }
|
|
148
123
|
end
|
|
149
124
|
|
|
150
125
|
def archive
|
|
151
|
-
unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
|
|
152
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
153
|
-
end
|
|
154
|
-
|
|
155
126
|
topic = @creative.topics.find(params[:id])
|
|
156
127
|
topic.archive!
|
|
157
128
|
|
|
158
|
-
|
|
159
|
-
@creative,
|
|
160
|
-
{ action: "archived", topic: topic.slice(:id, :name) }
|
|
161
|
-
)
|
|
129
|
+
broadcast_topic_event("archived", topic: topic.slice(:id, :name))
|
|
162
130
|
render json: { success: true }
|
|
163
131
|
end
|
|
164
132
|
|
|
165
133
|
def unarchive
|
|
166
|
-
unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
|
|
167
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
168
|
-
end
|
|
169
|
-
|
|
170
134
|
topic = @creative.topics.find(params[:id])
|
|
171
135
|
topic.unarchive!
|
|
172
136
|
|
|
173
|
-
|
|
174
|
-
@creative,
|
|
175
|
-
{ action: "unarchived", topic: topic.slice(:id, :name, :archived_at) }
|
|
176
|
-
)
|
|
137
|
+
broadcast_topic_event("unarchived", topic: topic.slice(:id, :name, :archived_at))
|
|
177
138
|
render json: { success: true }
|
|
178
139
|
end
|
|
179
140
|
|
|
180
141
|
def reorder
|
|
181
|
-
unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
|
|
182
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
183
|
-
end
|
|
184
|
-
|
|
185
142
|
topic_ids = params[:topic_ids]
|
|
186
143
|
unless topic_ids.is_a?(Array) && topic_ids.present?
|
|
187
144
|
render json: { error: "Invalid topic_ids" }, status: :unprocessable_entity and return
|
|
@@ -193,19 +150,12 @@ module Collavre
|
|
|
193
150
|
end
|
|
194
151
|
end
|
|
195
152
|
|
|
196
|
-
|
|
197
|
-
@creative,
|
|
198
|
-
{ action: "reordered", topic_ids: topic_ids }
|
|
199
|
-
)
|
|
153
|
+
broadcast_topic_event("reordered", topic_ids: topic_ids)
|
|
200
154
|
|
|
201
155
|
render json: { success: true }
|
|
202
156
|
end
|
|
203
157
|
|
|
204
158
|
def set_primary_agent
|
|
205
|
-
unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
|
|
206
|
-
render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
|
|
207
|
-
end
|
|
208
|
-
|
|
209
159
|
topic = @creative.topics.find(params[:id])
|
|
210
160
|
agent = User.find_by(id: params[:agent_id])
|
|
211
161
|
|
|
@@ -215,13 +165,7 @@ module Collavre
|
|
|
215
165
|
|
|
216
166
|
topic.set_primary_agent!(agent)
|
|
217
167
|
|
|
218
|
-
|
|
219
|
-
@creative,
|
|
220
|
-
{
|
|
221
|
-
action: "updated",
|
|
222
|
-
topic: topic_json_with_agent(topic, agent)
|
|
223
|
-
}
|
|
224
|
-
)
|
|
168
|
+
broadcast_topic_event("updated", topic: topic_json_with_agent(topic, agent))
|
|
225
169
|
|
|
226
170
|
render json: { success: true, topic: topic_json_with_agent(topic, agent) }
|
|
227
171
|
end
|
|
@@ -287,13 +231,11 @@ module Collavre
|
|
|
287
231
|
end
|
|
288
232
|
|
|
289
233
|
def agent_json(agent)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
initial: agent.display_name&.at(0)&.upcase || "?"
|
|
296
|
-
}
|
|
234
|
+
view_context.user_json(agent)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def broadcast_topic_event(action, creative: @creative, **payload)
|
|
238
|
+
TopicsChannel.broadcast_to(creative, { action: action, **payload })
|
|
297
239
|
end
|
|
298
240
|
end
|
|
299
241
|
end
|
|
@@ -9,6 +9,9 @@ module Collavre
|
|
|
9
9
|
|
|
10
10
|
def set_creative
|
|
11
11
|
@creative = Creative.find(params[:creative_id]).effective_origin
|
|
12
|
+
unless @creative.has_permission?(Current.user, :read)
|
|
13
|
+
render json: { error: I18n.t("collavre.creatives.errors.no_permission") }, status: :forbidden
|
|
14
|
+
end
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def set_comment
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module CreativePermissionGuard
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def require_creative_read!
|
|
10
|
+
return if @creative.has_permission?(Current.user, :read) || @creative.user == Current.user
|
|
11
|
+
|
|
12
|
+
render json: { error: creative_permission_denied_message }, status: :forbidden
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def require_creative_write!
|
|
16
|
+
return if @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
|
|
17
|
+
|
|
18
|
+
render json: { error: creative_permission_denied_message }, status: :forbidden
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def require_creative_admin!
|
|
22
|
+
return if @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
|
|
23
|
+
|
|
24
|
+
render json: { error: creative_permission_denied_message }, status: :forbidden
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Override in controllers that use a different i18n key.
|
|
28
|
+
def creative_permission_denied_message
|
|
29
|
+
I18n.t("collavre.topics.no_permission")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module IntegrationSetup
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def set_creative
|
|
10
|
+
@creative = Collavre::Creative.find(params[:creative_id])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def set_origin
|
|
14
|
+
@origin = @creative.effective_origin
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -81,11 +81,30 @@ module Collavre
|
|
|
81
81
|
safe_join(styles, "\n")
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
def user_json(user, email: false, ai_user: false)
|
|
85
|
+
data = {
|
|
86
|
+
id: user.id,
|
|
87
|
+
name: user.display_name,
|
|
88
|
+
avatar_url: user_avatar_url(user, size: 20),
|
|
89
|
+
default_avatar: !user.avatar.attached? && user.avatar_url.blank?,
|
|
90
|
+
initial: user.display_name&.at(0)&.upcase || "?"
|
|
91
|
+
}
|
|
92
|
+
data[:email] = user.email if email
|
|
93
|
+
data[:ai_user] = user.ai_user? if ai_user
|
|
94
|
+
data
|
|
95
|
+
end
|
|
96
|
+
|
|
84
97
|
private
|
|
85
98
|
|
|
99
|
+
CSS_VARIABLE_KEY_PATTERN = /\A--[a-zA-Z0-9_-]+\z/
|
|
100
|
+
CSS_VARIABLE_VALUE_PATTERN = /\A[^;}{<>"']+\z/
|
|
101
|
+
ALLOWED_COLOR_SCHEMES = %w[light dark].freeze
|
|
102
|
+
|
|
86
103
|
def render_theme_media_query(theme, mode)
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
return "" unless ALLOWED_COLOR_SCHEMES.include?(mode)
|
|
105
|
+
|
|
106
|
+
vars = safe_css_declarations(theme.variables)
|
|
107
|
+
legacy = safe_css_declarations(legacy_alias_declarations(theme.variables))
|
|
89
108
|
dark_filter = theme.dark? ? "--date-icon-filter: invert(0.8) !important;" : ""
|
|
90
109
|
|
|
91
110
|
<<~CSS
|
|
@@ -99,6 +118,15 @@ module Collavre
|
|
|
99
118
|
CSS
|
|
100
119
|
end
|
|
101
120
|
|
|
121
|
+
def safe_css_declarations(variables)
|
|
122
|
+
variables.filter_map { |k, v|
|
|
123
|
+
k = k.to_s
|
|
124
|
+
v = v.to_s
|
|
125
|
+
next unless k.match?(CSS_VARIABLE_KEY_PATTERN) && v.match?(CSS_VARIABLE_VALUE_PATTERN)
|
|
126
|
+
"#{k}: #{v} !important;"
|
|
127
|
+
}.join("\n ")
|
|
128
|
+
end
|
|
129
|
+
|
|
102
130
|
public
|
|
103
131
|
|
|
104
132
|
# Render all partials registered for a named extension slot.
|
|
@@ -48,6 +48,7 @@ import FileUploadPlugin, {
|
|
|
48
48
|
import { ImageNode } from "../lib/lexical/image_node"
|
|
49
49
|
import { AttachmentNode } from "../lib/lexical/attachment_node"
|
|
50
50
|
import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
|
|
51
|
+
import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
|
|
51
52
|
import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
|
|
52
53
|
import { updateResponsiveImages } from "../lib/responsive_images"
|
|
53
54
|
|
|
@@ -956,6 +957,7 @@ function EditorInner({
|
|
|
956
957
|
blobUrlTemplate={blobUrlTemplate}
|
|
957
958
|
/>
|
|
958
959
|
<AttachmentCleanupPlugin deletedAttachmentsRef={deletedAttachmentsRef} />
|
|
960
|
+
<MarkdownShortcutsPlugin />
|
|
959
961
|
{onEnterKey && <EnterKeyPlugin onEnterKey={onEnterKey} />}
|
|
960
962
|
</div>
|
|
961
963
|
</div>
|
|
@@ -966,14 +968,16 @@ function EnterKeyPlugin({ onEnterKey }) {
|
|
|
966
968
|
const [editor] = useLexicalComposerContext()
|
|
967
969
|
|
|
968
970
|
useEffect(() => {
|
|
969
|
-
// Use capture-phase keydown on the root element to intercept Enter
|
|
970
|
-
// BEFORE Lexical's own handlers process it
|
|
971
|
+
// Use capture-phase keydown on the root element to intercept Shift+Enter
|
|
972
|
+
// BEFORE Lexical's own handlers process it.
|
|
973
|
+
// Bare Enter is left to Lexical for newline insertion.
|
|
971
974
|
const rootElement = editor.getRootElement()
|
|
972
975
|
if (!rootElement) return
|
|
973
976
|
|
|
974
977
|
const handler = (event) => {
|
|
975
978
|
if (event.key !== 'Enter') return
|
|
976
|
-
if (event.shiftKey
|
|
979
|
+
if (!event.shiftKey) return // only intercept Shift+Enter
|
|
980
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
977
981
|
if (event.isComposing) return
|
|
978
982
|
|
|
979
983
|
if (onEnterKey(event, editor) === true) {
|
|
@@ -25,6 +25,7 @@ class CreativeTreeRow extends LitElement {
|
|
|
25
25
|
originLinkHtml: { state: true },
|
|
26
26
|
isTitle: { type: Boolean, attribute: "is-title", reflect: true },
|
|
27
27
|
archived: { type: Boolean, attribute: "archived", reflect: true },
|
|
28
|
+
githubSource: { type: Boolean, attribute: "github-source", reflect: true },
|
|
28
29
|
loadingChildren: { type: Boolean, attribute: "loading-children", reflect: true },
|
|
29
30
|
_loadingDotsState: { state: true },
|
|
30
31
|
editingUsers: { state: true }
|
|
@@ -48,6 +49,7 @@ class CreativeTreeRow extends LitElement {
|
|
|
48
49
|
this.editOffIconHtml = "";
|
|
49
50
|
this.originLinkHtml = "";
|
|
50
51
|
this.isTitle = false;
|
|
52
|
+
this.githubSource = false;
|
|
51
53
|
this.editingUsers = []; // [{ user_id, user_name, avatar_url }]
|
|
52
54
|
this._templatesExtracted = false;
|
|
53
55
|
this.loadingChildren = false;
|
|
@@ -315,12 +317,22 @@ class CreativeTreeRow extends LitElement {
|
|
|
315
317
|
`;
|
|
316
318
|
}
|
|
317
319
|
|
|
320
|
+
_renderGithubBadge() {
|
|
321
|
+
if (!this.githubSource) return nothing;
|
|
322
|
+
return html`<span class="github-source-badge" title="Synced from GitHub (read-only)">
|
|
323
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="vertical-align: -2px; opacity: 0.5;">
|
|
324
|
+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
|
325
|
+
</svg>
|
|
326
|
+
</span>`;
|
|
327
|
+
}
|
|
328
|
+
|
|
318
329
|
_renderContent() {
|
|
319
330
|
const level = Number(this.level) || 1;
|
|
320
331
|
// Toggle is now rendered outside
|
|
332
|
+
const githubBadge = this._renderGithubBadge();
|
|
321
333
|
const content = html`
|
|
322
334
|
<div class="creative-content" @click=${this._handleContentClick}>
|
|
323
|
-
${unsafeHTML(this.descriptionHtml || "")}
|
|
335
|
+
${githubBadge}${unsafeHTML(this.descriptionHtml || "")}
|
|
324
336
|
</div>
|
|
325
337
|
`;
|
|
326
338
|
const indicator = this.loadingChildren ? this._renderLoadingIndicator() : nothing;
|
|
@@ -579,6 +591,14 @@ class CreativeTreeRow extends LitElement {
|
|
|
579
591
|
}
|
|
580
592
|
|
|
581
593
|
_handleContentClick(event) {
|
|
594
|
+
// In select mode, block navigation — selection toggle is handled by
|
|
595
|
+
// select_mode_controller's mousedown handler to avoid double-toggling.
|
|
596
|
+
if (this.selectMode) {
|
|
597
|
+
event.preventDefault();
|
|
598
|
+
event.stopPropagation();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
582
602
|
// Check if the clicked element is an interactive element or inside one
|
|
583
603
|
const target = event.target;
|
|
584
604
|
if (target.tagName === 'A' || target.closest('a') ||
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect } from "react"
|
|
2
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
3
|
+
import {
|
|
4
|
+
registerMarkdownShortcuts,
|
|
5
|
+
UNORDERED_LIST,
|
|
6
|
+
ORDERED_LIST,
|
|
7
|
+
CODE
|
|
8
|
+
} from "@lexical/markdown"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Markdown-style shortcuts for the creative inline editor:
|
|
12
|
+
*
|
|
13
|
+
* - "* " / "- " / "+ " at line start → unordered list
|
|
14
|
+
* - "1. " (any number) at line start → ordered list
|
|
15
|
+
* - "```" + space at line start → code block
|
|
16
|
+
*
|
|
17
|
+
* These fire on text change (not on Enter), so they don't conflict
|
|
18
|
+
* with the Enter→addNew() shortcut on desktop.
|
|
19
|
+
*/
|
|
20
|
+
const CREATIVE_MARKDOWN_TRANSFORMERS = [
|
|
21
|
+
UNORDERED_LIST,
|
|
22
|
+
ORDERED_LIST,
|
|
23
|
+
CODE
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export default function MarkdownShortcutsPlugin() {
|
|
27
|
+
const [editor] = useLexicalComposerContext()
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
return registerMarkdownShortcuts(editor, CREATIVE_MARKDOWN_TRANSFORMERS)
|
|
31
|
+
}, [editor])
|
|
32
|
+
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
import { renderCommentMarkdown, renderMermaidDiagrams } from '../lib/utils/markdown'
|
|
3
3
|
import { addTableDownloadButtons } from '../lib/utils/table_download'
|
|
4
4
|
import CommonPopup from '../lib/common_popup'
|
|
5
|
+
import csrfFetch from '../lib/api/csrf_fetch'
|
|
5
6
|
|
|
6
7
|
// Global tracker: persists streaming state across Turbo replacements
|
|
7
8
|
// (each replacement creates a new controller instance, losing instance state)
|
|
@@ -163,6 +164,22 @@ export default class extends Controller {
|
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
cancelTask(event) {
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
event.stopPropagation()
|
|
170
|
+
const taskId = event.currentTarget.dataset.taskId
|
|
171
|
+
if (!taskId) return
|
|
172
|
+
event.currentTarget.disabled = true
|
|
173
|
+
csrfFetch(`/tasks/${taskId}/cancel`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
176
|
+
})
|
|
177
|
+
.then((response) => {
|
|
178
|
+
if (!response.ok) event.currentTarget.disabled = false
|
|
179
|
+
})
|
|
180
|
+
.catch(() => { event.currentTarget.disabled = false })
|
|
181
|
+
}
|
|
182
|
+
|
|
166
183
|
triggerReactionPicker(event) {
|
|
167
184
|
event.preventDefault()
|
|
168
185
|
event.stopPropagation()
|
|
@@ -103,6 +103,7 @@ export default class extends Controller {
|
|
|
103
103
|
this.currentTopicId = event.detail.topicId
|
|
104
104
|
this._isInbox = event.detail.isInbox || false
|
|
105
105
|
this._systemTopicId = event.detail.systemTopicId || null
|
|
106
|
+
this._mainTopicId = event.detail.mainTopicId || null
|
|
106
107
|
this._updateInboxReplyMode()
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -265,8 +266,9 @@ export default class extends Controller {
|
|
|
265
266
|
const wasPrivate = this.privateCheckboxTarget?.checked ?? false
|
|
266
267
|
|
|
267
268
|
const formData = new FormData(this.formTarget)
|
|
268
|
-
|
|
269
|
-
|
|
269
|
+
const effectiveTopicId = this.currentTopicId || this._mainTopicId
|
|
270
|
+
if (effectiveTopicId) {
|
|
271
|
+
formData.append('comment[topic_id]', effectiveTopicId)
|
|
270
272
|
}
|
|
271
273
|
if (this._pendingReviewType) {
|
|
272
274
|
formData.append('comment[review_type]', this._pendingReviewType)
|
|
@@ -763,8 +765,9 @@ export default class extends Controller {
|
|
|
763
765
|
}
|
|
764
766
|
const isPrivate = this.privateCheckboxTarget?.checked ?? false
|
|
765
767
|
if (isPrivate) formData.append('comment[private]', '1')
|
|
766
|
-
|
|
767
|
-
|
|
768
|
+
const effectiveTopicId = this.currentTopicId || this._mainTopicId
|
|
769
|
+
if (effectiveTopicId) {
|
|
770
|
+
formData.append('comment[topic_id]', effectiveTopicId)
|
|
768
771
|
}
|
|
769
772
|
|
|
770
773
|
const url = `/creatives/${this.creativeId}/comments`
|
|
@@ -579,10 +579,9 @@ export default class extends Controller {
|
|
|
579
579
|
bar.querySelector('.selection-action-branch').addEventListener('click', (e) => { e.stopPropagation(); this.branchSelectedComments() })
|
|
580
580
|
bar.querySelector('.selection-action-bar-close').addEventListener('click', () => this.clearSelection())
|
|
581
581
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
typingIndicator.parentNode.insertBefore(bar, typingIndicator)
|
|
582
|
+
const typingRow = this.element.querySelector('#typing-indicator-row') || this.element.querySelector('#typing-indicator')
|
|
583
|
+
if (typingRow) {
|
|
584
|
+
typingRow.parentNode.insertBefore(bar, typingRow)
|
|
586
585
|
} else {
|
|
587
586
|
this.element.appendChild(bar)
|
|
588
587
|
}
|
|
@@ -1061,6 +1060,46 @@ export default class extends Controller {
|
|
|
1061
1060
|
.finally(() => { this.movingComments = false })
|
|
1062
1061
|
}
|
|
1063
1062
|
|
|
1063
|
+
scrollToPreviousMessage() {
|
|
1064
|
+
const list = this.listTarget
|
|
1065
|
+
const items = Array.from(list.querySelectorAll('.comment-item'))
|
|
1066
|
+
if (items.length === 0) return
|
|
1067
|
+
|
|
1068
|
+
const listRect = list.getBoundingClientRect()
|
|
1069
|
+
const viewportTop = listRect.top
|
|
1070
|
+
|
|
1071
|
+
let currentIdx = -1
|
|
1072
|
+
for (let i = 0; i < items.length; i++) {
|
|
1073
|
+
const rect = items[i].getBoundingClientRect()
|
|
1074
|
+
if (rect.top >= viewportTop - 2) {
|
|
1075
|
+
currentIdx = i
|
|
1076
|
+
break
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (currentIdx === -1) currentIdx = items.length - 1
|
|
1081
|
+
|
|
1082
|
+
const currentItem = items[currentIdx]
|
|
1083
|
+
const currentRect = currentItem.getBoundingClientRect()
|
|
1084
|
+
const isAtTop = Math.abs(currentRect.top - viewportTop) < 4
|
|
1085
|
+
|
|
1086
|
+
const targetIdx = isAtTop ? currentIdx - 1 : currentIdx
|
|
1087
|
+
if (targetIdx < 0) {
|
|
1088
|
+
if (!this.allOlderLoaded) {
|
|
1089
|
+
this.loadOlderComments()
|
|
1090
|
+
}
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const target = items[targetIdx]
|
|
1095
|
+
const targetTop = target.offsetTop - list.offsetTop
|
|
1096
|
+
list.scrollTo({ top: targetTop, behavior: 'smooth' })
|
|
1097
|
+
this.stickToBottom = false
|
|
1098
|
+
|
|
1099
|
+
target.classList.add('highlight-flash')
|
|
1100
|
+
setTimeout(() => target.classList.remove('highlight-flash'), 2000)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1064
1103
|
// UI Helpers
|
|
1065
1104
|
updateStickiness() {
|
|
1066
1105
|
this.stickToBottom = this.isNearBottom()
|