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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 573c4b81cbf46f968761a96360d04aecfeb110eae011e5cd5a4f901d89bf380b
4
- data.tar.gz: cddd1b2705dfa48942f13605102cc8c80940ad959589c46c86f08f609398ca24
3
+ metadata.gz: 64a39a57fd00f6001e7992e634d5aef7b0536e7bfc4295399ea23737c10b0d83
4
+ data.tar.gz: 1b6d908a7f04e2f35ac7402b0c687160e71ac11e939a47f4193e1b4083947019
5
5
  SHA512:
6
- metadata.gz: 42ce696d0d74e33e93cd07fab7a0f697a4257da945613995f4d2aa01aa75f78d2c05ffd18667887705db5fd3aafcefbaec5a8741be6f18b16f757e8f0c4957e7
7
- data.tar.gz: 15ccdd29c804e25189aff2656bdb37c6e52ca6d642892eeed336b7685319465193436f340c61c19b14c1ba670bf51aa85c0198334d9b4231080136a212cf413c
6
+ metadata.gz: eb5a888a0e743db6524e89c35de782c4fa51ed3e0c903dfa0e80b989764bdc3e59e8f90a4cb4837cfe35b286e4edba56451442fb3338a49165c5bbe9bb81b75f
7
+ data.tar.gz: f987d4e8feaf6ae5fdf6c217c591c52382cd04f67e9e7d78f1d4f3e6834e1f978a549ade07b6fbee13e7d42b98f6f7e1245ce68b5502562f548c2461dd4ade1c
data/README.md CHANGED
@@ -30,7 +30,7 @@ Then run `bin/dev`. You'll see a basic website has automatically been created fo
30
30
 
31
31
  The easiest way for you to get started is to visit http://localhost:3000/admin and login with your GitHub credentials. As the first user, you'll automatically have an administrator account created.
32
32
 
33
- When you're ready to configure further, you can set your own configuration in `config/initializers/panda/cms.rb`. Make sure to turn off the default `github` account creation options!
33
+ When you're ready to configure further, you can set your own configuration in `config/initializers/panda.rb`. Make sure to configure your authentication providers and update the domain restriction!
34
34
 
35
35
  ### Existing applications
36
36
 
@@ -44,20 +44,90 @@ For initial setup, run:
44
44
 
45
45
  ```shell
46
46
  bundle install
47
- rails generate panda_cms:install
48
- rails panda_cms:install:migrations
47
+ rails generate panda:cms:install
48
+ rails panda:cms:install:migrations
49
+ rails db:migrate
49
50
  rails db:seed
50
51
  ```
51
52
 
52
53
  You may want to check this does not re-run any of your existing seeds!
53
54
 
54
- If you don't want to use GitHub to login (or are at a URL other than http://localhost:3000/), you'll need to configure a user provider (in `config/initializers/panda/cms.rb`), and then set your user's `admin` attribute to `true` once you've first tried to login.
55
+ If you don't want to use GitHub to login (or are at a URL other than http://localhost:3000/), you'll need to configure authentication providers (in `config/initializers/panda.rb`), and then set your user's `admin` attribute to `true` once you've first tried to login.
56
+
57
+ ## Configuration
58
+
59
+ All Panda configuration is managed in `config/initializers/panda.rb`. The generator creates this file with sensible defaults including Google OAuth authentication:
60
+
61
+ ```ruby
62
+ # config/initializers/panda.rb
63
+ Panda::Core.configure do |config|
64
+ config.admin_path = "/admin"
65
+
66
+ config.login_page_title = "Panda Admin"
67
+ config.admin_title = "Panda Admin"
68
+
69
+ config.authentication_providers = {
70
+ google_oauth2: {
71
+ enabled: true,
72
+ name: "Google",
73
+ client_id: Rails.application.credentials.dig(:google, :client_id),
74
+ client_secret: Rails.application.credentials.dig(:google, :client_secret),
75
+ options: {
76
+ scope: "email,profile",
77
+ prompt: "select_account",
78
+ hd: "yourdomain.com" # Restrict to specific domain
79
+ }
80
+ }
81
+ }
82
+
83
+ # Core settings
84
+ config.session_token_cookie = :panda_session
85
+ config.user_class = "Panda::Core::User"
86
+ config.user_identity_class = "Panda::Core::UserIdentity"
87
+ end
88
+ ```
89
+
90
+ **Important**: Update `hd: "yourdomain.com"` to your organization's domain to restrict admin access, or remove this line to allow any Google account.
91
+
92
+ See the [Configuration Documentation](docs/developers/configuration/) for detailed information on all available settings.
93
+
94
+ ### Engine Mounting
95
+
96
+ **The Panda CMS engine automatically mounts itself** via an `after_initialize` hook. You do **not** need to manually add `mount Panda::CMS::Engine => "/"` to your routes file. The engine will:
97
+
98
+ - Mount itself at the root path
99
+ - Add admin routes under the configured admin path (e.g., `/admin/cms` or `/manage/cms`)
100
+ - Set up a catch-all route for CMS pages (excluding admin paths)
101
+
102
+ The admin interface structure will be:
103
+ - `{admin_path}` - Panda Core admin dashboard (authentication, profile)
104
+ - `{admin_path}/cms` - Panda CMS admin (pages, posts, menus, files)
105
+
106
+ ## Styling
107
+
108
+ **Panda CMS does not compile or manage its own CSS.** All admin interface styling is provided by [Panda Core](https://github.com/tastybamboo/panda-core).
109
+
110
+ The CMS automatically loads Core's compiled stylesheet:
111
+
112
+ ```erb
113
+ <link rel="stylesheet" href="/panda-core-assets/panda-core.css">
114
+ ```
115
+
116
+ Core's Rack middleware serves this file from the gem, so:
117
+
118
+ - ✅ No CSS copying or compilation needed
119
+ - ✅ Styles update automatically when Core updates
120
+ - ✅ Consistent design across all Panda gems
121
+
122
+ For details on customizing styles, development workflows, and troubleshooting, see [docs/STYLING.md](docs/STYLING.md).
123
+
124
+ For CSS compilation (when contributing to styling), see [Panda Core Asset Compilation Guide](https://github.com/tastybamboo/panda-core/blob/main/docs/ASSET_COMPILATION.md).
55
125
 
56
126
  ## Gotchas
57
127
 
58
128
  This is a non-exhuastive list (there will be many more):
59
129
 
60
- * To date, this has only been tested with Rails 7.1, 7.2 and 8.0
130
+ * To date, this has only been tested with Rails 7.1, 7.2 and 8
61
131
  * There may be conflicts if you're not using Tailwind CSS on the frontend. Please report this.
62
132
 
63
133
  ## Contributing
@@ -2,68 +2,183 @@
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
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 CodeComponent < ViewComponent::Base
5
+ # Code component for editable HTML/code content
6
+ # @param key [Symbol] The key to use for the code component
7
+ # @param text [String] The default text to display
8
+ # @param editable [Boolean] If the code is editable or not (defaults to true)
9
+ class CodeComponent < Panda::Core::Base
11
10
  KIND = "code"
12
11
 
13
- def initialize(key: :text_component, text: "", editable: true, **options)
14
- @key = key
15
- @text = text
16
- @options = options || {}
17
- @options[:id] ||= "code-#{key.to_s.dasherize}"
18
- @editable = editable
12
+ prop :key, Symbol, default: :text_component
13
+ prop :text, String, default: ""
14
+ prop :editable, _Boolean, default: true
19
15
 
20
- raise BlockError, "Key 'code' is not allowed for CodeComponent" if key == :code
16
+ def view_template
17
+ # Russian doll caching: Cache component output at block_content level
18
+ # Only cache in non-editable mode (public-facing pages)
19
+ if should_cache?
20
+ raw cache_component_output
21
+ else
22
+ render_content
23
+ end
24
+ rescue => e
25
+ handle_error(e)
26
+ end
27
+
28
+ def before_template
29
+ raise BlockError, "Key 'code' is not allowed for CodeComponent" if @key == :code
30
+ prepare_content
21
31
  end
22
32
 
23
- def call
24
- # TODO: For the non-editable version, grab this from a cache or similar?
25
- block = Panda::CMS::Block.find_by(kind: KIND, key: @key,
26
- panda_cms_template_id: Current.page.panda_cms_template_id)
33
+ private
34
+
35
+ def prepare_content
36
+ @editable_state = component_is_editable?
37
+
38
+ block = find_block
39
+ return false if block.nil?
27
40
 
28
- if block.nil?
29
- unless Rails.env.production?
30
- raise Panda::CMS::MissingBlockError, "Block with key #{@key} not found for page #{Current.page.title}"
41
+ @block_content_obj = find_block_content(block)
42
+ @code_content = @block_content_obj&.content.to_s
43
+ @block_content_id = @block_content_obj&.id
44
+ end
45
+
46
+ def find_block
47
+ Panda::CMS::Block.find_by(
48
+ kind: KIND,
49
+ key: @key,
50
+ panda_cms_template_id: Current.page.panda_cms_template_id
51
+ )
52
+ end
53
+
54
+ def find_block_content(block)
55
+ block.block_contents.find_by(panda_cms_page_id: Current.page.id)
56
+ end
57
+
58
+ def render_editable_view
59
+ div(class: "code-component-wrapper mb-4", data: {controller: "inline-code-editor", inline_code_editor_page_id_value: Current.page.id, inline_code_editor_block_content_id_value: @block_content_id}) do
60
+ # Tab Navigation
61
+ div(class: "border-b border-gray-200 bg-white") do
62
+ nav(class: "-mb-px flex space-x-4 px-4", "aria-label": "Tabs") do
63
+ button(type: "button",
64
+ data: {inline_code_editor_target: "previewTab", action: "click->inline-code-editor#showPreview"},
65
+ class: "border-primary text-primary whitespace-nowrap border-b-2 py-2 px-1 text-sm font-medium") do
66
+ plain "Preview"
67
+ end
68
+ button(type: "button",
69
+ data: {inline_code_editor_target: "codeTab", action: "click->inline-code-editor#showCode"},
70
+ class: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap border-b-2 py-2 px-1 text-sm font-medium") do
71
+ plain "Code"
72
+ end
73
+ end
74
+ end
75
+
76
+ # Preview View
77
+ div(data: {inline_code_editor_target: "previewView"}, class: "mt-2") do
78
+ div(**preview_attrs) { raw(@code_content.to_s.html_safe) }
31
79
  end
32
80
 
33
- return false
81
+ # Code Editor View
82
+ div(data: {inline_code_editor_target: "codeView"}, class: "mt-2 hidden bg-white p-4 border border-gray-200") do
83
+ textarea(
84
+ data: {inline_code_editor_target: "codeInput"},
85
+ class: "w-full h-64 p-3 font-mono text-sm border border-gray-300 rounded focus:ring-primary focus:border-primary",
86
+ placeholder: "Enter your HTML/embed code here..."
87
+ ) { raw(@code_content.to_s) }
88
+
89
+ div(class: "mt-3 flex justify-end space-x-2") do
90
+ button(type: "button",
91
+ data: {action: "click->inline-code-editor#saveCode"},
92
+ class: "inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") do
93
+ plain "💾 Save Code"
94
+ end
95
+ end
96
+
97
+ div(data: {inline_code_editor_target: "saveMessage"}, class: "hidden mt-2")
98
+ end
34
99
  end
100
+ end
35
101
 
36
- block_content = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
37
- code_content = block_content&.content.to_s
102
+ def preview_attrs
103
+ {
104
+ class: "p-4 border border-dashed border-gray-300 bg-gray-50 min-h-32"
105
+ }
106
+ end
38
107
 
39
- if component_is_editable?
40
- @options[:contenteditable] = "plaintext-only"
41
- @options[:data] = {
108
+ def element_attrs
109
+ {
110
+ id: "editor-#{@block_content_id}",
111
+ contenteditable: "plaintext-only",
112
+ class: "block bg-yellow-50 font-mono text-xs p-2 border-2 border-yellow-700",
113
+ style: "white-space: pre-wrap;",
114
+ data: {
42
115
  "editable-kind": "html",
43
116
  "editable-page-id": Current.page.id,
44
- "editable-block-content-id": block_content&.id
117
+ "editable-block-content-id": @block_content_id
45
118
  }
46
- @options[:class] = "block bg-yellow-50 font-mono text-xs p-2 border-2 border-yellow-700"
47
- @options[:style] = "white-space: pre-wrap;"
119
+ }.merge(@attrs)
120
+ end
48
121
 
49
- @options[:id] = "editor-#{block_content&.id}"
122
+ def component_is_editable?
123
+ # TODO: Permissions
124
+ @editable && is_embedded? && Current.user&.admin?
125
+ end
126
+
127
+ def is_embedded?
128
+ # Security: Verify embed_id matches the current page being edited
129
+ # This prevents unauthorized editing by ensuring the embed_id in the URL
130
+ # matches the actual page ID from Current.page
131
+ view_context.params[:embed_id].present? &&
132
+ Current.page&.id.to_s == view_context.params[:embed_id].to_s
133
+ end
50
134
 
51
- # TODO: Switch between the HTML and the preview?
52
- content_tag(:div, code_content, @options, true)
135
+ def handle_error(error)
136
+ Rails.logger.error "CodeComponent error: #{error.message}"
137
+ Rails.logger.error error.backtrace.join("\n")
138
+
139
+ if Rails.env.production?
140
+ false
53
141
  else
54
- code_content.html_safe
142
+ div(class: "p-4 bg-red-50 border border-red-200 rounded") do
143
+ p(class: "text-red-800 font-semibold") { "CodeComponent Error" }
144
+ p(class: "text-red-600 text-sm") { error.message }
145
+ end
55
146
  end
56
147
  end
57
148
 
58
- def component_is_editable?
59
- # TODO: Permissions
60
- @editable && is_embedded? && Current.user&.admin
149
+ def render_content
150
+ if @editable_state
151
+ render_editable_view
152
+ else
153
+ raw(@code_content.to_s.html_safe)
154
+ end
61
155
  end
62
156
 
63
- def is_embedded?
64
- # TODO: Check security on this - embed_id should match something?
65
- request.params[:embed_id].present?
157
+ def should_cache?
158
+ !@editable_state &&
159
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
160
+ @block_content_obj.present?
161
+ end
162
+
163
+ def cache_component_output
164
+ cache_key = cache_key_for_component
165
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
166
+
167
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
168
+ render_content_to_string
169
+ end.html_safe
66
170
  end
171
+
172
+ def cache_key_for_component
173
+ "panda_cms/code_component/#{@block_content_obj.cache_key_with_version}/#{@key}"
174
+ end
175
+
176
+ def render_content_to_string
177
+ # For code component, we just return the raw HTML content
178
+ @code_content.to_s
179
+ end
180
+
181
+ class BlockError < StandardError; end
67
182
  end
68
183
  end
69
184
  end
@@ -2,14 +2,34 @@
2
2
 
3
3
  module Panda
4
4
  module CMS
5
- class GridComponent < ViewComponent::Base
6
- def initialize(columns: 1, spans: [1])
7
- @columns = "grid-cols-#{columns}"
8
- @colspans = []
9
- spans.each do |span|
10
- @colspans << "col-span-#{span}"
5
+ # Grid layout component for creating column-based layouts
6
+ # @param columns [Integer] Number of grid columns
7
+ # @param spans [Array<Integer>] Array of column span values for each grid cell
8
+ class GridComponent < Panda::Core::Base
9
+ prop :columns, Integer, default: 1
10
+ prop :spans, Array, default: -> { [1].freeze }
11
+
12
+ def view_template
13
+ div(class: "w-full grid #{grid_columns_class} min-h-20") do
14
+ column_span_classes.each do |colspan|
15
+ div(
16
+ class: "border border-red-500 bg-red-50 #{colspan}",
17
+ onDragOver: "parent.onDragOver(event);",
18
+ onDrop: "parent.onDrop(event);"
19
+ )
20
+ end
11
21
  end
12
22
  end
23
+
24
+ private
25
+
26
+ def grid_columns_class
27
+ "grid-cols-#{columns}"
28
+ end
29
+
30
+ def column_span_classes
31
+ spans.map { |span| "col-span-#{span}" }
32
+ end
13
33
  end
14
34
  end
15
35
  end
@@ -2,40 +2,77 @@
2
2
 
3
3
  module Panda
4
4
  module CMS
5
- class MenuComponent < ViewComponent::Base
6
- #
7
- # Renders the menu item and its children
8
- #
9
- # @param [String] name The name of the menu
10
- # @param [String] current_path The current path of the request (request.path)
11
- # @param [Hash] styles
12
- # The CSS classes to apply to the menu items, containing "default", "inactive" and "active" keys.
13
- # The "default" key is applied to all menu items. "inactive" and "active" are set based on the
14
- # current path.
15
- # @return [void]
16
- def initialize(name:, current_path: "", styles: {}, overrides: {}, render_page_menu: false, page_menu_styles: {})
17
- @menu = Panda::CMS::Menu.find_by(name: name)
18
- @menu_items = @menu.menu_items
19
- @menu_items = @menu_items.where("depth <= ?", @menu.depth) if @menu.depth
20
- @menu_items = @menu_items.order(:lft)
21
- @current_path = current_path.to_s
22
- @render_page_menu = render_page_menu
23
-
24
- @menu_items = @menu_items.order(:lft).map do |menu_item|
25
- if is_active?(menu_item)
26
- menu_item.define_singleton_method(:css_classes) { "#{styles[:default]} #{styles[:active]}" }
27
- else
28
- menu_item.define_singleton_method(:css_classes) { "#{styles[:default]} #{styles[:inactive]}" }
5
+ # Menu component for rendering navigational menus
6
+ # @param name [String] The name of the menu to render
7
+ # @param current_path [String] The current request path for highlighting active items
8
+ # @param styles [Hash] CSS classes for menu items (default, active, inactive)
9
+ # @param overrides [Hash] Menu item overrides (currently unused)
10
+ # @param render_page_menu [Boolean] Whether to render sub-page menus
11
+ # @param page_menu_styles [Hash] Styles for the page menu component
12
+ class MenuComponent < Panda::Core::Base
13
+ prop :name, String
14
+ prop :current_path, String, default: ""
15
+ prop :styles, Hash, default: -> { {}.freeze }
16
+ prop :overrides, Hash, default: -> { {}.freeze }
17
+ prop :render_page_menu, _Boolean, default: false
18
+ prop :page_menu_styles, Hash, default: -> { {}.freeze }
19
+
20
+ def view_template
21
+ return unless @menu
22
+
23
+ @processed_menu_items.each do |menu_item|
24
+ a(href: menu_item.resolved_link, class: menu_item.css_classes) { menu_item.text }
25
+
26
+ if @render_page_menu && menu_item.page
27
+ render Panda::CMS::PageMenuComponent.new(
28
+ page: menu_item.page,
29
+ start_depth: 1,
30
+ styles: @page_menu_styles,
31
+ show_heading: false
32
+ )
29
33
  end
34
+ end
35
+ end
36
+
37
+ def before_template
38
+ load_menu_items
39
+ end
40
+
41
+ private
42
+
43
+ def load_menu_items
44
+ @menu = Panda::CMS::Menu.find_by(name: @name)
45
+ return unless @menu
30
46
 
47
+ # Fragment caching: Cache menu_items query results
48
+ # Cache key includes menu's updated_at to auto-invalidate on changes
49
+ cache_key = "panda_cms_menu/#{@menu.name}/#{@menu.id}/#{@menu.updated_at.to_i}/items"
50
+
51
+ menu_items = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
52
+ items = @menu.menu_items
53
+ items = items.where("depth <= ?", @menu.depth) if @menu.depth
54
+ items.order(:lft).to_a # Convert to array for caching
55
+ end
56
+
57
+ @processed_menu_items = menu_items.map do |menu_item|
58
+ add_css_classes_to_item(menu_item)
31
59
  menu_item
32
60
  end
33
61
 
34
- # TODO: Surely don't need this but Current.page isn't working in the component
35
- return unless @render_page_menu
62
+ # Load current page for page menu rendering
63
+ if @render_page_menu
64
+ @current_page = Panda::CMS::Page.find_by(path: @current_path)
65
+ end
66
+ end
36
67
 
37
- @current_page = Panda::CMS::Page.find_by(path: @current_path)
38
- @page_menu_styles = page_menu_styles
68
+ def add_css_classes_to_item(menu_item)
69
+ css_class = if is_active?(menu_item)
70
+ "#{@styles[:default]} #{@styles[:active]}"
71
+ else
72
+ "#{@styles[:default]} #{@styles[:inactive]}"
73
+ end
74
+
75
+ menu_item.define_singleton_method(:css_classes) { css_class }
39
76
  end
40
77
 
41
78
  def is_active?(menu_item)
@@ -46,13 +83,14 @@ module Panda
46
83
  end
47
84
 
48
85
  def active_link?(path, match: :starts_with)
49
- if match == :starts_with
50
- return @current_path.starts_with?(path)
51
- elsif match == :exact
52
- return (@current_path == path)
86
+ case match
87
+ when :starts_with
88
+ @current_path.starts_with?(path)
89
+ when :exact
90
+ @current_path == path
91
+ else
92
+ false
53
93
  end
54
-
55
- false
56
94
  end
57
95
  end
58
96
  end
@@ -2,35 +2,124 @@
2
2
 
3
3
  module Panda
4
4
  module CMS
5
- class PageMenuComponent < ViewComponent::Base
6
- attr_accessor :page, :menu_item, :styles
5
+ # Page menu component for rendering hierarchical page navigation
6
+ # @param page [Panda::CMS::Page] The current page
7
+ # @param start_depth [Integer] The depth level to start the menu from
8
+ # @param styles [Hash] CSS classes for styling menu elements
9
+ # @param show_heading [Boolean] Whether to show the top-level heading
10
+ class PageMenuComponent < Panda::Core::Base
11
+ prop :page, Object
12
+ prop :start_depth, Integer
13
+ prop :styles, Hash, default: -> { {}.freeze }
14
+ prop :show_heading, _Boolean, default: true
7
15
 
8
- def initialize(page:, start_depth:, styles: {}, show_heading: true)
9
- @page = page
16
+ def view_template
17
+ return unless should_render?
10
18
 
19
+ nav(class: @styles[:container]) do
20
+ ul(role: "list", class: "p-0 m-0") do
21
+ render_heading if @show_heading
22
+ render_menu_items
23
+ end
24
+ end
25
+ end
26
+
27
+ def before_template
11
28
  return if @page.nil?
12
29
 
13
- start_page = if @page.depth == start_depth
30
+ @start_page = if @page.depth == @start_depth
14
31
  @page
15
32
  else
16
- @page.ancestors.find { |anc| anc.depth == start_depth }
33
+ @page.ancestors.find { |anc| anc.depth == @start_depth }
17
34
  end
18
35
 
19
- menu = start_page&.page_menu
36
+ menu = @start_page&.page_menu
20
37
  return if menu.nil?
21
38
 
22
- @menu_item = menu.menu_items.order(:lft)&.first
39
+ # Fragment caching: Cache menu items for this page menu
40
+ # Cache key includes menu's updated_at to auto-invalidate on changes
41
+ cache_key = "panda_cms_page_menu/#{menu.id}/#{menu.updated_at.to_i}/items"
42
+
43
+ cached_items = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
44
+ menu.menu_items.order(:lft).to_a
45
+ end
23
46
 
24
- @show_heading = show_heading
47
+ @menu_item = cached_items.first
25
48
 
26
- # Set some default styles for sanity
27
- @styles = styles
28
- @styles[:indent_with] ||= "pl-2"
49
+ # Set default styles if not already set
50
+ @styles[:indent_with] ||= "pl-2" if @styles
29
51
  end
30
52
 
31
- def render?
53
+ private
54
+
55
+ def should_render?
32
56
  @page&.path != "/" && @menu_item.present?
33
57
  end
58
+
59
+ def render_heading
60
+ li do
61
+ a(
62
+ href: @menu_item.page.path,
63
+ class: heading_class
64
+ ) { @menu_item.text }
65
+ end
66
+ end
67
+
68
+ def heading_class
69
+ if @menu_item.page == Panda::CMS::Current.page
70
+ @styles[:current_page_active]
71
+ else
72
+ @styles[:current_page_inactive]
73
+ end
74
+ end
75
+
76
+ def render_menu_items
77
+ ul do
78
+ Panda::CMS::MenuItem.includes(:page).each_with_level(@menu_item.descendants) do |submenu_item, level|
79
+ next if should_skip_item?(submenu_item, level)
80
+
81
+ render_menu_item(submenu_item, level)
82
+ end
83
+ end
84
+ end
85
+
86
+ def should_skip_item?(submenu_item, level)
87
+ # Skip if we're on the top menu item and level > 1
88
+ return true if Panda::CMS::Current.page == @menu_item.page && level > 1
89
+
90
+ # Skip if path contains parameter placeholder
91
+ return true if submenu_item.page&.path&.include?(":")
92
+
93
+ # Skip if page is nil or Current.page is nil
94
+ return true if submenu_item&.page.nil? || Panda::CMS::Current.page.nil?
95
+
96
+ # Skip if submenu page is deeper than current page and not an ancestor
97
+ (submenu_item.page&.depth&.to_i&.> Panda::CMS::Current.page&.depth&.to_i) &&
98
+ !Panda::CMS::Current.page&.in?(submenu_item.page.ancestors)
99
+ end
100
+
101
+ def render_menu_item(submenu_item, level)
102
+ li(
103
+ data: {
104
+ level: level,
105
+ page_id: submenu_item.page.id
106
+ },
107
+ class: menu_item_class(submenu_item)
108
+ ) do
109
+ a(
110
+ href: submenu_item.page&.path,
111
+ class: view_context.menu_indent(submenu_item, indent_with: @styles[:indent_with])
112
+ ) { submenu_item.page&.title }
113
+ end
114
+ end
115
+
116
+ def menu_item_class(submenu_item)
117
+ if submenu_item.page == Panda::CMS::Current.page
118
+ @styles[:active]
119
+ else
120
+ @styles[:inactive]
121
+ end
122
+ end
34
123
  end
35
124
  end
36
125
  end