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
@@ -6,6 +6,7 @@ module Panda
6
6
  self.table_name = "panda_cms_menus"
7
7
 
8
8
  after_save :generate_auto_menu_items, if: -> { kind == "auto" }
9
+ after_commit :clear_menu_cache
9
10
 
10
11
  has_many :menu_items, lambda {
11
12
  order(lft: :asc)
@@ -50,6 +51,17 @@ module Panda
50
51
 
51
52
  errors.add(:start_page, "can't be blank")
52
53
  end
54
+
55
+ #
56
+ # Clear fragment cache when menu is updated
57
+ # This ensures menu changes appear immediately on the front-end
58
+ #
59
+ # @return nil
60
+ # @visibility private
61
+ #
62
+ def clear_menu_cache
63
+ Rails.cache.delete("panda_cms_menu/#{name}/#{id}")
64
+ end
53
65
  end
54
66
  end
55
67
  end
@@ -51,6 +51,31 @@ module Panda
51
51
  code: "code"
52
52
  }, prefix: :type
53
53
 
54
+ enum :seo_index_mode, {
55
+ visible: "visible",
56
+ invisible: "invisible"
57
+ }, prefix: :seo
58
+
59
+ enum :og_type, {
60
+ website: "website",
61
+ article: "article",
62
+ profile: "profile",
63
+ video: "video",
64
+ book: "book"
65
+ }, prefix: :og
66
+
67
+ # Active Storage attachment for Open Graph image
68
+ has_one_attached :og_image do |attachable|
69
+ attachable.variant :og_share, resize_to_limit: [1200, 630]
70
+ end
71
+
72
+ # SEO validations
73
+ validates :seo_title, length: {maximum: 70}, allow_blank: true
74
+ validates :seo_description, length: {maximum: 160}, allow_blank: true
75
+ validates :og_title, length: {maximum: 60}, allow_blank: true
76
+ validates :og_description, length: {maximum: 200}, allow_blank: true
77
+ validates :canonical_url, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[http https])}, allow_blank: true
78
+
54
79
  # Callbacks
55
80
  after_save :handle_after_save
56
81
  before_save :update_cached_last_updated_at
@@ -91,6 +116,85 @@ module Panda
91
116
  new_timestamp
92
117
  end
93
118
 
119
+ #
120
+ # Returns the effective SEO title for this page
121
+ # Falls back to page title if not set, with optional inheritance
122
+ #
123
+ # @return [String] The SEO title to use
124
+ # @visibility public
125
+ #
126
+ def effective_seo_title
127
+ return seo_title if seo_title.present?
128
+ return title unless inherit_seo
129
+
130
+ # Traverse up tree to find inherited value
131
+ self_and_ancestors.reverse.find { |p| p.seo_title.present? }&.seo_title || title
132
+ end
133
+
134
+ #
135
+ # Returns the effective SEO description for this page
136
+ # With optional inheritance from parent pages
137
+ #
138
+ # @return [String, nil] The SEO description to use
139
+ # @visibility public
140
+ #
141
+ def effective_seo_description
142
+ return seo_description if seo_description.present?
143
+ return nil unless inherit_seo
144
+
145
+ self_and_ancestors.reverse.find { |p| p.seo_description.present? }&.seo_description
146
+ end
147
+
148
+ #
149
+ # Returns the effective Open Graph title
150
+ # Falls back to SEO title, then page title
151
+ #
152
+ # @return [String] The OG title to use
153
+ # @visibility public
154
+ #
155
+ def effective_og_title
156
+ og_title.presence || effective_seo_title
157
+ end
158
+
159
+ #
160
+ # Returns the effective Open Graph description
161
+ # Falls back to SEO description
162
+ #
163
+ # @return [String, nil] The OG description to use
164
+ # @visibility public
165
+ #
166
+ def effective_og_description
167
+ og_description.presence || effective_seo_description
168
+ end
169
+
170
+ #
171
+ # Returns the effective canonical URL for this page
172
+ # Falls back to the page's own URL if not explicitly set
173
+ #
174
+ # @return [String] The canonical URL to use
175
+ # @visibility public
176
+ #
177
+ def effective_canonical_url
178
+ canonical_url.presence || path
179
+ end
180
+
181
+ #
182
+ # Generates the robots meta tag content based on seo_index_mode
183
+ #
184
+ # @return [String] The robots meta tag content (e.g., "index, follow")
185
+ # @visibility public
186
+ #
187
+ def robots_meta_content
188
+ case seo_index_mode
189
+ when "visible"
190
+ "index, follow"
191
+ when "invisible"
192
+ "noindex, nofollow"
193
+ else
194
+ "index, follow" # Default fallback
195
+ end
196
+ end
197
+
94
198
  private
95
199
 
96
200
  def validate_unique_path_in_scope
@@ -163,6 +267,8 @@ module Panda
163
267
  def update_cached_last_updated_at
164
268
  # Will be set to updated_at automatically during save
165
269
  # Block content updates will call refresh_last_updated_at! separately
270
+ # Only update if column exists (for backwards compatibility with older schemas)
271
+ return unless self.class.column_names.include?("cached_last_updated_at")
166
272
  self.cached_last_updated_at = Time.current
167
273
  end
168
274
  end
@@ -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
@@ -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
@@ -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,12 +1,19 @@
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
- <%= 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>
@@ -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,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,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
- <%= 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
- <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 %>