panda-cms 0.8.2 → 0.10.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +75 -5
  3. data/app/components/panda/cms/code_component.rb +154 -39
  4. data/app/components/panda/cms/grid_component.rb +26 -6
  5. data/app/components/panda/cms/menu_component.rb +72 -34
  6. data/app/components/panda/cms/page_menu_component.rb +102 -13
  7. data/app/components/panda/cms/rich_text_component.rb +229 -139
  8. data/app/components/panda/cms/text_component.rb +107 -42
  9. data/app/controllers/panda/cms/admin/base_controller.rb +19 -3
  10. data/app/controllers/panda/cms/admin/dashboard_controller.rb +3 -3
  11. data/app/controllers/panda/cms/admin/files_controller.rb +7 -0
  12. data/app/controllers/panda/cms/admin/menus_controller.rb +47 -3
  13. data/app/controllers/panda/cms/admin/pages_controller.rb +11 -2
  14. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  15. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  16. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  17. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  18. data/app/helpers/panda/cms/application_helper.rb +17 -4
  19. data/app/helpers/panda/cms/asset_helper.rb +14 -61
  20. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  21. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  22. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +5 -1
  23. data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
  24. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  25. data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
  26. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  27. data/app/javascript/panda/cms/controllers/index.js +54 -13
  28. data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
  29. data/app/javascript/panda/cms/controllers/menu_form_controller.js +53 -0
  30. data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
  31. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  32. data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
  33. data/app/javascript/panda/cms/stimulus-loading.js +6 -7
  34. data/app/models/panda/cms/block_content.rb +9 -0
  35. data/app/models/panda/cms/menu.rb +12 -0
  36. data/app/models/panda/cms/page.rb +147 -0
  37. data/app/models/panda/cms/post.rb +98 -0
  38. data/app/views/layouts/homepage.html.erb +1 -4
  39. data/app/views/layouts/page.html.erb +1 -4
  40. data/app/views/panda/cms/admin/dashboard/show.html.erb +5 -5
  41. data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
  42. data/app/views/panda/cms/admin/files/index.html.erb +11 -118
  43. data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
  44. data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
  45. data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
  46. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
  47. data/app/views/panda/cms/admin/menus/edit.html.erb +62 -0
  48. data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
  49. data/app/views/panda/cms/admin/menus/new.html.erb +38 -0
  50. data/app/views/panda/cms/admin/pages/edit.html.erb +147 -22
  51. data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
  52. data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
  53. data/app/views/panda/cms/admin/posts/_form.html.erb +44 -15
  54. data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
  55. data/app/views/panda/cms/admin/posts/index.html.erb +6 -6
  56. data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
  57. data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
  58. data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
  59. data/app/views/shared/_header.html.erb +1 -4
  60. data/config/brakeman.ignore +38 -0
  61. data/config/importmap.rb +10 -10
  62. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
  63. data/config/initializers/panda/cms.rb +52 -10
  64. data/config/locales/en.yml +41 -0
  65. data/config/routes.rb +5 -3
  66. data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
  67. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
  68. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
  69. data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
  70. data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
  71. data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
  72. data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
  73. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  74. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  75. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  76. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  77. data/lib/generators/panda/cms/install_generator.rb +2 -5
  78. data/lib/panda/cms/asset_loader.rb +46 -76
  79. data/lib/panda/cms/bulk_editor.rb +288 -12
  80. data/lib/panda/cms/debug.rb +29 -0
  81. data/lib/panda/cms/engine/asset_config.rb +49 -0
  82. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  83. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  84. data/lib/panda/cms/engine/core_config.rb +106 -0
  85. data/lib/panda/cms/engine/helper_config.rb +20 -0
  86. data/lib/panda/cms/engine/route_config.rb +34 -0
  87. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  88. data/lib/panda/cms/engine.rb +44 -162
  89. data/lib/panda/cms/features.rb +52 -0
  90. data/lib/panda/cms.rb +10 -0
  91. data/lib/panda-cms/version.rb +1 -1
  92. data/lib/panda-cms.rb +20 -7
  93. data/lib/tasks/panda_cms_tasks.rake +16 -0
  94. metadata +41 -50
  95. data/app/components/panda/cms/admin/container_component.html.erb +0 -13
  96. data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
  97. data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
  98. data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
  99. data/app/components/panda/cms/admin/slideover_component.rb +0 -15
  100. data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
  101. data/app/components/panda/cms/admin/statistics_component.rb +0 -16
  102. data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
  103. data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
  104. data/app/components/panda/cms/admin/table_component.html.erb +0 -29
  105. data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
  106. data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
  107. data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
  108. data/app/components/panda/cms/admin/user_display_component.rb +0 -21
  109. data/app/components/panda/cms/grid_component.html.erb +0 -6
  110. data/app/components/panda/cms/menu_component.html.erb +0 -6
  111. data/app/components/panda/cms/page_menu_component.html.erb +0 -21
  112. data/app/components/panda/cms/rich_text_component.html.erb +0 -90
  113. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  114. data/app/views/layouts/panda/cms/application.html.erb +0 -42
  115. data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
  116. data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
  117. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
  118. data/app/views/panda/cms/shared/_footer.html.erb +0 -2
  119. data/app/views/panda/cms/shared/_header.html.erb +0 -25
  120. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  121. data/config/initializers/inflections.rb +0 -5
  122. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
  123. data/lib/tasks/assets.rake +0 -587
@@ -2,162 +2,232 @@
2
2
 
3
3
  module Panda
4
4
  module CMS
5
- # Text component
6
- # @param key [Symbol] The key to use for the text component
7
- # @param text [String] The text to display
5
+ # Rich text component for EditorJS-based content editing
6
+ # @param key [Symbol] The key to use for the rich text component
7
+ # @param text [String] The default text to display
8
8
  # @param editable [Boolean] If the text is editable or not (defaults to true)
9
- # @param options [Hash] The options to pass to the content_tag
10
- class RichTextComponent < ViewComponent::Base
9
+ class RichTextComponent < Panda::Core::Base
11
10
  class ComponentError < StandardError; end
12
11
 
13
12
  KIND = "rich_text"
14
13
 
15
- attr_accessor :editable, :content, :options
14
+ prop :key, Symbol, default: :text_component
15
+ prop :text, String, default: "Lorem ipsum..."
16
+ prop :editable, _Boolean, default: true
16
17
 
17
- def initialize(key: :text_component, text: "Lorem ipsum...", editable: true, **options)
18
- @key = key
19
- @text = text
20
- @options = options || {}
21
- @editable = editable
18
+ attr_accessor :content, :block_content_id
19
+
20
+ def view_template
21
+ # Russian doll caching: Cache component output at block_content level
22
+ # Only cache in non-editable mode (public-facing pages)
23
+ if should_cache?
24
+ raw cache_component_output
25
+ else
26
+ render_content
27
+ end
22
28
  end
23
29
 
24
- # Check if the element is editable and set up the content
25
- def before_render
26
- @editable &&= params[:embed_id].present? && params[:embed_id] == Current.page.id && Current.user.admin?
30
+ def render_content
31
+ div(class: "panda-cms-content", **element_attrs) do
32
+ if @editable_state
33
+ # Empty div for EditorJS to initialize into
34
+ else
35
+ raw(@rendered_content.html_safe)
36
+ end
37
+ end
38
+ end
27
39
 
28
- block = Panda::CMS::Block.find_by(kind: "rich_text", key: @key,
29
- panda_cms_template_id: Current.page.panda_cms_template_id)
40
+ def before_template
41
+ setup_editability
42
+ load_block_content
43
+ prepare_content
44
+ rescue ActiveRecord::RecordNotFound => e
45
+ handle_error(ComponentError.new("Database record not found: #{e.message}"))
46
+ rescue ActiveRecord::RecordInvalid => e
47
+ handle_error(ComponentError.new("Invalid record: #{e.message}"))
48
+ rescue => e
49
+ handle_error(e)
50
+ end
51
+
52
+ private
53
+
54
+ def setup_editability
55
+ @editable_state = @editable &&
56
+ view_context.params[:embed_id].present? &&
57
+ view_context.params[:embed_id] == Current.page.id &&
58
+ Current.user&.admin?
59
+ end
60
+
61
+ def load_block_content
62
+ block = Panda::CMS::Block.find_by(
63
+ kind: KIND,
64
+ key: @key,
65
+ panda_cms_template_id: Current.page.panda_cms_template_id
66
+ )
30
67
  raise ComponentError, "Block not found for key: #{@key}" unless block
31
68
 
32
- block_content = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
33
- if block_content.nil?
34
- block_content = Panda::CMS::BlockContent.create!(
69
+ @block_content = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
70
+
71
+ if @block_content.nil?
72
+ @block_content = Panda::CMS::BlockContent.create!(
35
73
  block: block,
36
74
  panda_cms_page_id: Current.page.id,
37
75
  content: empty_editor_js_content
38
76
  )
39
77
  end
40
78
 
41
- raw_content = block_content.cached_content || block_content.content
79
+ @block_content_id = @block_content.id
80
+ raw_content = @block_content.cached_content || @block_content.content
42
81
  @content = raw_content.presence || empty_editor_js_content
43
- @options[:id] = block_content.id
82
+ end
83
+
84
+ def prepare_content
85
+ if @editable_state
86
+ prepare_editable_content
87
+ else
88
+ prepare_display_content
89
+ end
90
+ end
44
91
 
45
- # Debug log the content
46
- Rails.logger.debug("RichTextComponent content before processing: #{@content.inspect}")
92
+ def prepare_editable_content
93
+ @editor_content = if @content.blank? || @content == "{}"
94
+ empty_editor_js_content
95
+ else
96
+ process_content_for_editor(@content)
97
+ end
47
98
 
48
- if @editable
49
- @options[:data] = {
50
- page_id: Current.page.id,
51
- mode: "rich_text"
52
- }
99
+ @encoded_data = Base64.strict_encode64(@editor_content.to_json)
100
+ rescue => e
101
+ Rails.logger.error("Content processing error: #{e.message}\nContent: #{@content.inspect}")
102
+ @editor_content = empty_editor_js_content
103
+ @encoded_data = Base64.strict_encode64(@editor_content.to_json)
104
+ end
53
105
 
54
- # For editable mode, always ensure we have a valid EditorJS structure
55
- @content = if @content.blank? || @content == "{}"
56
- empty_editor_js_content
57
- else
58
- begin
59
- if @content.is_a?(String)
60
- # Try to parse as JSON first
61
- begin
62
- parsed = JSON.parse(@content)
63
- if valid_editor_js_content?(parsed)
64
- # Ensure the content is properly structured
65
- {
66
- "time" => parsed["time"] || Time.current.to_i * 1000,
67
- "blocks" => parsed["blocks"].map do |block|
68
- {
69
- "type" => block["type"],
70
- "data" => block["data"].merge(
71
- "text" => block["data"]["text"].to_s.presence || ""
72
- ),
73
- "tunes" => block["tunes"]
74
- }.compact
75
- end,
76
- "version" => parsed["version"] || "2.28.2"
77
- }
78
- else
79
- # If not valid EditorJS, try to convert from HTML
80
- begin
81
- editor_content = Panda::Editor::HtmlToEditorJsConverter.convert(@content)
82
- if valid_editor_js_content?(editor_content)
83
- editor_content
84
- else
85
- empty_editor_js_content
86
- end
87
- rescue Panda::Editor::HtmlToEditorJsConverter::ConversionError => e
88
- Rails.logger.error("HTML conversion error: #{e.message}")
89
- empty_editor_js_content
90
- end
91
- end
92
- rescue JSON::ParserError => e
93
- Rails.logger.error("JSON parse error: #{e.message}")
94
- # Try to convert from HTML
95
- begin
96
- editor_content = Panda::Editor::HtmlToEditorJsConverter.convert(@content)
97
- if valid_editor_js_content?(editor_content)
98
- editor_content
99
- else
100
- empty_editor_js_content
101
- end
102
- rescue Panda::Editor::HtmlToEditorJsConverter::ConversionError => e
103
- Rails.logger.error("HTML conversion error: #{e.message}")
104
- empty_editor_js_content
105
- end
106
- end
107
- else
108
- # If it's not a string, assume it's already in the correct format
109
- valid_editor_js_content?(@content) ? @content : empty_editor_js_content
110
- end
111
- rescue => e
112
- Rails.logger.error("Content processing error: #{e.message}\nContent: #{@content.inspect}")
113
- empty_editor_js_content
114
- end
115
- end
106
+ def prepare_display_content
107
+ @rendered_content = if @content.blank? || @content == "{}"
108
+ "<p></p>"
116
109
  else
117
- # For non-editable mode, handle content display
118
- @content = if @content.blank? || @content == "{}"
119
- "<p></p>".html_safe
110
+ render_content_for_display(@content)
111
+ end
112
+ rescue => e
113
+ Rails.logger.error("RichTextComponent render error: #{e.message}\nContent: #{@content.inspect}")
114
+ @rendered_content = "<p></p>"
115
+ end
116
+
117
+ def process_content_for_editor(content)
118
+ parsed = if content.is_a?(String)
119
+ JSON.parse(content)
120
+ else
121
+ content
122
+ end
123
+
124
+ if valid_editor_js_content?(parsed)
125
+ normalize_editor_content(parsed)
126
+ else
127
+ convert_html_to_editor_js(content)
128
+ end
129
+ rescue JSON::ParserError
130
+ convert_html_to_editor_js(content)
131
+ end
132
+
133
+ def normalize_editor_content(parsed)
134
+ {
135
+ "time" => parsed["time"] || Time.current.to_i * 1000,
136
+ "blocks" => (parsed["blocks"] || []).map { |block| normalize_block(block) },
137
+ "version" => parsed["version"] || "2.28.2"
138
+ }
139
+ end
140
+
141
+ def normalize_block(block)
142
+ case block["type"]
143
+ when "paragraph"
144
+ block.merge("data" => block["data"].merge("text" => block["data"]["text"].to_s.presence || ""))
145
+ when "header"
146
+ block.merge("data" => block["data"].merge(
147
+ "text" => block["data"]["text"].to_s.presence || "",
148
+ "level" => block["data"]["level"].to_i
149
+ ))
150
+ when "list"
151
+ block.merge("data" => block["data"].merge(
152
+ "items" => (block["data"]["items"] || []).map { |item| item.to_s.presence || "" }
153
+ ))
154
+ else
155
+ block
156
+ end
157
+ end
158
+
159
+ def convert_html_to_editor_js(content)
160
+ editor_content = Panda::Editor::HtmlToEditorJsConverter.convert(content.to_s)
161
+ valid_editor_js_content?(editor_content) ? editor_content : empty_editor_js_content
162
+ rescue Panda::Editor::HtmlToEditorJsConverter::ConversionError => e
163
+ Rails.logger.error("HTML conversion error: #{e.message}")
164
+ empty_editor_js_content
165
+ end
166
+
167
+ def render_content_for_display(content)
168
+ # Try to parse as JSON if it looks like EditorJS format
169
+ if content.is_a?(String) && content.strip.match?(/^\{.*"blocks":\s*\[.*\].*\}$/m)
170
+ parsed_content = JSON.parse(content)
171
+ if valid_editor_js_content?(parsed_content)
172
+ render_editor_js_content(parsed_content)
120
173
  else
121
- begin
122
- # Try to parse as JSON if it looks like EditorJS format
123
- if @content.is_a?(String) && @content.strip.match?(/^\{.*"blocks":\s*\[.*\].*\}$/m)
124
- parsed_content = JSON.parse(@content)
125
- if valid_editor_js_content?(parsed_content)
126
- # Check if it's just an empty paragraph
127
- if parsed_content["blocks"].length == 1 &&
128
- parsed_content["blocks"][0]["type"] == "paragraph" &&
129
- parsed_content["blocks"][0]["data"]["text"].blank?
130
- "<p></p>".html_safe
131
- else
132
- renderer = Panda::Editor::Renderer.new(parsed_content)
133
- rendered = renderer.render
134
- rendered.presence&.html_safe || "<p></p>".html_safe
135
- end
136
- else
137
- process_html(@content)
138
- end
139
- else
140
- process_html(@content)
141
- end
142
- rescue JSON::ParserError
143
- process_html(@content)
144
- rescue => e
145
- Rails.logger.error("RichTextComponent render error: #{e.message}\nContent: #{@content.inspect}")
146
- "<p></p>".html_safe
147
- end
174
+ process_html_content(content)
148
175
  end
176
+ else
177
+ process_html_content(content)
149
178
  end
150
- rescue ActiveRecord::RecordNotFound => e
151
- raise ComponentError, "Database record not found: #{e.message}"
152
- rescue ActiveRecord::RecordInvalid => e
153
- raise ComponentError, "Invalid record: #{e.message}"
154
- rescue => e
155
- Rails.logger.error("RichTextComponent error: #{e.message}\nContent: #{@content.inspect}")
156
- @content = @editable ? empty_editor_js_content : "<p></p>".html_safe
157
- nil
179
+ rescue JSON::ParserError
180
+ process_html_content(content)
158
181
  end
159
182
 
160
- private
183
+ def render_editor_js_content(parsed_content)
184
+ # Check if it's just an empty paragraph
185
+ if parsed_content["blocks"].length == 1 &&
186
+ parsed_content["blocks"][0]["type"] == "paragraph" &&
187
+ parsed_content["blocks"][0]["data"]["text"].blank?
188
+ "<p></p>"
189
+ else
190
+ renderer = Panda::Editor::Renderer.new(parsed_content)
191
+ rendered = renderer.render
192
+ rendered.presence || "<p></p>"
193
+ end
194
+ end
195
+
196
+ def process_html_content(content)
197
+ return "<p></p>" if content.blank?
198
+
199
+ # If it's already HTML, return it
200
+ if content.match?(/<[^>]+>/)
201
+ content
202
+ else
203
+ # Wrap plain text in paragraph tags
204
+ "<p>#{content}</p>"
205
+ end
206
+ end
207
+
208
+ def element_attrs
209
+ attrs = {class: "panda-cms-content"}
210
+
211
+ if @editable_state
212
+ attrs[:id] = "editor-#{@block_content_id}"
213
+ attrs[:data] = {
214
+ "editable-previous-data": @encoded_data,
215
+ "editable-content": @encoded_data,
216
+ "editable-initialized": "false",
217
+ "editable-version": "2.28.2",
218
+ "editable-autosave": "false",
219
+ "editable-tools": '{"paragraph":true,"header":true,"list":true,"quote":true,"table":true}',
220
+ "editable-kind": "rich_text",
221
+ "editable-block-content-id": @block_content_id,
222
+ "editable-page-id": Current.page.id,
223
+ controller: "editor-js",
224
+ "editor-js-initialized-value": "false",
225
+ "editor-js-content-value": @encoded_data
226
+ }
227
+ end
228
+
229
+ attrs
230
+ end
161
231
 
162
232
  def empty_editor_js_content
163
233
  {
@@ -173,21 +243,41 @@ module Panda
173
243
  false
174
244
  end
175
245
 
176
- def process_html(content)
177
- return "<p></p>".html_safe if content.blank?
246
+ def handle_error(error)
247
+ Rails.logger.error("RichTextComponent error: #{error.message}\nContent: #{@content.inspect}")
178
248
 
179
- # If it's already HTML, just return it
180
- if content.match?(/<[^>]+>/)
181
- content.html_safe
249
+ if @editable_state
250
+ @editor_content = empty_editor_js_content
251
+ @encoded_data = Base64.strict_encode64(@editor_content.to_json)
182
252
  else
183
- # Wrap plain text in paragraph tags
184
- "<p>#{content}</p>".html_safe
253
+ @rendered_content = "<p></p>"
185
254
  end
255
+
256
+ nil
257
+ end
258
+
259
+ def should_cache?
260
+ !@editable_state &&
261
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
262
+ @block_content.present?
263
+ end
264
+
265
+ def cache_component_output
266
+ cache_key = cache_key_for_component
267
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
268
+
269
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
270
+ render_content_to_string
271
+ end.html_safe
272
+ end
273
+
274
+ def cache_key_for_component
275
+ "panda_cms/rich_text_component/#{@block_content.cache_key_with_version}/#{@key}"
186
276
  end
187
277
 
188
- # Only render the component if there is some content set, or if the component is editable
189
- def render?
190
- true # Always render, we'll show empty content if needed
278
+ def render_content_to_string
279
+ # Render the component HTML to a string for caching
280
+ helpers.content_tag(:div, @rendered_content.html_safe, class: "panda-cms-content", **element_attrs)
191
281
  end
192
282
  end
193
283
  end
@@ -2,69 +2,134 @@
2
2
 
3
3
  module Panda
4
4
  module CMS
5
- # Text component
5
+ # Text component for editable plain text content
6
6
  # @param key [Symbol] The key to use for the text component
7
- # @param text [String] The text to display
7
+ # @param text [String] The default text to display
8
8
  # @param editable [Boolean] If the text is editable or not (defaults to true)
9
- # @param options [Hash] The options to pass to the content_tag
10
- class TextComponent < ViewComponent::Base
9
+ class TextComponent < Panda::Core::Base
11
10
  KIND = "plain_text"
12
11
 
13
- # Allows accessing the plain text of the component directly
12
+ prop :key, Symbol, default: :text_component
13
+ prop :text, String, default: "Lorem ipsum..."
14
+ prop :editable, _Boolean, default: true
15
+
14
16
  attr_accessor :plain_text
15
17
 
16
- def initialize(key: :text_component, text: "Lorem ipsum...", editable: true, **options)
17
- @key = key
18
- @text = text
19
- @options = options || {}
20
- @options[:id] ||= "text-#{key.to_s.dasherize}"
21
- @editable = editable
22
- end
18
+ def view_template
19
+ return unless @content
23
20
 
24
- def call
25
- content_tag(:span, @content, @options, false) # Don't escape the content
26
- rescue
27
- if !Rails.env.production? || is_defined?(Sentry)
28
- raise Panda::CMS::MissingBlockError, "Block with key #{@key} not found for page #{Current.page.title}"
21
+ # Russian doll caching: Cache component output at block_content level
22
+ # Only cache in non-editable mode (public-facing pages)
23
+ if should_cache?
24
+ raw cache_component_output
25
+ else
26
+ render_content
29
27
  end
30
-
31
- false
28
+ rescue => e
29
+ handle_error(e)
32
30
  end
33
31
 
34
- #
35
- # Prepares content for display
36
- #
37
- # @usage Do not use this when rendering editable content
38
- def prepare_content_for_display(content)
39
- # Replace \n characters with <br> tags
40
- content.gsub("\n", "<br>")
32
+ def before_template
33
+ prepare_content
41
34
  end
42
35
 
43
- # Check if the element is editable
44
- # TODO: Check user permissions
45
- def before_render
46
- @editable &&= params[:embed_id].present? && params[:embed_id] == Current.page.id
36
+ private
47
37
 
48
- block = Panda::CMS::Block.find_by(kind: KIND, key: @key,
49
- panda_cms_template_id: Current.page.panda_cms_template_id)
38
+ def prepare_content
39
+ @editable_state = @editable && is_editable_context?
50
40
 
41
+ block = find_block
51
42
  return false if block.nil?
52
43
 
53
- block_content = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
54
- plain_text = block_content&.content.to_s
55
- if @editable
56
- @options[:contenteditable] = "plaintext-only"
57
- @options[:data] = {
44
+ find_block_content(block)
45
+ @plain_text = @block_content_obj&.content.to_s
46
+
47
+ if @editable_state
48
+ setup_editable_content(@block_content_obj)
49
+ else
50
+ @content = prepare_content_for_display(@plain_text)
51
+ end
52
+ end
53
+
54
+ def find_block
55
+ Panda::CMS::Block.find_by(
56
+ kind: KIND,
57
+ key: @key,
58
+ panda_cms_template_id: Current.page.panda_cms_template_id
59
+ )
60
+ end
61
+
62
+ def find_block_content(block)
63
+ @block_content_obj = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
64
+ end
65
+
66
+ def setup_editable_content(block_content)
67
+ @content = @plain_text
68
+ @block_content_id = block_content&.id
69
+ end
70
+
71
+ def element_attrs
72
+ attrs = @attrs.merge(id: element_id)
73
+
74
+ if @editable_state
75
+ attrs[:contenteditable] = "plaintext-only"
76
+ attrs[:data] = {
58
77
  "editable-kind": "plain_text",
59
78
  "editable-page-id": Current.page.id,
60
- "editable-block-content-id": block_content&.id
79
+ "editable-block-content-id": @block_content_id
61
80
  }
81
+ end
62
82
 
63
- @options[:id] = "editor-#{block_content&.id}"
64
- @content = plain_text
65
- else
66
- @content = prepare_content_for_display(plain_text)
83
+ attrs
84
+ end
85
+
86
+ def element_id
87
+ @editable_state ? "editor-#{@block_content_id}" : "text-#{@key.to_s.dasherize}"
88
+ end
89
+
90
+ def prepare_content_for_display(content)
91
+ # Replace \n characters with <br> tags
92
+ content.gsub("\n", "<br>")
93
+ end
94
+
95
+ def is_editable_context?
96
+ view_context.params[:embed_id].present? && view_context.params[:embed_id] == Current.page.id
97
+ end
98
+
99
+ def handle_error(_error)
100
+ if !Rails.env.production? || defined?(Sentry)
101
+ raise Panda::CMS::MissingBlockError, "Block with key #{@key} not found for page #{Current.page.title}"
67
102
  end
103
+
104
+ false
105
+ end
106
+
107
+ def should_cache?
108
+ !@editable_state &&
109
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
110
+ @block_content_obj.present?
111
+ end
112
+
113
+ def cache_component_output
114
+ cache_key = cache_key_for_component
115
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
116
+
117
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
118
+ render_content_to_string
119
+ end.html_safe
120
+ end
121
+
122
+ def cache_key_for_component
123
+ "panda_cms/text_component/#{@block_content_obj.cache_key_with_version}/#{@key}"
124
+ end
125
+
126
+ def render_content
127
+ span(**element_attrs) { raw(@content.html_safe) }
128
+ end
129
+
130
+ def render_content_to_string
131
+ # Phlex doesn't have a direct way to capture output, so we render directly
132
+ helpers.content_tag(:span, @content.html_safe, **element_attrs)
68
133
  end
69
134
  end
70
135
  end
@@ -4,11 +4,27 @@ module Panda
4
4
  module CMS
5
5
  module Admin
6
6
  # Base controller for all CMS admin controllers
7
- # Inherits from Core AdminController for authentication
8
- # Adds CMS-specific helpers and functionality
9
- class BaseController < ::Panda::Core::AdminController
7
+ # Inherits from Panda::Core::Admin::BaseController for authentication and base admin functionality
8
+ # Uses Core's admin layout with registered CMS navigation
9
+ class BaseController < ::Panda::Core::Admin::BaseController
10
+ # Override set_current_request_details to also set CMS-specific attributes
11
+ def set_current_request_details
12
+ super # Call Core's implementation first
13
+
14
+ # Set CMS current attributes (inherits from Core so has access to all Core attributes)
15
+ Panda::CMS::Current.request_id = request.uuid
16
+ Panda::CMS::Current.user_agent = request.user_agent
17
+ Panda::CMS::Current.ip_address = request.ip
18
+ Panda::CMS::Current.root = request.base_url
19
+ Panda::CMS::Current.user = Panda::Core::Current.user
20
+ Panda::CMS::Current.page = nil
21
+
22
+ Panda::CMS.config.url ||= Panda::Core::Current.root
23
+ end
24
+
10
25
  # Include CMS helpers so views have access to panda_cms_form_with, etc.
11
26
  helper Panda::CMS::ApplicationHelper
27
+ helper Panda::CMS::AssetHelper if defined?(Panda::CMS::AssetHelper)
12
28
 
13
29
  # Include the helper methods in the controller as well
14
30
  include Panda::CMS::ApplicationHelper
@@ -5,13 +5,13 @@ require "groupdate"
5
5
  module Panda
6
6
  module CMS
7
7
  module Admin
8
- class DashboardController < ::Panda::Core::Admin::DashboardController
8
+ class DashboardController < BaseController
9
9
  before_action :set_initial_breadcrumb, only: %i[show]
10
10
 
11
- # Override the panda-core dashboard with CMS-specific dashboard
11
+ # CMS-specific dashboard
12
12
  def show
13
13
  # Render the CMS dashboard view
14
- render "panda/cms/admin/dashboard/show"
14
+ render :show
15
15
  end
16
16
 
17
17
  private
@@ -4,6 +4,11 @@ module Panda
4
4
  module CMS
5
5
  module Admin
6
6
  class FilesController < ::Panda::CMS::Admin::BaseController
7
+ def index
8
+ @files = ActiveStorage::Blob.order(created_at: :desc)
9
+ @selected_file = @files.first if @files.any?
10
+ end
11
+
7
12
  def create
8
13
  file = params[:image]
9
14
  return render json: {success: 0} unless file
@@ -23,6 +28,8 @@ module Panda
23
28
  }
24
29
  }
25
30
  end
31
+
32
+ private
26
33
  end
27
34
  end
28
35
  end