panda-cms 0.10.0 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -11
  3. data/app/components/panda/cms/code_component.rb +45 -8
  4. data/app/components/panda/cms/menu_component.rb +9 -3
  5. data/app/components/panda/cms/page_menu_component.rb +9 -1
  6. data/app/components/panda/cms/rich_text_component.rb +49 -17
  7. data/app/components/panda/cms/text_component.rb +46 -14
  8. data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
  9. data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
  10. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  11. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  12. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  13. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  14. data/app/helpers/panda/cms/application_helper.rb +2 -3
  15. data/app/helpers/panda/cms/asset_helper.rb +14 -72
  16. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  17. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  18. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
  19. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  20. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  21. data/app/javascript/panda/cms/controllers/index.js +6 -0
  22. data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
  23. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  24. data/app/javascript/panda/cms/stimulus-loading.js +2 -1
  25. data/app/models/panda/cms/menu.rb +12 -0
  26. data/app/models/panda/cms/page.rb +106 -0
  27. data/app/models/panda/cms/post.rb +97 -0
  28. data/app/views/layouts/homepage.html.erb +1 -4
  29. data/app/views/layouts/page.html.erb +1 -4
  30. data/app/views/panda/cms/admin/dashboard/show.html.erb +1 -1
  31. data/app/views/panda/cms/admin/files/index.html.erb +1 -1
  32. data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
  33. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
  34. data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
  35. data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
  36. data/app/views/panda/cms/admin/menus/new.html.erb +5 -7
  37. data/app/views/panda/cms/admin/pages/edit.html.erb +139 -20
  38. data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
  39. data/app/views/panda/cms/admin/posts/_form.html.erb +41 -2
  40. data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
  41. data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
  42. data/app/views/shared/_header.html.erb +1 -4
  43. data/config/brakeman.ignore +38 -0
  44. data/config/importmap.rb +8 -6
  45. data/config/locales/en.yml +41 -0
  46. data/config/routes.rb +1 -1
  47. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  48. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  49. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  50. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  51. data/lib/panda/cms/asset_loader.rb +27 -77
  52. data/lib/panda/cms/bulk_editor.rb +288 -12
  53. data/lib/panda/cms/engine/asset_config.rb +49 -0
  54. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  55. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  56. data/lib/panda/cms/engine/core_config.rb +106 -0
  57. data/lib/panda/cms/engine/helper_config.rb +20 -0
  58. data/lib/panda/cms/engine/route_config.rb +34 -0
  59. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  60. data/lib/panda/cms/engine.rb +44 -221
  61. data/lib/panda/cms.rb +10 -0
  62. data/lib/panda-cms/version.rb +1 -1
  63. data/lib/panda-cms.rb +16 -2
  64. metadata +20 -22
  65. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  66. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  67. data/config/initializers/inflections.rb +0 -5
  68. data/lib/tasks/assets.rake +0 -540
@@ -38,6 +38,31 @@ module Panda
38
38
  archived: "archived"
39
39
  }
40
40
 
41
+ enum :seo_index_mode, {
42
+ visible: "visible",
43
+ invisible: "invisible"
44
+ }, prefix: :seo
45
+
46
+ enum :og_type, {
47
+ website: "website",
48
+ article: "article",
49
+ profile: "profile",
50
+ video: "video",
51
+ book: "book"
52
+ }, prefix: :og
53
+
54
+ # Active Storage attachment for Open Graph image
55
+ has_one_attached :og_image do |attachable|
56
+ attachable.variant :og_share, resize_to_limit: [1200, 630]
57
+ end
58
+
59
+ # SEO validations
60
+ validates :seo_title, length: {maximum: 70}, allow_blank: true
61
+ validates :seo_description, length: {maximum: 160}, allow_blank: true
62
+ validates :og_title, length: {maximum: 60}, allow_blank: true
63
+ validates :og_description, length: {maximum: 200}, allow_blank: true
64
+ validates :canonical_url, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[http https])}, allow_blank: true
65
+
41
66
  def to_param
42
67
  # For date-based URLs, return just the slug portion
43
68
  parts = CGI.unescape(slug).delete_prefix("/").split("/")
@@ -88,6 +113,78 @@ module Panda
88
113
  text.truncate(length).html_safe
89
114
  end
90
115
 
116
+ #
117
+ # Returns the effective SEO title for this post
118
+ # Falls back to post title if not set
119
+ #
120
+ # @return [String] The SEO title to use
121
+ # @visibility public
122
+ #
123
+ def effective_seo_title
124
+ seo_title.presence || title
125
+ end
126
+
127
+ #
128
+ # Returns the effective SEO description for this post
129
+ # Falls back to excerpt if not set
130
+ #
131
+ # @return [String, nil] The SEO description to use
132
+ # @visibility public
133
+ #
134
+ def effective_seo_description
135
+ seo_description.presence || excerpt(160, squish: true)
136
+ end
137
+
138
+ #
139
+ # Returns the effective Open Graph title
140
+ # Falls back to SEO title, then post title
141
+ #
142
+ # @return [String] The OG title to use
143
+ # @visibility public
144
+ #
145
+ def effective_og_title
146
+ og_title.presence || effective_seo_title
147
+ end
148
+
149
+ #
150
+ # Returns the effective Open Graph description
151
+ # Falls back to SEO description or excerpt
152
+ #
153
+ # @return [String, nil] The OG description to use
154
+ # @visibility public
155
+ #
156
+ def effective_og_description
157
+ og_description.presence || effective_seo_description
158
+ end
159
+
160
+ #
161
+ # Returns the effective canonical URL for this post
162
+ # Falls back to the post's own URL if not explicitly set
163
+ #
164
+ # @return [String] The canonical URL to use
165
+ # @visibility public
166
+ #
167
+ def effective_canonical_url
168
+ canonical_url.presence || slug
169
+ end
170
+
171
+ #
172
+ # Generates the robots meta tag content based on seo_index_mode
173
+ #
174
+ # @return [String] The robots meta tag content (e.g., "index, follow")
175
+ # @visibility public
176
+ #
177
+ def robots_meta_content
178
+ case seo_index_mode
179
+ when "visible"
180
+ "index, follow"
181
+ when "invisible"
182
+ "noindex, nofollow"
183
+ else
184
+ "index, follow" # Default fallback
185
+ end
186
+ end
187
+
91
188
  private
92
189
 
93
190
  def clear_menu_cache
@@ -2,10 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <title>Test Homepage</title>
5
- <% if params[:embed_id].present? %>
6
- <!-- Include Panda CMS assets for editor functionality when in edit mode -->
7
- <%= panda_cms_complete_assets %>
8
- <% end %>
5
+ <!-- CMS assets are automatically injected by editor_iframe_controller when in edit mode -->
9
6
  </head>
10
7
  <body>
11
8
  <h1><%= @page.title %></h1>
@@ -2,10 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <title>Test Page</title>
5
- <% if params[:embed_id].present? %>
6
- <!-- Include Panda CMS assets for editor functionality when in edit mode -->
7
- <%= panda_cms_complete_assets %>
8
- <% end %>
5
+ <!-- CMS assets are automatically injected by editor_iframe_controller when in edit mode -->
9
6
  </head>
10
7
  <body>
11
8
  <h1><%= @page.title %></h1>
@@ -1,7 +1,7 @@
1
1
  <div class="" data-controller="dashboard">
2
2
  <%= render Panda::Core::Admin::ContainerComponent.new do |container| %>
3
3
  <% container.heading(text: "Dashboard", level: 1) do |heading| %>
4
- <% heading.button(action: :add, text: "Add Page", link: new_admin_cms_page_path) %>
4
+ <% heading.button(action: :add, text: "Add Page", href: new_admin_cms_page_path) %>
5
5
  <% end %>
6
6
  <dl class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
7
7
  <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Today", value: Panda::CMS::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
@@ -12,6 +12,6 @@
12
12
  <% end %>
13
13
 
14
14
  <div data-controller="file-gallery" class="pb-24">
15
- <%= render Panda::Core::Admin::FileGalleryComponent.new(files: @files, selected_file: @selected_file) %>
15
+ <%= Panda::Core::Admin::FileGalleryComponent.new(files: @files, selected_file: @selected_file) %>
16
16
  </div>
17
17
  <% end %>
@@ -5,10 +5,10 @@
5
5
  <%= render Panda::Core::Admin::TableComponent.new(term: "submission", rows: submissions) do |table| %>
6
6
  <% fields.each do |field, title| %>
7
7
  <% table.column(title) do |submission| %>
8
- <% if field[0] == "email" || field[0] == "email_address" %>
9
- <a href="mailto:<%= submission.data[field[0]] %>" class="border-b border-gray-500 hover:text-gray-900"><%= submission.data[field[0]] %></a>
8
+ <% if field == "email" || field == "email_address" %>
9
+ <a href="mailto:<%= submission.data[field] %>" class="border-b border-gray-500 hover:text-gray-900"><%= submission.data[field] %></a>
10
10
  <% else %>
11
- <%= simple_format(submission.data[field[0]]) %>
11
+ <%= simple_format(submission.data[field]) %>
12
12
  <% end %>
13
13
  <% end %>
14
14
  <% end %>
@@ -1,7 +1,7 @@
1
1
  <div class="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700 nested-form-wrapper" data-new-record="<%= form.object.new_record? %>">
2
- <%= form.text_field :text, placeholder: "Menu item text" %>
3
- <%= form.collection_select :panda_cms_page_id, Panda::CMS::Page.order(:title), :id, :title, { include_blank: "Select a page (optional)" }, { class: "mt-1" } %>
4
- <%= form.text_field :external_url, placeholder: "External URL (optional)" %>
2
+ <%= form.text_field :text, label: "Menu Item Text" %>
3
+ <%= form.collection_select :panda_cms_page_id, Panda::CMS::Page.order(:title), :id, :title, { include_blank: "Select a page (optional)", label: "Page" }, { class: "mt-1" } %>
4
+ <%= form.text_field :external_url, label: "External URL (optional)" %>
5
5
 
6
6
  <div class="mt-3">
7
7
  <%= render Panda::Core::Admin::ButtonComponent.new(text: "Remove", action: :delete, link: "#", size: :small, data: { action: "click->nested-form#remove" }) %>
@@ -1,18 +1,17 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
- <% component.heading(text: "Edit Menu: #{menu.name}", level: 1) %>
2
+ <% component.heading(text: "Edit Menu: #{@menu.name}", level: 1) %>
3
3
 
4
- <%= panda_cms_form_with model: menu, url: admin_cms_menu_path(menu), method: :put do |f| %>
5
- <%= render Panda::Core::Admin::FormErrorComponent.new(model: menu) %>
4
+ <%= panda_cms_form_with model: @menu, url: admin_cms_menu_path(@menu), method: :put, data: { controller: "menu-form" } do |f| %>
5
+ <%= render Panda::Core::Admin::FormErrorComponent.new(model: @menu) %>
6
6
 
7
7
  <%= f.text_field :name %>
8
- <%= f.select :kind, options_for_select([["Static", "static"], ["Auto", "auto"]], selected: menu.kind), {}, { data: { action: "change->menu-form#kindChanged" } } %>
8
+ <%= f.select :kind, options_for_select([["Static", "static"], ["Auto", "auto"]], selected: @menu.kind), {}, { data: { action: "change->menu-form#kindChanged" } } %>
9
9
 
10
- <div data-controller="menu-form">
11
- <div data-menu-form-target="startPageField" class="<%= 'hidden' unless menu.kind == 'auto' %>">
12
- <%= f.collection_select :start_page_id, Panda::CMS::Page.order(:title), :id, :title, { include_blank: "Select a page..." }, { class: "mt-1" } %>
13
- </div>
10
+ <div data-menu-form-target="startPageField" class="<%= 'hidden' unless @menu.kind == 'auto' %>">
11
+ <%= f.collection_select :start_page_id, Panda::CMS::Page.order(:title), :id, :title, { include_blank: "Select a page...", label: "Start Page" }, { class: "mt-1" } %>
12
+ </div>
14
13
 
15
- <% if menu.kind == "static" %>
14
+ <% if @menu.kind == "static" %>
16
15
  <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
17
16
  <% panel.heading(text: "Menu Items") %>
18
17
 
@@ -24,8 +23,8 @@
24
23
  </template>
25
24
 
26
25
  <div class="space-y-4">
27
- <% if menu.menu_items.any? %>
28
- <%= f.fields_for :menu_items, menu.menu_items.sort_by(&:lft) do |item_form| %>
26
+ <% if @menu.menu_items.any? %>
27
+ <%= f.fields_for :menu_items, @menu.menu_items.sort_by(&:lft) do |item_form| %>
29
28
  <%= render "menu_item_fields", form: item_form %>
30
29
  <% end %>
31
30
  <% end %>
@@ -42,8 +41,8 @@
42
41
  <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
43
42
  <% panel.heading(text: "Auto-Generated Menu Items") %>
44
43
 
45
- <% if menu.menu_items.any? %>
46
- <%= render Panda::Core::Admin::TableComponent.new(term: "menu item", rows: menu.menu_items.root.self_and_descendants) do |table| %>
44
+ <% if @menu.menu_items.any? %>
45
+ <%= render Panda::Core::Admin::TableComponent.new(term: "menu item", rows: @menu.menu_items.root.self_and_descendants) do |table| %>
47
46
  <% table.column("Text") do |menu_item| %>
48
47
  <div class="<%= "ml-#{menu_item.depth * 6}" %>">
49
48
  <%= menu_item.text %>
@@ -57,7 +56,6 @@
57
56
  <% end %>
58
57
  <% end %>
59
58
  <% end %>
60
- </div>
61
59
 
62
60
  <%= f.button "Save Menu" %>
63
61
  <% end %>
@@ -1,6 +1,6 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.heading(text: "Menus", level: 1) do |heading| %>
3
- <% heading.button(action: :add, text: "New Menu", link: new_admin_cms_menu_path) %>
3
+ <% heading.button(action: :add, text: "New Menu", href: new_admin_cms_menu_path) %>
4
4
  <% end %>
5
5
  <%= render Panda::Core::Admin::TableComponent.new(term: "menu", rows: menus) do |table| %>
6
6
  <% table.column("Name") { |menu| link_to menu.name, edit_admin_cms_menu_path(menu) } %>
@@ -1,18 +1,17 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.heading(text: "New Menu", level: 1) %>
3
3
 
4
- <%= panda_cms_form_with model: menu, url: admin_cms_menus_path, method: :post do |f| %>
4
+ <%= panda_cms_form_with model: menu, url: admin_cms_menus_path, method: :post, data: { controller: "menu-form" } do |f| %>
5
5
  <%= render Panda::Core::Admin::FormErrorComponent.new(model: menu) %>
6
6
 
7
7
  <%= f.text_field :name %>
8
8
  <%= f.select :kind, options_for_select([["Static", "static"], ["Auto", "auto"]], selected: menu.kind || "static"), {}, { data: { action: "change->menu-form#kindChanged" } } %>
9
9
 
10
- <div data-controller="menu-form">
11
- <div data-menu-form-target="startPageField" class="hidden">
12
- <%= f.collection_select :start_page_id, Panda::CMS::Page.order(:title), :id, :title, { include_blank: "Select a page..." }, { class: "mt-1" } %>
13
- </div>
10
+ <div data-menu-form-target="startPageField" class="hidden">
11
+ <%= f.collection_select :start_page_id, Panda::CMS::Page.order(:title), :id, :title, { include_blank: "Select a page...", label: "Start Page" }, { class: "mt-1" } %>
12
+ </div>
14
13
 
15
- <div data-menu-form-target="menuItemsSection">
14
+ <div data-menu-form-target="menuItemsSection">
16
15
  <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
17
16
  <% panel.heading(text: "Menu Items") %>
18
17
 
@@ -33,7 +32,6 @@
33
32
  </div>
34
33
  <% end %>
35
34
  </div>
36
- </div>
37
35
 
38
36
  <%= f.button "Create Menu" %>
39
37
  <% end %>
@@ -1,32 +1,150 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new(full_height: true) do |component| %>
2
- <% component.heading(text: "#{page.title}", level: 1) %>
2
+ <% component.heading(text: "#{page.title}", level: 1, meta: (link_to page.path, target: "_blank", class: "text-black/60 hover:text-black/80 inline-flex items-center gap-x-1" do
3
+ raw("#{page.path}<i class='fa-solid fa-arrow-up-right-from-square text-xs ml-1'></i>")
4
+ end)) do |h| %>
5
+ <% h.button(text: "Page Details", action: :secondary, as_button: true, size: :regular, id: "open-page-details", icon: "gear", data: { action: "click->toggle#toggle touch->toggle#toggle" }) %>
6
+ <% h.button(text: "Save Changes", action: :save, href: "#", size: :regular, id: "saveEditableButton", icon: "check") %>
7
+ <% end %>
3
8
  <% component.with_slideover(title: "Page Details") do %>
4
- <%= panda_cms_form_with model: page, url: admin_cms_page_path, method: :put do |f| %>
5
- <%= f.text_field :title %>
6
- <%= f.text_field :template, value: template.name, readonly: true, class: "read-only:bg-gray-100" %>
9
+ <% parent_seo_data = page.parent ? {
10
+ seoTitle: page.parent.seo_title,
11
+ seoDescription: page.parent.seo_description,
12
+ seoKeywords: page.parent.seo_keywords,
13
+ canonicalUrl: page.parent.canonical_url,
14
+ ogTitle: page.parent.og_title,
15
+ ogDescription: page.parent.og_description,
16
+ ogType: page.parent.og_type
17
+ }.to_json : '{}' %>
18
+ <% ai_generation_url = begin
19
+ admin_cms_pro_ai_generate_seo_path(page.id)
20
+ rescue NoMethodError
21
+ nil
22
+ end %>
23
+ <%= panda_cms_form_with model: page, url: admin_cms_page_path, method: :put, id: "page-form", data: {
24
+ controller: "page-form",
25
+ page_form_parent_seo_data_value: parent_seo_data,
26
+ page_form_page_id_value: page.id,
27
+ page_form_ai_generation_url_value: ai_generation_url
28
+ } do |f| %>
29
+ <%= f.text_field :title, { data: { page_form_target: "pageTitle" } } %>
30
+ <%= f.text_field :template, value: template.name, readonly: true %>
7
31
  <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: page.status) %>
8
32
  <% if page.page_type.in?(["system", "posts"]) %>
9
- <%= f.text_field :page_type, value: page.page_type.humanize, readonly: true, class: "read-only:bg-gray-100" %>
33
+ <%= f.text_field :page_type, value: page.page_type.humanize, readonly: true %>
10
34
  <% else %>
11
- <%= f.select :page_type, options_for_select([["Active", "standard"], ["Hidden", "hidden"], ["Code", "code"]], selected: page.page_type) %>
35
+ <%= f.select :page_type, options_for_select([["Standard", "standard"], ["Hidden", "hidden"], ["Code", "code"]], selected: page.page_type) %>
36
+ <% end %>
37
+
38
+ <%= f.section_heading "SEO Settings" %>
39
+
40
+ <% if defined?(Panda::CMS::Pro) && ai_generation_url.present? %>
41
+ <div class="mb-4">
42
+ <%= render Panda::Core::Admin::ButtonComponent.new(
43
+ text: "Generate With AI",
44
+ action: :save,
45
+ icon: "wand-magic-sparkles",
46
+ size: :regular,
47
+ as_button: true,
48
+ data: {
49
+ action: "click->page-form#generateSeoWithAI",
50
+ page_form_target: "generateButton"
51
+ }
52
+ ) %>
53
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uses AI to analyze page content and generate optimized SEO fields.</p>
54
+ </div>
12
55
  <% end %>
13
- <%= f.submit "Save" %>
56
+
57
+ <% unless page.parent_id.nil? %>
58
+ <%= f.check_box :inherit_seo, {
59
+ label: "Inherit SEO from parent page",
60
+ data: {
61
+ page_form_target: "inheritCheckbox",
62
+ action: "change->page-form#toggleInherit"
63
+ }
64
+ } %>
65
+ <% end %>
66
+
67
+ <div class="space-y-4">
68
+ <%= f.text_field :seo_title, {
69
+ meta: "Max 70 characters. Leave blank to use page title.",
70
+ max_length: 70,
71
+ data: { page_form_target: "seoTitle" }
72
+ } %>
73
+ <%= f.text_area :seo_description, {
74
+ rows: 3,
75
+ meta: "Max 160 characters. Shown in search results.",
76
+ max_length: 160,
77
+ data: { page_form_target: "seoDescription" }
78
+ } %>
79
+ <%= f.text_field :seo_keywords, {
80
+ meta: "Comma-separated keywords for search engines.",
81
+ data: { page_form_target: "seoKeywords" }
82
+ } %>
83
+ <%= f.radio_button_group :seo_index_mode,
84
+ [["Visible to search engines", "visible"], ["Hidden from search engines", "invisible"]],
85
+ { meta: "Control how search engines index this page." } %>
86
+ <%= f.text_field :canonical_url, {
87
+ meta: "Optional. Full URL to the canonical version of this page.",
88
+ data: { page_form_target: "canonicalUrl" }
89
+ } %>
90
+ </div>
91
+
92
+ <%= f.section_heading "Social Sharing" %>
93
+
94
+ <div class="space-y-4">
95
+ <%= f.text_field :og_title, {
96
+ label: "Social Media Title",
97
+ meta: "Max 60 characters. Leave blank to use SEO title.",
98
+ max_length: 60,
99
+ data: { page_form_target: "ogTitle" }
100
+ } %>
101
+ <%= f.text_area :og_description, {
102
+ label: "Social Media Description",
103
+ rows: 2,
104
+ meta: "Max 200 characters. Used on Facebook, LinkedIn, Twitter.",
105
+ max_length: 200,
106
+ data: { page_form_target: "ogDescription" }
107
+ } %>
108
+ <%= f.select :og_type,
109
+ options_for_select(
110
+ [["Website", "website"], ["Article", "article"], ["Profile", "profile"], ["Video", "video"], ["Book", "book"]],
111
+ selected: page.og_type
112
+ ),
113
+ { label: "Content Type" },
114
+ { data: { page_form_target: "ogType" } } %>
115
+ <%= f.file_field :og_image, {
116
+ label: "Social Media Image",
117
+ meta: "Recommended: 1200×630px (1.91:1 ratio). Used on Facebook, LinkedIn, Twitter.",
118
+ accept: "image/png,image/jpeg,image/jpg,image/webp",
119
+ with_cropper: true,
120
+ aspect_ratio: 1.91,
121
+ min_width: 1200,
122
+ min_height: 630
123
+ } %>
124
+ <% if page.og_image.attached? %>
125
+ <div class="mt-2">
126
+ <%= image_tag page.og_image.variant(:og_share), class: "max-w-xs rounded border border-gray-300" %>
127
+ </div>
128
+ <% end %>
129
+ </div>
14
130
  <% end %>
15
131
  <% end %>
16
- <div id="successMessage" class="hidden">
132
+ <% component.with_footer do %>
133
+ <button type="button" data-action="click->toggle#toggle touch->toggle#toggle" class="inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 cursor-pointer dark:bg-white/10 dark:text-gray-100 dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20">
134
+ <i class="fa-solid fa-xmark"></i> Cancel
135
+ </button>
136
+ <button type="submit" form="page-form" class="inline-flex items-center gap-x-1.5 justify-center rounded-md bg-mid px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-mid/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-mid cursor-pointer dark:shadow-none">
137
+ <i class="fa-solid fa-check"></i> Save
138
+ </button>
139
+ <% end %>
140
+
141
+ <%= content_tag :div, id: "successMessage", class: "hidden fixed top-4 right-4 z-50" do %>
17
142
  <%= render Panda::Core::Admin::FlashMessageComponent.new(kind: :success, message: "This page was successfully updated!", temporary: false) %>
18
- </div>
19
- <div id="errorMessage" class="hidden">
143
+ <% end %>
144
+
145
+ <%= content_tag :div, id: "errorMessage", class: "hidden fixed top-4 right-4 z-50" do %>
20
146
  <%= render Panda::Core::Admin::FlashMessageComponent.new(kind: :error, message: "There was an error updating this page.", temporary: false) %>
21
- </div>
22
- <div class="grid grid-cols-2 mb-4 -mt-5">
23
- <div>
24
- <a class="inline-block mb-2 text-sm text-black/60" target="_blank" href="<%= @page.path %>"><%= @page.path %> <i class="ml-2 fa-solid fa-arrow-up-right-from-square"></i></a>
25
- </div>
26
- <div class="flex justify-end">
27
- <%= render Panda::Core::Admin::ButtonComponent.new(text: "Save Changes", action: :save, icon: "check", link: "#", size: :regular, id: "saveEditableButton") %>
28
- </div>
29
- </div>
147
+ <% end %>
30
148
 
31
149
  <%= content_tag :iframe, nil,
32
150
  src: "#{page.path}?embed_id=#{page.id}",
@@ -37,6 +155,7 @@
37
155
  controller: "editor-iframe",
38
156
  editor_iframe_page_id_value: @page.id,
39
157
  editor_iframe_admin_path_value: "#{admin_cms_dashboard_url}",
40
- editor_iframe_autosave_value: false
158
+ editor_iframe_autosave_value: false,
159
+ editor_iframe_assets_value: panda_cms_injectable_assets
41
160
  } %>
42
161
  <% end %>
@@ -1,6 +1,6 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.heading(text: "Pages", level: 1) do |heading| %>
3
- <% heading.button(action: :add, text: "Add Page", link: new_admin_cms_page_path) %>
3
+ <% heading.button(action: :add, text: "Add Page", href: new_admin_cms_page_path) %>
4
4
  <% end %>
5
5
 
6
6
  <% if root_page %>
@@ -9,7 +9,7 @@
9
9
  <% table.column("Name") do |page| %>
10
10
  <div class="flex items-start" data-tree-target="row" data-page-id="<%= page.id %>" data-level="<%= page.level %>" data-parent-id="<%= page.parent_id %>">
11
11
  <div class="flex-1 min-w-0">
12
- <% # Add indentation for nested levels %>
12
+ <%# Add indentation for nested levels %>
13
13
  <% indent_class = case page.level
14
14
  when 0 then ""
15
15
  when 1 then "ml-4"
@@ -19,10 +19,10 @@
19
19
  <div class="flex items-start <%= indent_class %>">
20
20
  <div class="flex-1 min-w-0">
21
21
  <div class="flex items-center gap-2">
22
- <% # Chevron/icon section %>
22
+ <%# Chevron/icon section %>
23
23
  <% if page.children_count > 0 %>
24
24
  <% if page.level > 0 %>
25
- <% # Non-root pages with children: show chevron button only %>
25
+ <%# Non-root pages with children: show chevron button only %>
26
26
  <button type="button"
27
27
  class="text-xs text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary rounded px-1 w-4 flex items-center justify-center"
28
28
  data-tree-target="toggle"
@@ -31,11 +31,11 @@
31
31
  <i class="fa-solid fa-chevron-right"></i>
32
32
  </button>
33
33
  <% else %>
34
- <% # Root page (Home): show folder icon %>
34
+ <%# Root page (Home): show folder icon %>
35
35
  <i class="fa-solid fa-folder text-gray-500 mr-2"></i>
36
36
  <% end %>
37
37
  <% else %>
38
- <% # Leaf pages: show file icon %>
38
+ <%# Leaf pages: show file icon %>
39
39
  <i class="fa-solid fa-file text-gray-400 mr-2"></i>
40
40
  <% end %>
41
41
  <%= link_to page.title, edit_admin_cms_page_path(page), class: "font-medium hover:text-primary #{page.children_count > 0 && page.level > 0 ? 'ml-2' : ''}" %>
@@ -12,7 +12,7 @@
12
12
  "slug-target": "output_text"
13
13
  } %>
14
14
  </div>
15
- <%= f.select :author_id, Panda::Core::User.admin.map { |u| [u.name, u.id] } %>
15
+ <%= f.select :author_id, Panda::Core::User.admins.map { |u| [u.name, u.id] } %>
16
16
  <%= f.datetime_field :published_at %>
17
17
  <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: post.status) %>
18
18
 
@@ -31,7 +31,46 @@
31
31
  </div>
32
32
  </div>
33
33
 
34
- <%= f.submit post.persisted? ? "Update Post" : "Create Post",
34
+ <%= f.section_heading "SEO Settings" %>
35
+
36
+ <div class="space-y-4">
37
+ <%= f.text_field :seo_title, { meta: "Max 70 characters. Leave blank to use post title." } %>
38
+ <%= f.text_area :seo_description, { rows: 3, meta: "Max 160 characters. Leave blank to use post excerpt." } %>
39
+ <%= f.text_field :seo_keywords, { meta: "Comma-separated keywords for search engines." } %>
40
+ <%= f.radio_button_group :seo_index_mode,
41
+ [["Visible to search engines", "visible"], ["Hidden from search engines", "invisible"]],
42
+ { meta: "Control how search engines index this post." } %>
43
+ <%= f.text_field :canonical_url, { meta: "Optional. Full URL to the canonical version of this post." } %>
44
+ </div>
45
+
46
+ <%= f.section_heading "Social Sharing" %>
47
+
48
+ <div class="space-y-4">
49
+ <%= f.text_field :og_title, { label: "Social Media Title", meta: "Max 60 characters. Leave blank to use SEO title." } %>
50
+ <%= f.text_area :og_description, { label: "Social Media Description", rows: 2, meta: "Max 200 characters. Used on Facebook, LinkedIn, Twitter." } %>
51
+ <%= f.select :og_type,
52
+ options_for_select(
53
+ [["Website", "website"], ["Article", "article"], ["Profile", "profile"], ["Video", "video"], ["Book", "book"]],
54
+ selected: post.og_type
55
+ ),
56
+ { label: "Content Type" } %>
57
+ <%= f.file_field :og_image, {
58
+ label: "Social Media Image",
59
+ meta: "Recommended: 1200×630px (1.91:1 ratio). Used on Facebook, LinkedIn, Twitter.",
60
+ accept: "image/png,image/jpeg,image/jpg,image/webp",
61
+ with_cropper: true,
62
+ aspect_ratio: 1.91,
63
+ min_width: 1200,
64
+ min_height: 630
65
+ } %>
66
+ <% if f.object.present? && f.object.persisted? && f.object.og_image.attached? %>
67
+ <div class="mt-2">
68
+ <%= image_tag f.object.og_image.variant(:og_share), class: "max-w-xs rounded border border-gray-300" %>
69
+ </div>
70
+ <% end %>
71
+ </div>
72
+
73
+ <%= f.submit f.object.persisted? ? "Update Post" : "Create Post",
35
74
  class: "btn btn-primary",
36
75
  data: {
37
76
  disable_with: "Saving...",
@@ -1,6 +1,6 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.heading(text: post.title, level: 1) do |heading| %>
3
- <% heading.button(action: :view, text: "View Post", link: post_path(post.admin_param)) %>
3
+ <% heading.button(action: :view, text: "View Post", href: post_path(post.admin_param)) %>
4
4
  <% end %>
5
5
 
6
6
  <%= render "form", post: post, url: admin_cms_post_path(post.admin_param), method: :patch %>
@@ -1,6 +1,6 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.heading(text: "Posts", level: 1, icon: "fa-solid fa-newspaper") do |heading| %>
3
- <% heading.button(action: :add, text: "Add Post", link: new_admin_cms_post_path) %>
3
+ <% heading.button(action: :add, text: "Add Post", href: new_admin_cms_post_path) %>
4
4
  <% end %>
5
5
 
6
6
  <%= render Panda::Core::Admin::TableComponent.new(term: "post", rows: posts, icon: "fa-solid fa-newspaper") do |table| %>
@@ -12,9 +12,9 @@
12
12
  </span>
13
13
  </div>
14
14
  <% end %>
15
- <% table.column("Status") { |post| render Panda::Core::Admin::TagComponent.new(status: post.status) } %>
16
- <% table.column("Published") { |post| render Panda::CMS::Admin::UserActivityComponent.new(at: post.published_at, user: post.author)} %>
17
- <% table.column("Last Updated") { |post| render Panda::CMS::Admin::UserActivityComponent.new(at: post.updated_at)} %>
15
+ <% table.column("Status") { |post| render Panda::Core::Admin::TagComponent.new(status: post.status.to_sym) } %>
16
+ <% table.column("Published") { |post| render Panda::Core::Admin::UserActivityComponent.new(at: post.published_at, user: post.author)} %>
17
+ <% table.column("Last Updated") { |post| render Panda::Core::Admin::UserActivityComponent.new(at: post.updated_at)} %>
18
18
  <% end %>
19
19
 
20
20
  <% end %>
@@ -2,10 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <title>Panda CMS Page</title>
5
- <% if params[:embed_id].present? %>
6
- <!-- Include Panda CMS assets for editor functionality when in edit mode -->
7
- <%= panda_cms_complete_assets %>
8
- <% end %>
5
+ <!-- CMS assets are automatically injected by editor_iframe_controller when in edit mode -->
9
6
  </head>
10
7
  <body>
11
8
  <h1>Test Header</h1>
@@ -0,0 +1,38 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "Dynamic Render Path",
5
+ "warning_code": 15,
6
+ "fingerprint": "cbd974174800cbaa06ad90971544cdfb087e88c17ca6ca42d5e9b7e2f62f544f",
7
+ "check_name": "Render",
8
+ "message": "Render path contains parameter value",
9
+ "file": "app/views/panda/cms/admin/menus/edit.html.erb",
10
+ "line": 5,
11
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
12
+ "code": "render(action => Panda::Core::Admin::FormErrorComponent.new(:model => Panda::CMS::Menu.find(params[:id])), { :locals => ({ :\"panda::core::admin::formerrorcomponent\" => Panda::Core::Admin::FormErrorComponent.new(:model => Panda::CMS::Menu.find(params[:id])) }) })",
13
+ "render_path": [
14
+ {
15
+ "type": "controller",
16
+ "class": "Panda::CMS::Admin::MenusController",
17
+ "method": "edit",
18
+ "line": 39,
19
+ "file": "app/controllers/panda/cms/admin/menus_controller.rb",
20
+ "rendered": {
21
+ "name": "panda/cms/admin/menus/edit",
22
+ "file": "app/views/panda/cms/admin/menus/edit.html.erb"
23
+ }
24
+ }
25
+ ],
26
+ "location": {
27
+ "type": "template",
28
+ "template": "panda/cms/admin/menus/edit"
29
+ },
30
+ "user_input": "params[:id]",
31
+ "confidence": "Weak",
32
+ "cwe_id": [22],
33
+ "note": "False positive: The component class is hardcoded (Panda::Core::Admin::FormErrorComponent), not dynamically determined. The model parameter is validated through Rails' find method and only used as data to pass to the component, not to determine the rendering path."
34
+ }
35
+ ],
36
+ "updated": "2025-11-09 00:00:00 +0000",
37
+ "brakeman_version": "7.1.1"
38
+ }