panda-cms 0.10.0 → 0.10.3

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -11
  3. data/app/assets/tailwind/panda/cms/_application.css +1 -0
  4. data/app/components/panda/cms/admin/popular_pages_component.rb +62 -0
  5. data/app/components/panda/cms/code_component.rb +46 -9
  6. data/app/components/panda/cms/menu_component.rb +18 -5
  7. data/app/components/panda/cms/page_menu_component.rb +9 -1
  8. data/app/components/panda/cms/rich_text_component.rb +49 -17
  9. data/app/components/panda/cms/text_component.rb +46 -14
  10. data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
  11. data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
  12. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  13. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  14. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  15. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  16. data/app/helpers/panda/cms/application_helper.rb +2 -3
  17. data/app/helpers/panda/cms/asset_helper.rb +14 -72
  18. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  19. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  20. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
  21. data/app/javascript/panda/cms/controllers/editor_form_controller.js +3 -3
  22. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +35 -8
  23. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  24. data/app/javascript/panda/cms/controllers/index.js +6 -0
  25. data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
  26. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  27. data/app/javascript/panda/cms/stimulus-loading.js +2 -1
  28. data/app/jobs/panda/cms/record_visit_job.rb +2 -1
  29. data/app/models/panda/cms/menu.rb +12 -0
  30. data/app/models/panda/cms/page.rb +106 -0
  31. data/app/models/panda/cms/post.rb +97 -0
  32. data/app/models/panda/cms/visit.rb +16 -1
  33. data/app/services/panda/social/instagram_feed_service.rb +54 -54
  34. data/app/views/layouts/homepage.html.erb +1 -4
  35. data/app/views/layouts/page.html.erb +1 -4
  36. data/app/views/panda/cms/admin/dashboard/show.html.erb +11 -4
  37. data/app/views/panda/cms/admin/files/index.html.erb +1 -1
  38. data/app/views/panda/cms/admin/forms/new.html.erb +1 -1
  39. data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
  40. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
  41. data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
  42. data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
  43. data/app/views/panda/cms/admin/menus/new.html.erb +6 -8
  44. data/app/views/panda/cms/admin/pages/edit.html.erb +213 -20
  45. data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
  46. data/app/views/panda/cms/admin/posts/_form.html.erb +47 -8
  47. data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
  48. data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
  49. data/app/views/panda/cms/shared/_favicons.html.erb +7 -7
  50. data/app/views/shared/_header.html.erb +1 -4
  51. data/config/brakeman.ignore +38 -0
  52. data/config/importmap.rb +7 -6
  53. data/config/initializers/groupdate.rb +5 -0
  54. data/config/locales/en.yml +42 -2
  55. data/config/routes.rb +1 -1
  56. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +0 -10
  57. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +0 -6
  58. data/db/migrate/20240317230622_create_panda_cms_visits.rb +1 -1
  59. data/db/migrate/20240805121123_create_panda_cms_posts.rb +1 -1
  60. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +1 -1
  61. data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +0 -6
  62. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +1 -3
  63. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  64. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  65. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  66. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  67. data/db/migrate/20251117234530_add_index_to_visited_at_on_panda_cms_visits.rb +7 -0
  68. data/db/migrate/20251118015100_backfill_visited_at_for_existing_visits.rb +17 -0
  69. data/db/seeds.rb +5 -0
  70. data/lib/panda/cms/asset_loader.rb +42 -78
  71. data/lib/panda/cms/bulk_editor.rb +288 -12
  72. data/lib/panda/cms/engine/asset_config.rb +49 -0
  73. data/lib/panda/cms/engine/autoload_config.rb +37 -0
  74. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  75. data/lib/panda/cms/engine/core_config.rb +106 -0
  76. data/lib/panda/cms/engine/helper_config.rb +20 -0
  77. data/lib/panda/cms/engine/route_config.rb +33 -0
  78. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  79. data/lib/panda/cms/engine.rb +32 -228
  80. data/lib/{panda-cms → panda/cms}/version.rb +1 -1
  81. data/lib/panda/cms.rb +12 -0
  82. data/lib/panda-cms.rb +24 -3
  83. data/lib/tasks/ci.rake +0 -0
  84. metadata +32 -67
  85. data/app/assets/builds/panda.cms.css +0 -2754
  86. data/app/assets/stylesheets/panda/cms/application.tailwind.css +0 -162
  87. data/app/assets/stylesheets/panda/cms/editor.css +0 -120
  88. data/app/assets/tailwind/application.css +0 -178
  89. data/app/assets/tailwind/tailwind.config.js +0 -15
  90. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  91. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  92. data/config/initializers/inflections.rb +0 -5
  93. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +0 -31
  94. data/db/migrate/20240317010532_create_panda_cms_users.rb +0 -14
  95. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +0 -61
  96. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +0 -7
  97. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +0 -24
  98. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +0 -30
  99. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +0 -10
  100. data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +0 -37
  101. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +0 -113
  102. data/lib/generators/panda/cms/install_generator.rb +0 -28
  103. data/lib/tasks/assets.rake +0 -540
  104. data/public/panda-cms-assets/editor-js/core/editorjs.min.js +0 -83
  105. data/public/panda-cms-assets/editor-js/plugins/embed.min.js +0 -2
  106. data/public/panda-cms-assets/editor-js/plugins/header.min.js +0 -9
  107. data/public/panda-cms-assets/editor-js/plugins/nested-list.min.js +0 -2
  108. data/public/panda-cms-assets/editor-js/plugins/paragraph.min.js +0 -9
  109. data/public/panda-cms-assets/editor-js/plugins/quote.min.js +0 -2
  110. data/public/panda-cms-assets/editor-js/plugins/simple-image.min.js +0 -2
  111. data/public/panda-cms-assets/editor-js/plugins/table.min.js +0 -2
  112. data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
  113. data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
  114. data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
  115. data/public/panda-cms-assets/favicons/browserconfig.xml +0 -9
  116. data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
  117. data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
  118. data/public/panda-cms-assets/favicons/favicon.ico +0 -0
  119. data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
  120. data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +0 -61
  121. data/public/panda-cms-assets/favicons/site.webmanifest +0 -14
  122. data/public/panda-cms-assets/manifest.json +0 -20
  123. data/public/panda-cms-assets/panda-cms-0.7.4.css +0 -26
  124. data/public/panda-cms-assets/panda-cms-0.7.4.js +0 -150
  125. data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
  126. data/public/panda-cms-assets/panda-nav.png +0 -0
  127. data/public/panda-cms-assets/rich_text_editor.css +0 -568
  128. /data/db/migrate/{20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb → 20251105000001_add_pending_review_status_to_pages_and_posts.rb} +0 -0
@@ -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
+ },
63
+ "data-action": "change->page-form#toggleInherit"
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,81 @@
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
  } %>
161
+
162
+ <%# Vanilla JS character counters - works independently of Stimulus %>
163
+ <script type="text/javascript">
164
+ (function() {
165
+ function initCharacterCounters() {
166
+ var fieldConfigs = [
167
+ { selector: '[data-page-form-target="seoTitle"]', max: 70, label: 'SEO Title' },
168
+ { selector: '[data-page-form-target="seoDescription"]', max: 160, label: 'SEO Description' },
169
+ { selector: '[data-page-form-target="ogTitle"]', max: 60, label: 'Social Media Title' },
170
+ { selector: '[data-page-form-target="ogDescription"]', max: 200, label: 'Social Media Description' }
171
+ ];
172
+
173
+ fieldConfigs.forEach(function(config) {
174
+ var field = document.querySelector(config.selector);
175
+ if (!field) return;
176
+
177
+ createCharacterCounter(field, config.max);
178
+ field.addEventListener('input', function() {
179
+ updateCharacterCounter(field, config.max);
180
+ });
181
+ });
182
+ }
183
+
184
+ function createCharacterCounter(field, maxLength) {
185
+ var container = field.closest('.panda-core-field-container');
186
+ if (!container) return;
187
+
188
+ var counter = container.querySelector('.character-counter');
189
+ if (!counter) {
190
+ counter = document.createElement('div');
191
+ counter.className = 'character-counter text-xs mt-1 text-gray-500 dark:text-gray-400';
192
+
193
+ var errorMsg = container.querySelector('.text-red-600');
194
+ if (errorMsg) {
195
+ errorMsg.parentNode.insertBefore(counter, errorMsg);
196
+ } else {
197
+ container.appendChild(counter);
198
+ }
199
+ }
200
+
201
+ updateCharacterCounter(field, maxLength);
202
+ }
203
+
204
+ function updateCharacterCounter(field, maxLength) {
205
+ var container = field.closest('.panda-core-field-container');
206
+ if (!container) return;
207
+
208
+ var counter = container.querySelector('.character-counter');
209
+ if (!counter) return;
210
+
211
+ var currentLength = field.value.length;
212
+ var remaining = maxLength - currentLength;
213
+
214
+ counter.textContent = currentLength + ' / ' + maxLength + ' characters';
215
+
216
+ counter.className = 'character-counter text-xs mt-1';
217
+
218
+ if (remaining < 0) {
219
+ counter.className += ' text-red-600 dark:text-red-400 font-semibold';
220
+ counter.textContent += ' (' + Math.abs(remaining) + ' over limit)';
221
+ } else if (remaining < 10) {
222
+ counter.className += ' text-yellow-600 dark:text-yellow-400';
223
+ } else {
224
+ counter.className += ' text-gray-500 dark:text-gray-400';
225
+ }
226
+ }
227
+
228
+ if (document.readyState === 'loading') {
229
+ document.addEventListener('DOMContentLoaded', initCharacterCounters);
230
+ } else {
231
+ initCharacterCounters();
232
+ }
233
+ })();
234
+ </script>
42
235
  <% 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' : ''}" %>
@@ -4,15 +4,15 @@
4
4
  <div data-controller="slug" data-slug-add-date-prefix-value="true">
5
5
  <%= f.text_field :title,
6
6
  data: {
7
- "slug-target": "input_text",
8
- action: "focusout->slug#generatePath"
9
- } %>
7
+ "slug-target": "input_text"
8
+ },
9
+ "data-action": "focusout->slug#generatePath" %>
10
10
  <%= f.text_field :slug,
11
11
  data: {
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
 
@@ -22,16 +22,55 @@
22
22
  <%= f.hidden_field :content,
23
23
  data: {
24
24
  editor_form_target: "hiddenField",
25
- initial_content: editor_content_for(post, local_assigns[:preserved_content]),
26
- action: "change->editor-form#handleContentChange"
27
- } %>
25
+ initial_content: editor_content_for(post, local_assigns[:preserved_content])
26
+ },
27
+ "data-action": "change->editor-form#handleContentChange" %>
28
28
  <div id="<%= editor_id %>"
29
29
  data-editor-form-target="editorContainer"
30
30
  class="max-w-full block bg-white pt-1 mb-4 mt-2 border border-mid rounded-md min-h-[300px]">
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 %>
@@ -1,9 +1,9 @@
1
- <link rel="apple-touch-icon" sizes="180x180" href="/panda-cms-assets/favicons/apple-touch-icon.png">
2
- <link rel="icon" type="image/png" sizes="32x32" href="/panda-cms-assets/favicons/favicon-32x32.png">
3
- <link rel="icon" type="image/png" sizes="16x16" href="/panda-cms-assets/favicons/favicon-16x16.png">
4
- <link rel="manifest" href="/panda-cms-assets/favicons/site.webmanifest">
5
- <link rel="mask-icon" href="/panda-cms-assets/favicons/safari-pinned-tab.svg" color="#5bbad5">
6
- <link rel="shortcut icon" href="/panda-cms-assets/favicons/favicon.ico">
1
+ <link rel="apple-touch-icon" sizes="180x180" href="/panda-core-assets/favicons/apple-touch-icon.png">
2
+ <link rel="icon" type="image/png" sizes="32x32" href="/panda-core-assets/favicons/favicon-32x32.png">
3
+ <link rel="icon" type="image/png" sizes="16x16" href="/panda-core-assets/favicons/favicon-16x16.png">
4
+ <link rel="manifest" href="/panda-core-assets/favicons/site.webmanifest">
5
+ <link rel="mask-icon" href="/panda-core-assets/favicons/safari-pinned-tab.svg" color="#5bbad5">
6
+ <link rel="shortcut icon" href="/panda-core-assets/favicons/favicon.ico">
7
7
  <meta name="msapplication-TileColor" content="#b91d47">
8
- <meta name="msapplication-config" content="/panda-cms-assets/favicons/browserconfig.xml">
8
+ <meta name="msapplication-config" content="/panda-core-assets/favicons/browserconfig.xml">
9
9
  <meta name="theme-color" content="#ffffff">
@@ -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
+ }
data/config/importmap.rb CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  # Base dependencies are now in panda-core (Stimulus, Turbo, Font Awesome, etc.)
4
4
  # This file only contains CMS-specific pins
5
+ # NOTE: Paths must be absolute (starting with /) because Rack::Static serves
6
+ # from /panda/cms/, not from asset pipeline /assets/
5
7
 
6
- pin "application_panda_cms", to: "panda/cms/application_panda_cms.js", preload: true
7
- pin "@hotwired/stimulus-loading", to: "panda/cms/stimulus-loading.js", preload: true
8
- pin "@editorjs/editorjs", to: "panda/cms/editor/editorjs.js" # @2.30.6
8
+ pin "panda/cms/application", to: "/panda/cms/application.js", preload: true
9
+ pin "@hotwired/stimulus-loading", to: "/panda/cms/stimulus-loading.js", preload: true
10
+ pin "@editorjs/editorjs", to: "/panda/cms/editor/editorjs.js" # @2.30.6
9
11
 
10
12
  # Pin the controllers directory
11
- pin "controllers", to: "panda/cms/controllers/index.js"
12
- pin_all_from Panda::CMS::Engine.root.join("app/javascript/panda/cms/controllers"), under: "controllers"
13
- pin_all_from Panda::CMS::Engine.root.join("app/javascript/panda/cms/editor"), under: "editor"
13
+ pin "panda/cms/controllers/index", to: "/panda/cms/controllers/index.js"
14
+ pin_all_from Panda::CMS::Engine.root.join("app/javascript/panda/cms/controllers"), under: "controllers", to: "/panda/cms/controllers"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load the groupdate gem to enable time-series grouping on ActiveRecord models
4
+ # This provides methods like group_by_day, group_by_week, group_by_month
5
+ require "groupdate"
@@ -29,6 +29,17 @@ en:
29
29
  title: Title
30
30
  path: URL
31
31
  panda_cms_template_id: Template
32
+ page_type: Page Type
33
+ seo_title: SEO Title
34
+ seo_description: SEO Description
35
+ seo_keywords: SEO Keywords
36
+ seo_index_mode: Search Engine Visibility
37
+ canonical_url: Canonical URL
38
+ og_title: OpenGraph Title
39
+ og_description: OpenGraph Description
40
+ og_type: OpenGraph Type
41
+ og_image: OpenGraph Image
42
+ inherit_seo: Inherit Settings
32
43
  panda/cms/post:
33
44
  title: Title
34
45
  slug: URL
@@ -36,6 +47,15 @@ en:
36
47
  user_id: Author
37
48
  published_at: Published At
38
49
  post_content: Content
50
+ seo_title: SEO Title
51
+ seo_description: SEO Description
52
+ seo_keywords: SEO Keywords
53
+ seo_index_mode: Search Engine Visibility
54
+ canonical_url: Canonical URL
55
+ og_title: OpenGraph Title
56
+ og_description: OpenGraph Description
57
+ og_type: OpenGraph Type
58
+ og_image: OpenGraph Image
39
59
  statuses:
40
60
  active: Active
41
61
  draft: Draft
@@ -48,7 +68,27 @@ en:
48
68
  external_url: External URL
49
69
  panda/cms_page_id: Page
50
70
  panda/cms/user:
51
- firstname: First Name
52
- lastname: Last Name
71
+ name: Name
53
72
  email: Email Address
54
73
  current_theme: Theme
74
+ enums:
75
+ panda/cms/page:
76
+ seo_index_mode:
77
+ visible: Visible to search engines
78
+ invisible: Hidden from search engines
79
+ og_type:
80
+ website: Website
81
+ article: Article
82
+ profile: Profile
83
+ video: Video
84
+ book: Book
85
+ panda/cms/post:
86
+ seo_index_mode:
87
+ visible: Visible to search engines
88
+ invisible: Hidden from search engines
89
+ og_type:
90
+ website: Website
91
+ article: Article
92
+ profile: Profile
93
+ video: Video
94
+ book: Book
data/config/routes.rb CHANGED
@@ -10,7 +10,7 @@ Panda::CMS::Engine.routes.draw do
10
10
 
11
11
  namespace admin_path.delete_prefix("/").to_sym, path: "#{admin_path}/cms", as: :admin_cms, module: :admin do
12
12
  resources :files
13
- resources :forms, only: %i[index show]
13
+ resources :forms
14
14
  resources :menus
15
15
  resources :pages do
16
16
  resources :block_contents, only: %i[update]
@@ -2,18 +2,8 @@
2
2
 
3
3
  class AddNestedSetsToPandaCMSPages < ActiveRecord::Migration[7.1]
4
4
  def self.up
5
- Panda::CMS::Page.where(parent_id: 0).update_all(parent_id: nil)
6
5
  add_column :panda_cms_pages, :lft, :integer
7
6
  add_column :panda_cms_pages, :rgt, :integer
8
-
9
- # This is necessary to update :lft and :rgt columns
10
- Panda::CMS::Page.reset_column_information
11
-
12
- # Only rebuild if there are existing pages
13
- # On fresh installs, there won't be any pages yet
14
- if Panda::CMS::Page.any?
15
- Panda::CMS::Page.rebuild!
16
- end
17
7
  end
18
8
 
19
9
  def self.down
@@ -11,16 +11,10 @@ class AddNestedToPandaCMSMenuItems < ActiveRecord::Migration[7.1]
11
11
  add_index :panda_cms_menu_items, :lft
12
12
  add_index :panda_cms_menu_items, :rgt
13
13
 
14
- Panda::CMS::MenuItem.reset_column_information
15
- Panda::CMS::MenuItem.rebuild!
16
-
17
14
  # Update pages whilst we're at it
18
15
  add_column :panda_cms_pages, :depth, :integer
19
16
  add_column :panda_cms_pages, :children_count, :integer, null: false, default: 0
20
17
  add_index :panda_cms_pages, :lft
21
18
  add_index :panda_cms_pages, :rgt
22
-
23
- Panda::CMS::Page.reset_column_information
24
- Panda::CMS::Page.rebuild!
25
19
  end
26
20
  end
@@ -8,7 +8,7 @@ class CreatePandaCMSVisits < ActiveRecord::Migration[7.1]
8
8
 
9
9
  t.references :panda_cms_page, null: true, foreign_key: true, type: :uuid
10
10
  t.references :panda_cms_redirect, null: true, foreign_key: true, type: :uuid
11
- t.references :panda_cms_user, null: true, foreign_key: true, type: :uuid
11
+ t.references :panda_core_user, null: true, foreign_key: true, type: :uuid
12
12
  t.timestamps
13
13
  end
14
14
  end
@@ -17,7 +17,7 @@ class CreatePandaCMSPosts < ActiveRecord::Migration[7.1]
17
17
  t.text :content
18
18
  t.datetime :published_at
19
19
  t.references :post_tag, type: :uuid, null: false, foreign_key: {to_table: :panda_cms_post_tags}
20
- t.references :user, type: :uuid, null: false, foreign_key: {to_table: :panda_cms_users}
20
+ t.references :user, type: :uuid, null: false, foreign_key: {to_table: :panda_core_users}
21
21
  t.timestamps
22
22
  t.index :slug, unique: true
23
23
  end
@@ -5,7 +5,7 @@ class FixPandaCMSVisitsColumnNames < ActiveRecord::Migration[7.1]
5
5
  change_table :panda_cms_visits do |t|
6
6
  t.rename :panda_cms_page_id, :page_id
7
7
  t.rename :panda_cms_redirect_id, :redirect_id
8
- t.rename :panda_cms_user_id, :user_id
8
+ t.rename :panda_core_user_id, :user_id
9
9
  t.string :referrer, null: true
10
10
  t.datetime :visited_at
11
11
  t.string :url, null: true