panda-cms 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +83 -4
  3. data/app/components/panda/cms/code_component.rb +117 -39
  4. data/app/components/panda/cms/grid_component.rb +26 -6
  5. data/app/components/panda/cms/menu_component.rb +66 -34
  6. data/app/components/panda/cms/page_menu_component.rb +94 -13
  7. data/app/components/panda/cms/rich_text_component.rb +198 -140
  8. data/app/components/panda/cms/text_component.rb +77 -44
  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 +6 -1
  14. data/app/controllers/panda/cms/pages_controller.rb +2 -2
  15. data/app/helpers/panda/cms/application_helper.rb +15 -1
  16. data/app/helpers/panda/cms/asset_helper.rb +14 -3
  17. data/app/javascript/panda/cms/application_panda_cms.js +1 -1
  18. data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
  19. data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
  20. data/app/javascript/panda/cms/controllers/index.js +48 -13
  21. data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
  22. data/app/javascript/panda/cms/controllers/menu_form_controller.js +40 -0
  23. data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
  24. data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
  25. data/app/javascript/panda/cms/stimulus-loading.js +5 -7
  26. data/app/models/panda/cms/block_content.rb +9 -0
  27. data/app/models/panda/cms/page.rb +41 -0
  28. data/app/models/panda/cms/post.rb +1 -0
  29. data/app/views/panda/cms/admin/dashboard/show.html.erb +5 -5
  30. data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
  31. data/app/views/panda/cms/admin/files/index.html.erb +11 -118
  32. data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
  33. data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
  34. data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
  35. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
  36. data/app/views/panda/cms/admin/menus/edit.html.erb +64 -0
  37. data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
  38. data/app/views/panda/cms/admin/menus/new.html.erb +40 -0
  39. data/app/views/panda/cms/admin/pages/edit.html.erb +15 -9
  40. data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
  41. data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
  42. data/app/views/panda/cms/admin/posts/_form.html.erb +4 -14
  43. data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
  44. data/app/views/panda/cms/admin/posts/index.html.erb +3 -3
  45. data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
  46. data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
  47. data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
  48. data/config/importmap.rb +4 -6
  49. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
  50. data/config/initializers/panda/cms.rb +52 -10
  51. data/config/routes.rb +4 -2
  52. data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
  53. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
  54. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
  55. data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
  56. data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
  57. data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
  58. data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
  59. data/lib/generators/panda/cms/install_generator.rb +2 -5
  60. data/lib/panda/cms/asset_loader.rb +36 -16
  61. data/lib/panda/cms/debug.rb +29 -0
  62. data/lib/panda/cms/engine.rb +107 -48
  63. data/lib/panda/cms/features.rb +52 -0
  64. data/lib/panda-cms/version.rb +1 -1
  65. data/lib/panda-cms.rb +5 -6
  66. data/lib/tasks/assets.rake +5 -52
  67. data/lib/tasks/panda_cms_tasks.rake +16 -0
  68. metadata +22 -29
  69. data/app/components/panda/cms/admin/container_component.html.erb +0 -13
  70. data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
  71. data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
  72. data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
  73. data/app/components/panda/cms/admin/slideover_component.rb +0 -15
  74. data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
  75. data/app/components/panda/cms/admin/statistics_component.rb +0 -16
  76. data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
  77. data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
  78. data/app/components/panda/cms/admin/table_component.html.erb +0 -29
  79. data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
  80. data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
  81. data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
  82. data/app/components/panda/cms/admin/user_display_component.rb +0 -21
  83. data/app/components/panda/cms/grid_component.html.erb +0 -6
  84. data/app/components/panda/cms/menu_component.html.erb +0 -6
  85. data/app/components/panda/cms/page_menu_component.html.erb +0 -21
  86. data/app/components/panda/cms/rich_text_component.html.erb +0 -90
  87. data/app/views/layouts/panda/cms/application.html.erb +0 -42
  88. data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
  89. data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
  90. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
  91. data/app/views/panda/cms/shared/_footer.html.erb +0 -2
  92. data/app/views/panda/cms/shared/_header.html.erb +0 -25
  93. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 573c4b81cbf46f968761a96360d04aecfeb110eae011e5cd5a4f901d89bf380b
4
- data.tar.gz: cddd1b2705dfa48942f13605102cc8c80940ad959589c46c86f08f609398ca24
3
+ metadata.gz: a5d7ab51a41583d6af2b9c03249894b81eb3301e8a88068bf512fea5ba10891a
4
+ data.tar.gz: 9cbbd69b5b6e6ce229fef244135e7abb1e12580be401524b054e94ff227280cc
5
5
  SHA512:
6
- metadata.gz: 42ce696d0d74e33e93cd07fab7a0f697a4257da945613995f4d2aa01aa75f78d2c05ffd18667887705db5fd3aafcefbaec5a8741be6f18b16f757e8f0c4957e7
7
- data.tar.gz: 15ccdd29c804e25189aff2656bdb37c6e52ca6d642892eeed336b7685319465193436f340c61c19b14c1ba670bf51aa85c0198334d9b4231080136a212cf413c
6
+ metadata.gz: 84f46d5e374a05e046047e7e7751371f4b90d2e86a137df179c02e6e89c08169048e64c18ef38b0b78ab5137c22f55d0777451ab49bb32ab3aa3c67745b6aaed
7
+ data.tar.gz: 0b6ad4481db935ed61ca57d087582e8b370fe2ecb6b0e15dadeaa53042a834dbecaf9cd7d0122830788e10b071cce66290d0abd6a0b4649adf23465140854069
data/README.md CHANGED
@@ -30,7 +30,17 @@ 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
+
35
+ ## Panda CMS Pro
36
+
37
+ Commercial features such as structured **Collections** live in the `panda-cms-pro` gem. Once the pro gem is installed you can:
38
+
39
+ - Model repeatable content with collections and items.
40
+ - Loop over entries inside layouts using helpers like `panda_cms_collection_items("trustees")`.
41
+ - Keep the feature hidden in open-source installs thanks to `Panda::CMS::Features`.
42
+
43
+ See `docs/collections.md` for the editor workflow and template examples, and `docs/private-gem-server.md` for hosting the private gem server.
34
44
 
35
45
  ### Existing applications
36
46
 
@@ -44,14 +54,83 @@ For initial setup, run:
44
54
 
45
55
  ```shell
46
56
  bundle install
47
- rails generate panda_cms:install
48
- rails panda_cms:install:migrations
57
+ rails generate panda:cms:install
58
+ rails panda:cms:install:migrations
59
+ rails db:migrate
49
60
  rails db:seed
50
61
  ```
51
62
 
52
63
  You may want to check this does not re-run any of your existing seeds!
53
64
 
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.
65
+ 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.
66
+
67
+ ## Configuration
68
+
69
+ All Panda configuration is managed in `config/initializers/panda.rb`. The generator creates this file with sensible defaults including Google OAuth authentication:
70
+
71
+ ```ruby
72
+ # config/initializers/panda.rb
73
+ Panda::Core.configure do |config|
74
+ config.admin_path = "/admin"
75
+
76
+ config.login_page_title = "Panda Admin"
77
+ config.admin_title = "Panda Admin"
78
+
79
+ config.authentication_providers = {
80
+ google_oauth2: {
81
+ enabled: true,
82
+ name: "Google",
83
+ client_id: Rails.application.credentials.dig(:google, :client_id),
84
+ client_secret: Rails.application.credentials.dig(:google, :client_secret),
85
+ options: {
86
+ scope: "email,profile",
87
+ prompt: "select_account",
88
+ hd: "yourdomain.com" # Restrict to specific domain
89
+ }
90
+ }
91
+ }
92
+
93
+ # Core settings
94
+ config.session_token_cookie = :panda_session
95
+ config.user_class = "Panda::Core::User"
96
+ config.user_identity_class = "Panda::Core::UserIdentity"
97
+ end
98
+ ```
99
+
100
+ **Important**: Update `hd: "yourdomain.com"` to your organization's domain to restrict admin access, or remove this line to allow any Google account.
101
+
102
+ See the [Configuration Documentation](docs/developers/configuration/) for detailed information on all available settings.
103
+
104
+ ### Engine Mounting
105
+
106
+ **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:
107
+
108
+ - Mount itself at the root path
109
+ - Add admin routes under the configured admin path (e.g., `/admin/cms` or `/manage/cms`)
110
+ - Set up a catch-all route for CMS pages (excluding admin paths)
111
+
112
+ The admin interface structure will be:
113
+ - `{admin_path}` - Panda Core admin dashboard (authentication, profile)
114
+ - `{admin_path}/cms` - Panda CMS admin (pages, posts, menus, files)
115
+
116
+ ## Styling
117
+
118
+ **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).
119
+
120
+ The CMS automatically loads Core's compiled stylesheet:
121
+
122
+ ```erb
123
+ <link rel="stylesheet" href="/panda-core-assets/panda-core.css">
124
+ ```
125
+
126
+ Core's Rack middleware serves this file from the gem, so:
127
+ - ✅ No CSS copying or compilation needed
128
+ - ✅ Styles update automatically when Core updates
129
+ - ✅ Consistent design across all Panda gems
130
+
131
+ For details on customizing styles, development workflows, and troubleshooting, see [docs/STYLING.md](docs/STYLING.md).
132
+
133
+ 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
134
 
56
135
  ## Gotchas
57
136
 
@@ -2,68 +2,146 @@
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
+ if @editable_state
18
+ render_editable_view
19
+ else
20
+ raw(@code_content.to_s.html_safe)
21
+ end
22
+ rescue => e
23
+ handle_error(e)
24
+ end
25
+
26
+ def before_template
27
+ raise BlockError, "Key 'code' is not allowed for CodeComponent" if @key == :code
28
+ prepare_content
21
29
  end
22
30
 
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)
31
+ private
32
+
33
+ def prepare_content
34
+ @editable_state = component_is_editable?
35
+
36
+ block = find_block
37
+ return false if block.nil?
38
+
39
+ block_content = find_block_content(block)
40
+ @code_content = block_content&.content.to_s
41
+ @block_content_id = block_content&.id
42
+ end
27
43
 
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}"
44
+ def find_block
45
+ Panda::CMS::Block.find_by(
46
+ kind: KIND,
47
+ key: @key,
48
+ panda_cms_template_id: Current.page.panda_cms_template_id
49
+ )
50
+ end
51
+
52
+ def find_block_content(block)
53
+ block.block_contents.find_by(panda_cms_page_id: Current.page.id)
54
+ end
55
+
56
+ def render_editable_view
57
+ 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
58
+ # Tab Navigation
59
+ div(class: "border-b border-gray-200 bg-white") do
60
+ nav(class: "-mb-px flex space-x-4 px-4", "aria-label": "Tabs") do
61
+ button(type: "button",
62
+ data: {inline_code_editor_target: "previewTab", action: "click->inline-code-editor#showPreview"},
63
+ class: "border-primary text-primary whitespace-nowrap border-b-2 py-2 px-1 text-sm font-medium") do
64
+ plain "Preview"
65
+ end
66
+ button(type: "button",
67
+ data: {inline_code_editor_target: "codeTab", action: "click->inline-code-editor#showCode"},
68
+ 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
69
+ plain "Code"
70
+ end
71
+ end
31
72
  end
32
73
 
33
- return false
74
+ # Preview View
75
+ div(data: {inline_code_editor_target: "previewView"}, class: "mt-2") do
76
+ div(**preview_attrs) { raw(@code_content.to_s.html_safe) }
77
+ end
78
+
79
+ # Code Editor View
80
+ div(data: {inline_code_editor_target: "codeView"}, class: "mt-2 hidden bg-white p-4 border border-gray-200") do
81
+ textarea(
82
+ data: {inline_code_editor_target: "codeInput"},
83
+ class: "w-full h-64 p-3 font-mono text-sm border border-gray-300 rounded focus:ring-primary focus:border-primary",
84
+ placeholder: "Enter your HTML/embed code here..."
85
+ ) { raw(@code_content.to_s) }
86
+
87
+ div(class: "mt-3 flex justify-end space-x-2") do
88
+ button(type: "button",
89
+ data: {action: "click->inline-code-editor#saveCode"},
90
+ 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
91
+ plain "💾 Save Code"
92
+ end
93
+ end
94
+
95
+ div(data: {inline_code_editor_target: "saveMessage"}, class: "hidden mt-2")
96
+ end
34
97
  end
98
+ end
35
99
 
36
- block_content = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
37
- code_content = block_content&.content.to_s
100
+ def preview_attrs
101
+ {
102
+ class: "p-4 border border-dashed border-gray-300 bg-gray-50 min-h-32"
103
+ }
104
+ end
38
105
 
39
- if component_is_editable?
40
- @options[:contenteditable] = "plaintext-only"
41
- @options[:data] = {
106
+ def element_attrs
107
+ {
108
+ id: "editor-#{@block_content_id}",
109
+ contenteditable: "plaintext-only",
110
+ class: "block bg-yellow-50 font-mono text-xs p-2 border-2 border-yellow-700",
111
+ style: "white-space: pre-wrap;",
112
+ data: {
42
113
  "editable-kind": "html",
43
114
  "editable-page-id": Current.page.id,
44
- "editable-block-content-id": block_content&.id
115
+ "editable-block-content-id": @block_content_id
45
116
  }
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;"
48
-
49
- @options[:id] = "editor-#{block_content&.id}"
50
-
51
- # TODO: Switch between the HTML and the preview?
52
- content_tag(:div, code_content, @options, true)
53
- else
54
- code_content.html_safe
55
- end
117
+ }.merge(@attrs)
56
118
  end
57
119
 
58
120
  def component_is_editable?
59
121
  # TODO: Permissions
60
- @editable && is_embedded? && Current.user&.admin
122
+ @editable && is_embedded? && Current.user&.admin?
61
123
  end
62
124
 
63
125
  def is_embedded?
64
126
  # TODO: Check security on this - embed_id should match something?
65
- request.params[:embed_id].present?
127
+ view_context.request.params[:embed_id].present?
66
128
  end
129
+
130
+ def handle_error(error)
131
+ Rails.logger.error "CodeComponent error: #{error.message}"
132
+ Rails.logger.error error.backtrace.join("\n")
133
+
134
+ if Rails.env.production?
135
+ false
136
+ else
137
+ div(class: "p-4 bg-red-50 border border-red-200 rounded") do
138
+ p(class: "text-red-800 font-semibold") { "CodeComponent Error" }
139
+ p(class: "text-red-600 text-sm") { error.message }
140
+ end
141
+ end
142
+ end
143
+
144
+ class BlockError < StandardError; end
67
145
  end
68
146
  end
69
147
  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,71 @@
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
30
42
 
43
+ def load_menu_items
44
+ @menu = Panda::CMS::Menu.find_by(name: @name)
45
+ return unless @menu
46
+
47
+ menu_items = @menu.menu_items
48
+ menu_items = menu_items.where("depth <= ?", @menu.depth) if @menu.depth
49
+ menu_items = menu_items.order(:lft)
50
+
51
+ @processed_menu_items = menu_items.map do |menu_item|
52
+ add_css_classes_to_item(menu_item)
31
53
  menu_item
32
54
  end
33
55
 
34
- # TODO: Surely don't need this but Current.page isn't working in the component
35
- return unless @render_page_menu
56
+ # Load current page for page menu rendering
57
+ if @render_page_menu
58
+ @current_page = Panda::CMS::Page.find_by(path: @current_path)
59
+ end
60
+ end
61
+
62
+ def add_css_classes_to_item(menu_item)
63
+ css_class = if is_active?(menu_item)
64
+ "#{@styles[:default]} #{@styles[:active]}"
65
+ else
66
+ "#{@styles[:default]} #{@styles[:inactive]}"
67
+ end
36
68
 
37
- @current_page = Panda::CMS::Page.find_by(path: @current_path)
38
- @page_menu_styles = page_menu_styles
69
+ menu_item.define_singleton_method(:css_classes) { css_class }
39
70
  end
40
71
 
41
72
  def is_active?(menu_item)
@@ -46,13 +77,14 @@ module Panda
46
77
  end
47
78
 
48
79
  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)
80
+ case match
81
+ when :starts_with
82
+ @current_path.starts_with?(path)
83
+ when :exact
84
+ @current_path == path
85
+ else
86
+ false
53
87
  end
54
-
55
- false
56
88
  end
57
89
  end
58
90
  end
@@ -2,35 +2,116 @@
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
39
  @menu_item = menu.menu_items.order(:lft)&.first
23
40
 
24
- @show_heading = show_heading
25
-
26
- # Set some default styles for sanity
27
- @styles = styles
28
- @styles[:indent_with] ||= "pl-2"
41
+ # Set default styles if not already set
42
+ @styles[:indent_with] ||= "pl-2" if @styles
29
43
  end
30
44
 
31
- def render?
45
+ private
46
+
47
+ def should_render?
32
48
  @page&.path != "/" && @menu_item.present?
33
49
  end
50
+
51
+ def render_heading
52
+ li do
53
+ a(
54
+ href: @menu_item.page.path,
55
+ class: heading_class
56
+ ) { @menu_item.text }
57
+ end
58
+ end
59
+
60
+ def heading_class
61
+ if @menu_item.page == Panda::CMS::Current.page
62
+ @styles[:current_page_active]
63
+ else
64
+ @styles[:current_page_inactive]
65
+ end
66
+ end
67
+
68
+ def render_menu_items
69
+ ul do
70
+ Panda::CMS::MenuItem.includes(:page).each_with_level(@menu_item.descendants) do |submenu_item, level|
71
+ next if should_skip_item?(submenu_item, level)
72
+
73
+ render_menu_item(submenu_item, level)
74
+ end
75
+ end
76
+ end
77
+
78
+ def should_skip_item?(submenu_item, level)
79
+ # Skip if we're on the top menu item and level > 1
80
+ return true if Panda::CMS::Current.page == @menu_item.page && level > 1
81
+
82
+ # Skip if path contains parameter placeholder
83
+ return true if submenu_item.page&.path&.include?(":")
84
+
85
+ # Skip if page is nil or Current.page is nil
86
+ return true if submenu_item&.page.nil? || Panda::CMS::Current.page.nil?
87
+
88
+ # Skip if submenu page is deeper than current page and not an ancestor
89
+ (submenu_item.page&.depth&.to_i&.> Panda::CMS::Current.page&.depth&.to_i) &&
90
+ !Panda::CMS::Current.page&.in?(submenu_item.page.ancestors)
91
+ end
92
+
93
+ def render_menu_item(submenu_item, level)
94
+ li(
95
+ data: {
96
+ level: level,
97
+ page_id: submenu_item.page.id
98
+ },
99
+ class: menu_item_class(submenu_item)
100
+ ) do
101
+ a(
102
+ href: submenu_item.page&.path,
103
+ class: view_context.menu_indent(submenu_item, indent_with: @styles[:indent_with])
104
+ ) { submenu_item.page&.title }
105
+ end
106
+ end
107
+
108
+ def menu_item_class(submenu_item)
109
+ if submenu_item.page == Panda::CMS::Current.page
110
+ @styles[:active]
111
+ else
112
+ @styles[:inactive]
113
+ end
114
+ end
34
115
  end
35
116
  end
36
117
  end