panda-cms 0.10.2 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -0
  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 +2 -2
  6. data/app/components/panda/cms/menu_component.rb +9 -2
  7. data/app/components/panda/cms/rich_text_component.rb +1 -1
  8. data/app/components/panda/cms/text_component.rb +1 -1
  9. data/app/javascript/panda/cms/controllers/editor_form_controller.js +3 -3
  10. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +4 -4
  11. data/app/jobs/panda/cms/record_visit_job.rb +2 -1
  12. data/app/models/panda/cms/visit.rb +16 -1
  13. data/app/services/panda/social/instagram_feed_service.rb +54 -54
  14. data/app/views/panda/cms/admin/dashboard/show.html.erb +10 -3
  15. data/app/views/panda/cms/admin/forms/new.html.erb +1 -1
  16. data/app/views/panda/cms/admin/menus/edit.html.erb +1 -1
  17. data/app/views/panda/cms/admin/menus/new.html.erb +1 -1
  18. data/app/views/panda/cms/admin/pages/edit.html.erb +77 -3
  19. data/app/views/panda/cms/admin/posts/_form.html.erb +6 -6
  20. data/app/views/panda/cms/shared/_favicons.html.erb +7 -7
  21. data/config/importmap.rb +0 -1
  22. data/config/initializers/groupdate.rb +5 -0
  23. data/config/locales/en.yml +1 -2
  24. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +0 -10
  25. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +0 -6
  26. data/db/migrate/20240317230622_create_panda_cms_visits.rb +1 -1
  27. data/db/migrate/20240805121123_create_panda_cms_posts.rb +1 -1
  28. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +1 -1
  29. data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +0 -6
  30. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +1 -3
  31. data/db/migrate/20251117234530_add_index_to_visited_at_on_panda_cms_visits.rb +7 -0
  32. data/db/migrate/20251118015100_backfill_visited_at_for_existing_visits.rb +17 -0
  33. data/db/seeds.rb +5 -0
  34. data/lib/panda/cms/asset_loader.rb +18 -4
  35. data/lib/panda/cms/engine/autoload_config.rb +18 -0
  36. data/lib/panda/cms/engine/route_config.rb +1 -2
  37. data/lib/panda/cms/engine.rb +4 -23
  38. data/lib/{panda-cms → panda/cms}/version.rb +1 -1
  39. data/lib/panda/cms.rb +3 -1
  40. data/lib/panda-cms.rb +8 -1
  41. data/lib/tasks/ci.rake +0 -0
  42. metadata +13 -46
  43. data/app/assets/builds/panda.cms.css +0 -2754
  44. data/app/assets/stylesheets/panda/cms/application.tailwind.css +0 -162
  45. data/app/assets/stylesheets/panda/cms/editor.css +0 -120
  46. data/app/assets/tailwind/application.css +0 -178
  47. data/app/assets/tailwind/tailwind.config.js +0 -15
  48. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +0 -31
  49. data/db/migrate/20240317010532_create_panda_cms_users.rb +0 -14
  50. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +0 -61
  51. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +0 -7
  52. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +0 -24
  53. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +0 -30
  54. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +0 -10
  55. data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +0 -37
  56. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +0 -113
  57. data/lib/generators/panda/cms/install_generator.rb +0 -28
  58. data/public/panda-cms-assets/editor-js/core/editorjs.min.js +0 -83
  59. data/public/panda-cms-assets/editor-js/plugins/embed.min.js +0 -2
  60. data/public/panda-cms-assets/editor-js/plugins/header.min.js +0 -9
  61. data/public/panda-cms-assets/editor-js/plugins/nested-list.min.js +0 -2
  62. data/public/panda-cms-assets/editor-js/plugins/paragraph.min.js +0 -9
  63. data/public/panda-cms-assets/editor-js/plugins/quote.min.js +0 -2
  64. data/public/panda-cms-assets/editor-js/plugins/simple-image.min.js +0 -2
  65. data/public/panda-cms-assets/editor-js/plugins/table.min.js +0 -2
  66. data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
  67. data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
  68. data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
  69. data/public/panda-cms-assets/favicons/browserconfig.xml +0 -9
  70. data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
  71. data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
  72. data/public/panda-cms-assets/favicons/favicon.ico +0 -0
  73. data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
  74. data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +0 -61
  75. data/public/panda-cms-assets/favicons/site.webmanifest +0 -14
  76. data/public/panda-cms-assets/manifest.json +0 -20
  77. data/public/panda-cms-assets/panda-cms-0.7.4.css +0 -26
  78. data/public/panda-cms-assets/panda-cms-0.7.4.js +0 -150
  79. data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
  80. data/public/panda-cms-assets/panda-nav.png +0 -0
  81. data/public/panda-cms-assets/rich_text_editor.css +0 -568
  82. /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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64a39a57fd00f6001e7992e634d5aef7b0536e7bfc4295399ea23737c10b0d83
4
- data.tar.gz: 1b6d908a7f04e2f35ac7402b0c687160e71ac11e939a47f4193e1b4083947019
3
+ metadata.gz: 680bef797106fd65c694455aac3c55bc7799bfdfba990997a96bec44f325f780
4
+ data.tar.gz: a8485d6a4da3f5623531eb14ae6188226204ab8b2bce3381baa3f1c6e3b7caf4
5
5
  SHA512:
6
- metadata.gz: eb5a888a0e743db6524e89c35de782c4fa51ed3e0c903dfa0e80b989764bdc3e59e8f90a4cb4837cfe35b286e4edba56451442fb3338a49165c5bbe9bb81b75f
7
- data.tar.gz: f987d4e8feaf6ae5fdf6c217c591c52382cd04f67e9e7d78f1d4f3e6834e1f978a549ade07b6fbee13e7d42b98f6f7e1245ce68b5502562f548c2461dd4ade1c
6
+ metadata.gz: 3cba49016e04bc8ec5a2f1011897687eeb2478c451c0a9d38feef27605e4ddfe674c9d8543ee92e33b76049d3ecb08bac0bab1e6b790576c5f043683dbca606e
7
+ data.tar.gz: bb6c39903cdf5412c99dae06721a3d908dc0ed6d2e2c6a5c443eb96c6e6e43965029530a2206bce40bf2a8c06ddfd8fafbdf97d341c6f9739269f64693240479
data/README.md CHANGED
@@ -171,6 +171,83 @@ expect(page.title).to eq("Home")
171
171
 
172
172
  When testing models with file validations or complex callbacks, use the helper methods in `spec/models/panda/cms/page_spec.rb` as a reference.
173
173
 
174
+ ## 🚀 Running CI Locally
175
+
176
+ This project uses a **deterministic CI environment**, based on a single Docker
177
+ image (`panda-cms-test`). This ensures:
178
+
179
+ - identical Ruby/Node/Chrome versions everywhere
180
+ - no drift between local / Docker / GitHub Actions / act
181
+ - fast, stable, reproducible tests
182
+
183
+ There are **three** supported ways to run the full CI suite locally.
184
+
185
+ ---
186
+
187
+ ### 1. Run full CI via Docker Compose
188
+
189
+ ```sh
190
+ bin/ci build # build the local test image
191
+ bin/ci local # run full CI stack locally
192
+ ```
193
+
194
+ This uses `docker-compose.ci.yml` and reproduces the entire GitHub Actions environment.
195
+
196
+ ---
197
+
198
+ ### 2. Run single RSpec execution in the CI container
199
+
200
+ ```sh
201
+ bin/ci test
202
+ ```
203
+
204
+ This mounts your project into the container and executes RSpec exactly as CI does.
205
+
206
+ ---
207
+
208
+ ### 3. Run GitHub Actions locally using act
209
+
210
+ Install act:
211
+
212
+ ```sh
213
+ brew install act
214
+ ```
215
+
216
+ Use the project’s `.actrc`:
217
+
218
+ ```
219
+ -P ubuntu-latest=ghcr.io/tastybamboo/panda-cms-test:local
220
+ --container-options "--shm-size=2gb"
221
+ ```
222
+
223
+ Then run:
224
+
225
+ ```sh
226
+ bin/ci act
227
+ ```
228
+
229
+ This executes **the real GitHub Actions workflow** on your machine.
230
+
231
+ ---
232
+
233
+ ### 4. Continuous Integration on GitHub
234
+
235
+ GitHub Actions uses the same deterministic container image.
236
+ See:
237
+
238
+ ```
239
+ .github/workflows/ci.yml
240
+ ```
241
+
242
+ ---
243
+
244
+ ### 5. Code Coverage
245
+
246
+ Coverage is produced per-suite (models, requests, libs, system) and merged
247
+ into a unified `coverage/` directory via SimpleCov.
248
+
249
+ Artifacts are uploaded automatically on CI.
250
+
174
251
  ## License
175
252
 
176
253
  The gem is available as open source under the terms of the [BSD-3-Clause License](https://opensource.org/licenses/bsd-3-clause).
@@ -0,0 +1 @@
1
+ /* Panda CMS specific styles go here */
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module CMS
5
+ module Admin
6
+ class PopularPagesComponent < Panda::Core::Base
7
+ def initialize(popular_pages:, period_name: "All Time")
8
+ @popular_pages = popular_pages
9
+ @period_name = period_name
10
+ end
11
+
12
+ def view_template
13
+ render Panda::Core::Admin::PanelComponent.new do |panel|
14
+ panel.heading(text: "Popular Pages (#{@period_name})", level: :panel)
15
+
16
+ panel.body do
17
+ if @popular_pages.any?
18
+ div(class: "overflow-y-auto max-h-96") do
19
+ table(class: "min-w-full divide-y divide-gray-300") do
20
+ thead(class: "sticky top-0 bg-white z-10") do
21
+ tr do
22
+ th(class: "py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0") { "Page" }
23
+ th(class: "px-3 py-3.5 text-left text-sm font-semibold text-gray-900") { "Path" }
24
+ th(class: "px-3 py-3.5 text-right text-sm font-semibold text-gray-900") { "Views" }
25
+ end
26
+ end
27
+ tbody(class: "divide-y divide-gray-200") do
28
+ index = 0
29
+ @popular_pages.each do |page_data|
30
+ tr(class: index.even? ? "bg-indigo-50" : "bg-white") do
31
+ td(class: "whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0") do
32
+ a(
33
+ href: view_context.admin_cms_page_path(page_data.id),
34
+ class: "text-indigo-600 hover:text-indigo-900"
35
+ ) { page_data.title }
36
+ end
37
+ td(class: "whitespace-nowrap px-3 py-4 text-sm text-gray-500") do
38
+ a(
39
+ href: page_data.path,
40
+ class: "text-gray-600 hover:text-gray-900",
41
+ target: "_blank"
42
+ ) { page_data.path }
43
+ end
44
+ td(class: "whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-right font-semibold") do
45
+ page_data.visit_count.to_s
46
+ end
47
+ end
48
+ index += 1
49
+ end
50
+ end
51
+ end
52
+ end
53
+ else
54
+ p(class: "text-sm text-gray-500") { "No page visits recorded yet." }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -17,7 +17,7 @@ module Panda
17
17
  # Russian doll caching: Cache component output at block_content level
18
18
  # Only cache in non-editable mode (public-facing pages)
19
19
  if should_cache?
20
- raw cache_component_output
20
+ raw cache_component_output.to_s.html_safe
21
21
  else
22
22
  render_content
23
23
  end
@@ -84,7 +84,7 @@ module Panda
84
84
  data: {inline_code_editor_target: "codeInput"},
85
85
  class: "w-full h-64 p-3 font-mono text-sm border border-gray-300 rounded focus:ring-primary focus:border-primary",
86
86
  placeholder: "Enter your HTML/embed code here..."
87
- ) { raw(@code_content.to_s) }
87
+ ) { plain @code_content.to_s }
88
88
 
89
89
  div(class: "mt-3 flex justify-end space-x-2") do
90
90
  button(type: "button",
@@ -6,7 +6,7 @@ module Panda
6
6
  # @param name [String] The name of the menu to render
7
7
  # @param current_path [String] The current request path for highlighting active items
8
8
  # @param styles [Hash] CSS classes for menu items (default, active, inactive)
9
- # @param overrides [Hash] Menu item overrides (currently unused)
9
+ # @param overrides [Hash] Menu item overrides - supports :hidden_items array to hide specific menu items by text
10
10
  # @param render_page_menu [Boolean] Whether to render sub-page menus
11
11
  # @param page_menu_styles [Hash] Styles for the page menu component
12
12
  class MenuComponent < Panda::Core::Base
@@ -54,7 +54,14 @@ module Panda
54
54
  items.order(:lft).to_a # Convert to array for caching
55
55
  end
56
56
 
57
- @processed_menu_items = menu_items.map do |menu_item|
57
+ # Filter menu items based on overrides
58
+ filtered_menu_items = if @overrides[:hidden_items].present?
59
+ menu_items.reject { |item| @overrides[:hidden_items].include?(item.text) }
60
+ else
61
+ menu_items
62
+ end
63
+
64
+ @processed_menu_items = filtered_menu_items.map do |menu_item|
58
65
  add_css_classes_to_item(menu_item)
59
66
  menu_item
60
67
  end
@@ -277,7 +277,7 @@ module Panda
277
277
 
278
278
  def render_content_to_string
279
279
  # Render the component HTML to a string for caching
280
- helpers.content_tag(:div, @rendered_content.html_safe, class: "panda-cms-content", **element_attrs)
280
+ view_context.content_tag(:div, @rendered_content.html_safe, class: "panda-cms-content", **element_attrs)
281
281
  end
282
282
  end
283
283
  end
@@ -129,7 +129,7 @@ module Panda
129
129
 
130
130
  def render_content_to_string
131
131
  # Phlex doesn't have a direct way to capture output, so we render directly
132
- helpers.content_tag(:span, @content.html_safe, **element_attrs)
132
+ view_context.content_tag(:span, @content.html_safe, **element_attrs)
133
133
  end
134
134
  end
135
135
  end
@@ -1,6 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
- import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/cms/editor/editor_js_config";
3
- import { ResourceLoader } from "panda/cms/editor/resource_loader";
2
+ import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/editor/editor_js_config";
3
+ import { ResourceLoader } from "panda/editor/resource_loader";
4
4
 
5
5
  export default class extends Controller {
6
6
  static targets = ["editorContainer", "hiddenField"];
@@ -50,7 +50,7 @@ export default class extends Controller {
50
50
  this.editorContainerTarget.appendChild(holderDiv);
51
51
 
52
52
  const { getEditorConfig } = await import(
53
- "panda/cms/editor/editor_js_config"
53
+ "panda/editor/editor_js_config"
54
54
  );
55
55
 
56
56
  // Get initial content before creating config
@@ -1,8 +1,8 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { PlainTextEditor } from "panda/cms/editor/plain_text_editor"
3
- import { EditorJSInitializer } from "panda/cms/editor/editor_js_initializer"
4
- import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/cms/editor/editor_js_config"
5
- import { ResourceLoader } from "panda/cms/editor/resource_loader"
2
+ import { PlainTextEditor } from "panda/editor/plain_text_editor"
3
+ import { EditorJSInitializer } from "panda/editor/editor_js_initializer"
4
+ import { EDITOR_JS_RESOURCES, EDITOR_JS_CSS } from "panda/editor/editor_js_config"
5
+ import { ResourceLoader } from "panda/editor/resource_loader"
6
6
 
7
7
  export default class extends Controller {
8
8
  static values = {
@@ -23,7 +23,8 @@ module Panda
23
23
  user_agent: user_agent,
24
24
  ip_address: ip_address,
25
25
  referrer: referer, # TODO: Fix the naming of this column
26
- params: params
26
+ params: params,
27
+ visited_at: Time.current
27
28
  )
28
29
  end
29
30
  end
@@ -3,9 +3,24 @@
3
3
  module Panda
4
4
  module CMS
5
5
  class Visit < ApplicationRecord
6
- belongs_to :page, class_name: "Panda::CMS::Page", foreign_key: :panda_cms_page_id, optional: true
6
+ belongs_to :page, class_name: "Panda::CMS::Page", foreign_key: :page_id, optional: true
7
7
  belongs_to :user, class_name: "Panda::Core::User", foreign_key: :user_id, optional: true
8
8
  belongs_to :redirect, class_name: "Panda::CMS::Redirect", foreign_key: :redirect_id, optional: true
9
+
10
+ # Returns the most popular pages by visit count
11
+ # @param limit [Integer] Number of pages to return (default: 10)
12
+ # @param period [ActiveSupport::Duration] Time period to consider (default: all time)
13
+ # @return [Array<Hash>] Array of hashes with page and visit_count
14
+ def self.popular_pages(limit: 10, period: nil)
15
+ scope = joins(:page).where.not(page_id: nil)
16
+ scope = scope.where("visited_at >= ?", period.ago) if period
17
+
18
+ scope
19
+ .group("panda_cms_pages.id", "panda_cms_pages.title", "panda_cms_pages.path")
20
+ .select("panda_cms_pages.id, panda_cms_pages.title, panda_cms_pages.path, COUNT(*) as visit_count")
21
+ .order("visit_count DESC")
22
+ .limit(limit)
23
+ end
9
24
  end
10
25
  end
11
26
  end
@@ -1,63 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "http"
4
- require "down"
3
+ # require "http"
4
+ # require "down"
5
5
 
6
6
  module Panda
7
7
  module Social
8
8
  class InstagramFeedService
9
- GRAPH_API_VERSION = "v19.0"
10
- GRAPH_API_BASE_URL = "https://graph.instagram.com/#{GRAPH_API_VERSION}".freeze
11
-
12
- def initialize(access_token)
13
- @access_token = access_token
14
- end
15
-
16
- def sync_recent_posts
17
- fetch_media.each do |post_data|
18
- process_post(post_data)
19
- end
20
- end
21
-
22
- private
23
-
24
- def fetch_media
25
- response = HTTP.get("#{GRAPH_API_BASE_URL}/me/media", params: {
26
- access_token: @access_token,
27
- fields: "id,caption,media_type,media_url,permalink,timestamp"
28
- })
29
-
30
- return [] unless response.status.success?
31
-
32
- JSON.parse(response.body.to_s)["data"]
33
- end
34
-
35
- def process_post(post_data)
36
- return unless post_data["media_type"] == "IMAGE"
37
-
38
- instagram_post = InstagramPost.find_or_initialize_by(instagram_id: post_data["id"])
39
-
40
- instagram_post.assign_attributes(
41
- caption: post_data["caption"],
42
- posted_at: Time.zone.parse(post_data["timestamp"]),
43
- permalink: post_data["permalink"]
44
- )
45
-
46
- if instagram_post.new_record? || instagram_post.changed?
47
- # Download and attach image
48
- tempfile = Down.download(post_data["media_url"])
49
- instagram_post.image.attach(
50
- io: tempfile,
51
- filename: File.basename(post_data["media_url"])
52
- )
53
-
54
- instagram_post.save!
55
- end
56
- rescue Down::Error => e
57
- Rails.logger.error "Failed to download Instagram image: #{e.message}"
58
- rescue => e
59
- Rails.logger.error "Error processing Instagram post #{post_data["id"]}: #{e.message}"
60
- end
9
+ # GRAPH_API_VERSION = "v19.0"
10
+ # GRAPH_API_BASE_URL = "https://graph.instagram.com/#{GRAPH_API_VERSION}".freeze
11
+
12
+ # def initialize(access_token)
13
+ # @access_token = access_token
14
+ # end
15
+
16
+ # def sync_recent_posts
17
+ # fetch_media.each do |post_data|
18
+ # process_post(post_data)
19
+ # end
20
+ # end
21
+
22
+ # private
23
+
24
+ # def fetch_media
25
+ # response = HTTP.get("#{GRAPH_API_BASE_URL}/me/media", params: {
26
+ # access_token: @access_token,
27
+ # fields: "id,caption,media_type,media_url,permalink,timestamp"
28
+ # })
29
+
30
+ # return [] unless response.status.success?
31
+
32
+ # JSON.parse(response.body.to_s)["data"]
33
+ # end
34
+
35
+ # def process_post(post_data)
36
+ # return unless post_data["media_type"] == "IMAGE"
37
+
38
+ # instagram_post = InstagramPost.find_or_initialize_by(instagram_id: post_data["id"])
39
+
40
+ # instagram_post.assign_attributes(
41
+ # caption: post_data["caption"],
42
+ # posted_at: Time.zone.parse(post_data["timestamp"]),
43
+ # permalink: post_data["permalink"]
44
+ # )
45
+
46
+ # if instagram_post.new_record? || instagram_post.changed?
47
+ # # Download and attach image
48
+ # tempfile = Down.download(post_data["media_url"])
49
+ # instagram_post.image.attach(
50
+ # io: tempfile,
51
+ # filename: File.basename(post_data["media_url"])
52
+ # )
53
+
54
+ # instagram_post.save!
55
+ # end
56
+ # rescue Down::Error => e
57
+ # Rails.logger.error "Failed to download Instagram image: #{e.message}"
58
+ # rescue => e
59
+ # Rails.logger.error "Error processing Instagram post #{post_data["id"]}: #{e.message}"
60
+ # end
61
61
  end
62
62
  end
63
63
  end
@@ -4,9 +4,16 @@
4
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
- <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Today", value: Panda::CMS::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
8
- <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Last Week", value: Panda::CMS::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
9
- <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Last Month", value: Panda::CMS::Visit.group_by_month(:visited_at, last: 1).count.values.first) %>
7
+ <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Today", value: Panda::CMS::Visit.group_by_day(:visited_at, last: 1).count.values.first || 0) %>
8
+ <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Last Week", value: Panda::CMS::Visit.group_by_week(:visited_at, last: 1).count.values.first || 0) %>
9
+ <%= render Panda::Core::Admin::StatisticsComponent.new(metric: "Views Last Month", value: Panda::CMS::Visit.group_by_month(:visited_at, last: 1).count.values.first || 0) %>
10
10
  </dl>
11
+
12
+ <div class="grid grid-cols-1 gap-5 mt-8">
13
+ <%= render Panda::CMS::Admin::PopularPagesComponent.new(
14
+ popular_pages: Panda::CMS::Visit.popular_pages(limit: 10),
15
+ period_name: "All Time"
16
+ ) %>
17
+ </div>
11
18
  <% end %>
12
19
  </div>
@@ -5,7 +5,7 @@
5
5
  <div data-controller="slug">
6
6
  <input type="hidden" value="<%= Panda::CMS::Current.root %>" data-slug-target="existing_root">
7
7
  <%= f.select :parent_id, options, {}, { "data-slug-target": "input_select", "data-action": "change->slug#setPrePath" } %>
8
- <%= f.text_field :title, { data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
8
+ <%= f.text_field :title, { data: { "slug-target": "input_text" }, "data-action": "focusout->slug#generatePath" } %>
9
9
  <%= f.text_field :path, { data: { prefix: Panda::CMS::Current.root, "slug-target": "output_text" } } %>
10
10
  <%= f.collection_select :panda_cms_template_id, Panda::CMS::Template.available, :id, :name %>
11
11
  <%= f.button %>
@@ -5,7 +5,7 @@
5
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
10
  <div data-menu-form-target="startPageField" class="<%= 'hidden' unless @menu.kind == 'auto' %>">
11
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" } %>
@@ -5,7 +5,7 @@
5
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 || "static"), {}, { data: { action: "change->menu-form#kindChanged" } } %>
8
+ <%= f.select :kind, options_for_select([["Static", "static"], ["Auto", "auto"]], selected: menu.kind || "static"), {}, { "data-action": "change->menu-form#kindChanged" } %>
9
9
 
10
10
  <div data-menu-form-target="startPageField" class="hidden">
11
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" } %>
@@ -58,9 +58,9 @@
58
58
  <%= f.check_box :inherit_seo, {
59
59
  label: "Inherit SEO from parent page",
60
60
  data: {
61
- page_form_target: "inheritCheckbox",
62
- action: "change->page-form#toggleInherit"
63
- }
61
+ page_form_target: "inheritCheckbox"
62
+ },
63
+ "data-action": "change->page-form#toggleInherit"
64
64
  } %>
65
65
  <% end %>
66
66
 
@@ -158,4 +158,78 @@
158
158
  editor_iframe_autosave_value: false,
159
159
  editor_iframe_assets_value: panda_cms_injectable_assets
160
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>
161
235
  <% end %>
@@ -4,9 +4,9 @@
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"
@@ -22,9 +22,9 @@
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]">
@@ -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">
data/config/importmap.rb CHANGED
@@ -12,4 +12,3 @@ pin "@editorjs/editorjs", to: "/panda/cms/editor/editorjs.js" # @2.30.6
12
12
  # Pin the controllers directory
13
13
  pin "panda/cms/controllers/index", to: "/panda/cms/controllers/index.js"
14
14
  pin_all_from Panda::CMS::Engine.root.join("app/javascript/panda/cms/controllers"), under: "controllers", to: "/panda/cms/controllers"
15
- pin_all_from Panda::CMS::Engine.root.join("app/javascript/panda/cms/editor"), under: "editor", to: "/panda/cms/editor"
@@ -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"
@@ -68,8 +68,7 @@ en:
68
68
  external_url: External URL
69
69
  panda/cms_page_id: Page
70
70
  panda/cms/user:
71
- firstname: First Name
72
- lastname: Last Name
71
+ name: Name
73
72
  email: Email Address
74
73
  current_theme: Theme
75
74
  enums:
@@ -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