panda-cms 0.7.3 → 0.7.5

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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -5
  3. data/Rakefile +2 -0
  4. data/app/assets/builds/panda.cms.css +2 -6
  5. data/app/assets/tailwind/application.css +178 -0
  6. data/app/assets/tailwind/tailwind.config.js +15 -0
  7. data/app/builders/panda/cms/form_builder.rb +27 -36
  8. data/app/components/panda/cms/admin/flash_message_component.html.erb +2 -2
  9. data/app/components/panda/cms/admin/heading_component.rb +5 -4
  10. data/app/components/panda/cms/admin/panel_component.rb +2 -2
  11. data/app/components/panda/cms/admin/statistics_component.rb +1 -2
  12. data/app/components/panda/cms/admin/user_activity_component.html.erb +3 -1
  13. data/app/components/panda/cms/admin/user_activity_component.rb +8 -21
  14. data/app/components/panda/cms/code_component.rb +8 -4
  15. data/app/components/panda/cms/menu_component.rb +7 -6
  16. data/app/components/panda/cms/page_menu_component.rb +15 -17
  17. data/app/components/panda/cms/rich_text_component.rb +5 -6
  18. data/app/components/panda/cms/text_component.rb +6 -7
  19. data/app/constraints/panda/cms/admin_constraint.rb +4 -1
  20. data/app/controllers/panda/cms/admin/block_contents_controller.rb +0 -1
  21. data/app/controllers/panda/cms/admin/dashboard_controller.rb +13 -9
  22. data/app/controllers/panda/cms/admin/forms_controller.rb +0 -3
  23. data/app/controllers/panda/cms/admin/my_profile_controller.rb +44 -0
  24. data/app/controllers/panda/cms/admin/pages_controller.rb +15 -4
  25. data/app/controllers/panda/cms/admin/posts_controller.rb +6 -22
  26. data/app/controllers/panda/cms/admin/sessions_controller.rb +3 -5
  27. data/app/controllers/panda/cms/admin/settings/bulk_editor_controller.rb +32 -25
  28. data/app/controllers/panda/cms/admin/settings_controller.rb +14 -10
  29. data/app/controllers/panda/cms/application_controller.rb +7 -2
  30. data/app/controllers/panda/cms/errors_controller.rb +5 -2
  31. data/app/controllers/panda/cms/form_submissions_controller.rb +4 -0
  32. data/app/controllers/panda/cms/pages_controller.rb +40 -35
  33. data/app/controllers/panda/cms/posts_controller.rb +2 -0
  34. data/app/helpers/panda/cms/admin/files_helper.rb +5 -1
  35. data/app/helpers/panda/cms/admin/pages_helper.rb +5 -1
  36. data/app/helpers/panda/cms/asset_helper.rb +182 -0
  37. data/app/helpers/panda/cms/pages_helper.rb +2 -0
  38. data/app/helpers/panda/cms/posts_helper.rb +2 -0
  39. data/app/helpers/panda/cms/theme_helper.rb +2 -0
  40. data/app/javascript/panda/cms/controllers/editor_form_controller.js +59 -6
  41. data/app/javascript/panda/cms/controllers/index.js +5 -9
  42. data/app/javascript/panda/cms/controllers/slug_controller.js +64 -31
  43. data/app/javascript/panda/cms/controllers/theme_form_controller.js +25 -0
  44. data/app/javascript/panda/cms/stimulus-loading.js +39 -0
  45. data/app/javascript/panda_cms/stimulus-loading.js +39 -0
  46. data/app/jobs/panda/cms/application_job.rb +2 -0
  47. data/app/jobs/panda/cms/record_visit_job.rb +14 -14
  48. data/app/mailers/panda/cms/application_mailer.rb +2 -0
  49. data/app/mailers/panda/cms/form_mailer.rb +3 -1
  50. data/app/models/panda/cms/application_record.rb +3 -0
  51. data/app/models/panda/cms/block.rb +12 -17
  52. data/app/models/panda/cms/block_content.rb +7 -6
  53. data/app/models/panda/cms/breadcrumb.rb +2 -0
  54. data/app/models/panda/cms/current.rb +2 -0
  55. data/app/models/panda/cms/form.rb +2 -0
  56. data/app/models/panda/cms/form_submission.rb +2 -0
  57. data/app/models/panda/cms/menu.rb +12 -9
  58. data/app/models/panda/cms/menu_item.rb +10 -6
  59. data/app/models/panda/cms/page.rb +31 -16
  60. data/app/models/panda/cms/post.rb +12 -10
  61. data/app/models/panda/cms/redirect.rb +9 -1
  62. data/app/models/panda/cms/template.rb +17 -13
  63. data/app/models/panda/cms/user.rb +2 -0
  64. data/app/models/panda/cms/visit.rb +3 -1
  65. data/app/models/panda/social/instagram_post.rb +17 -0
  66. data/app/services/panda/cms/html_to_editor_js_converter.rb +10 -15
  67. data/app/services/panda/social/instagram_feed_service.rb +63 -0
  68. data/app/views/layouts/different_page.html.erb +6 -0
  69. data/app/views/layouts/homepage.html.erb +37 -0
  70. data/app/views/layouts/page.html.erb +18 -0
  71. data/app/views/layouts/panda/cms/application.html.erb +1 -0
  72. data/app/views/panda/cms/admin/my_profile/edit.html.erb +35 -0
  73. data/app/views/panda/cms/admin/pages/index.html.erb +1 -1
  74. data/app/views/panda/cms/admin/pages/new.html.erb +14 -8
  75. data/app/views/panda/cms/admin/posts/_form.html.erb +10 -0
  76. data/app/views/panda/cms/admin/posts/edit.html.erb +3 -2
  77. data/app/views/panda/cms/admin/posts/index.html.erb +1 -1
  78. data/app/views/panda/cms/admin/settings/index.html.erb +3 -1
  79. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +1 -1
  80. data/app/views/panda/cms/shared/_header.html.erb +14 -4
  81. data/app/views/panda/cms/shared/_importmap.html.erb +2 -1
  82. data/app/views/shared/_footer.html.erb +3 -0
  83. data/app/views/shared/_header.html.erb +11 -0
  84. data/config/importmap.rb +2 -0
  85. data/config/initializers/inflections.rb +2 -0
  86. data/config/initializers/panda/cms/form_errors.rb +20 -21
  87. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +2 -0
  88. data/config/initializers/panda/cms.rb +2 -0
  89. data/config/initializers/zeitwork.rb +2 -0
  90. data/config/locales/en.yml +5 -0
  91. data/config/puma/test.rb +3 -1
  92. data/config/routes.rb +11 -8
  93. data/db/migrate/20240205223709_create_panda_cms_pages.rb +2 -0
  94. data/db/migrate/20240219213327_create_panda_cms_page_versions.rb +2 -0
  95. data/db/migrate/20240303002805_create_panda_cms_templates.rb +4 -1
  96. data/db/migrate/20240303003434_create_panda_cms_template_versions.rb +2 -0
  97. data/db/migrate/20240303022441_create_panda_cms_blocks.rb +4 -1
  98. data/db/migrate/20240303024256_create_panda_cms_block_contents.rb +2 -0
  99. data/db/migrate/20240303024746_create_panda_cms_block_content_versions.rb +2 -0
  100. data/db/migrate/20240303233238_add_panda_cms_menu_table.rb +2 -0
  101. data/db/migrate/20240303234724_add_panda_cms_menu_item_table.rb +2 -0
  102. data/db/migrate/20240304134343_add_parent_id_to_panda_cms_pages.rb +2 -0
  103. data/db/migrate/20240315125411_add_status_to_panda_cms_pages.rb +7 -5
  104. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +2 -0
  105. data/db/migrate/20240316212822_add_kind_to_panda_cms_menus.rb +3 -1
  106. data/db/migrate/20240316221425_add_start_page_to_panda_cms_menus.rb +2 -0
  107. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +2 -0
  108. data/db/migrate/20240317010532_create_panda_cms_users.rb +2 -0
  109. data/db/migrate/20240317161534_add_max_uses_to_panda_cms_template.rb +2 -0
  110. data/db/migrate/20240317163053_reset_counter_cache_on_panda_cms_template.rb +2 -0
  111. data/db/migrate/20240317214827_create_panda_cms_redirects.rb +2 -0
  112. data/db/migrate/20240317230622_create_panda_cms_visits.rb +2 -0
  113. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +5 -2
  114. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +2 -0
  115. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +8 -6
  116. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +2 -0
  117. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +2 -0
  118. data/db/migrate/20240804235210_create_panda_cms_forms.rb +2 -0
  119. data/db/migrate/20240805013612_create_panda_cms_form_submissions.rb +2 -0
  120. data/db/migrate/20240805121123_create_panda_cms_posts.rb +3 -1
  121. data/db/migrate/20240805123104_create_panda_cms_post_versions.rb +2 -0
  122. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +2 -0
  123. data/db/migrate/20240806204412_add_completion_path_to_panda_cms_forms.rb +2 -0
  124. data/db/migrate/20240820081917_change_form_submissions_to_submission_count.rb +2 -0
  125. data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +6 -4
  126. data/db/migrate/20241031205109_add_cached_content_to_panda_cms_block_contents.rb +2 -0
  127. data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +2 -0
  128. data/db/migrate/20241120000419_remove_post_tag_references.rb +2 -0
  129. data/db/migrate/20241120110943_add_editor_js_to_posts.rb +2 -0
  130. data/db/migrate/20241120113859_add_cached_content_to_panda_cms_posts.rb +2 -0
  131. data/db/migrate/20241123234140_remove_post_tag_id_from_posts.rb +2 -0
  132. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +2 -0
  133. data/db/migrate/20250120235542_remove_paper_trail.rb +56 -0
  134. data/db/migrate/20250126234001_create_panda_social_instagram_posts.rb +16 -0
  135. data/db/migrate/20250504221812_add_current_theme_to_panda_cms_users.rb +7 -0
  136. data/db/seeds.rb +2 -0
  137. data/lib/generators/panda/cms/install_generator.rb +2 -0
  138. data/lib/panda/cms/asset_loader.rb +390 -0
  139. data/lib/panda/cms/bulk_editor.rb +7 -3
  140. data/lib/panda/cms/demo_site_generator.rb +27 -4
  141. data/lib/panda/cms/editor_js/blocks/alert.rb +2 -0
  142. data/lib/panda/cms/editor_js/blocks/base.rb +2 -0
  143. data/lib/panda/cms/editor_js/blocks/header.rb +2 -0
  144. data/lib/panda/cms/editor_js/blocks/image.rb +3 -0
  145. data/lib/panda/cms/editor_js/blocks/list.rb +2 -0
  146. data/lib/panda/cms/editor_js/blocks/paragraph.rb +3 -0
  147. data/lib/panda/cms/editor_js/blocks/quote.rb +3 -0
  148. data/lib/panda/cms/editor_js/blocks/table.rb +3 -1
  149. data/lib/panda/cms/editor_js/renderer.rb +3 -0
  150. data/lib/panda/cms/editor_js.rb +2 -0
  151. data/lib/panda/cms/editor_js_content.rb +50 -23
  152. data/lib/panda/cms/engine.rb +36 -37
  153. data/lib/panda/cms/exceptions_app.rb +2 -0
  154. data/lib/panda/cms/railtie.rb +2 -0
  155. data/lib/panda/cms/slug.rb +3 -1
  156. data/lib/panda-cms/version.rb +3 -1
  157. data/lib/panda-cms.rb +17 -2
  158. data/lib/tasks/assets.rake +547 -0
  159. data/lib/tasks/panda/cms/install.rake +25 -0
  160. data/lib/tasks/panda/social/instagram.rake +20 -0
  161. data/lib/tasks/panda_cms.rake +3 -30
  162. data/public/panda-cms-assets/editor-js/core/editorjs.min.js +83 -0
  163. data/public/panda-cms-assets/editor-js/plugins/embed.min.js +2 -0
  164. data/public/panda-cms-assets/editor-js/plugins/header.min.js +9 -0
  165. data/public/panda-cms-assets/editor-js/plugins/nested-list.min.js +2 -0
  166. data/public/panda-cms-assets/editor-js/plugins/paragraph.min.js +9 -0
  167. data/public/panda-cms-assets/editor-js/plugins/quote.min.js +2 -0
  168. data/public/panda-cms-assets/editor-js/plugins/simple-image.min.js +2 -0
  169. data/public/panda-cms-assets/editor-js/plugins/table.min.js +2 -0
  170. data/public/panda-cms-assets/manifest.json +20 -0
  171. data/public/panda-cms-assets/panda-cms-0.7.4.css +26 -0
  172. data/public/panda-cms-assets/panda-cms-0.7.4.js +150 -0
  173. metadata +71 -438
  174. data/app/models/action_text/rich_text_version.rb +0 -6
  175. data/app/models/panda/cms/block_content_version.rb +0 -8
  176. data/app/models/panda/cms/page_version.rb +0 -8
  177. data/app/models/panda/cms/post_version.rb +0 -8
  178. data/app/models/panda/cms/template_version.rb +0 -8
  179. data/app/models/panda/cms/version.rb +0 -8
  180. data/config/initializers/panda/cms/paper_trail.rb +0 -7
  181. data/db/migrate/20240904200605_create_action_text_tables.action_text.rb +0 -24
  182. data/db/migrate/20241119214549_remove_action_text_from_posts.rb +0 -9
@@ -1,33 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Block < ApplicationRecord
4
6
  self.table_name = "panda_cms_blocks"
5
7
 
6
- belongs_to :template, foreign_key: :panda_cms_template_id, class_name: "Panda::CMS::Template", inverse_of: :blocks, optional: true
7
- has_many :block_contents, foreign_key: :panda_cms_block_id, class_name: "Panda::CMS::BlockContent", inverse_of: :block
8
+ belongs_to :template, foreign_key: :panda_cms_template_id, class_name: "Panda::CMS::Template"
9
+ has_many :block_contents, foreign_key: :panda_cms_block_id, class_name: "Panda::CMS::BlockContent",
10
+ dependent: :destroy
8
11
 
9
- validates :kind, presence: true
10
12
  validates :name, presence: true
11
13
  validates :key, presence: true, uniqueness: {scope: :panda_cms_template_id, case_sensitive: false}
14
+ validates :kind, presence: true
12
15
 
13
- # Validation for presence on template intentionally skipped to allow global elements
14
-
15
- # NB: Commented out values are not yet implemented
16
16
  enum :kind, {
17
17
  plain_text: "plain_text",
18
18
  rich_text: "rich_text",
19
+ image: "image",
20
+ video: "video",
21
+ audio: "audio",
22
+ file: "file",
23
+ code: "code",
19
24
  iframe: "iframe",
20
- list: "list",
21
- code: "code"
22
- # image: "image",
23
- # video: "video",
24
- # audio: "audio",
25
- # file: "file",
26
- # iframe: "iframe",
27
- # quote: "quote",
28
- # list: "list"
29
- # table: "table",
30
- # form: "form"
25
+ quote: "quote"
31
26
  }
32
27
  end
33
28
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class BlockContent < ApplicationRecord
@@ -5,14 +7,13 @@ module Panda
5
7
 
6
8
  self.table_name = "panda_cms_block_contents"
7
9
 
8
- has_paper_trail versions: {
9
- class_name: "Panda::CMS::BlockContentVersion"
10
- }
11
-
12
- belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::Page", inverse_of: :block_contents, optional: true, touch: true
13
- belongs_to :block, foreign_key: :panda_cms_block_id, class_name: "Panda::CMS::Block", inverse_of: :block_contents, optional: false
10
+ belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::Page", touch: true
11
+ belongs_to :block, foreign_key: :panda_cms_block_id, class_name: "Panda::CMS::Block"
14
12
 
15
13
  validates :block, presence: true, uniqueness: {scope: :page}
14
+
15
+ store_accessor :content, [], prefix: true
16
+ store_accessor :cached_content, [], prefix: true
16
17
  end
17
18
  end
18
19
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Breadcrumb
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Current < ActiveSupport::CurrentAttributes
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Form < ApplicationRecord
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class FormSubmission < ApplicationRecord
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Menu < ApplicationRecord
@@ -5,13 +7,16 @@ module Panda
5
7
 
6
8
  after_save :generate_auto_menu_items, if: -> { kind == "auto" }
7
9
 
8
- has_many :menu_items, -> { order(lft: :asc) }, foreign_key: :panda_cms_menu_id, class_name: "Panda::CMS::MenuItem", inverse_of: :menu
9
- belongs_to :start_page, class_name: "Panda::CMS::Page", foreign_key: "start_page_id", inverse_of: :page_menu, optional: true
10
+ has_many :menu_items, lambda {
11
+ order(lft: :asc)
12
+ }, foreign_key: :panda_cms_menu_id, class_name: "Panda::CMS::MenuItem", inverse_of: :menu
13
+ belongs_to :start_page, class_name: "Panda::CMS::Page", foreign_key: "start_page_id", inverse_of: :page_menu,
14
+ optional: true
10
15
 
11
16
  accepts_nested_attributes_for :menu_items, reject_if: :all_blank, allow_destroy: true
12
17
 
13
18
  validates :name, presence: true, uniqueness: {case_sensitive: false}
14
- validates :kind, presence: true, inclusion: {in: ["static", "auto"]}
19
+ validates :kind, presence: true, inclusion: {in: %w[static auto]}
15
20
  validate :validate_start_page
16
21
 
17
22
  def generate_auto_menu_items
@@ -30,9 +35,7 @@ module Panda
30
35
  def generate_menu_items(parent_menu_item:, parent_page:)
31
36
  parent_page.children.where(status: [:active]).each do |page|
32
37
  menu_item = menu_items.create(text: page.title, panda_cms_page_id: page.id, parent: parent_menu_item)
33
- if page.children
34
- generate_menu_items(parent_menu_item: menu_item, parent_page: page)
35
- end
38
+ generate_menu_items(parent_menu_item: menu_item, parent_page: page) if page.children
36
39
  end
37
40
  end
38
41
 
@@ -43,9 +46,9 @@ module Panda
43
46
  # @visibility private
44
47
  #
45
48
  def validate_start_page
46
- if kind == "auto" && start_page.nil?
47
- errors.add(:start_page, "can't be blank")
48
- end
49
+ return unless kind == "auto" && start_page.nil?
50
+
51
+ errors.add(:start_page, "can't be blank")
49
52
  end
50
53
  end
51
54
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "awesome_nested_set"
2
4
 
3
5
  module Panda
@@ -8,8 +10,10 @@ module Panda
8
10
  self.implicit_order_column = "lft"
9
11
  self.table_name = "panda_cms_menu_items"
10
12
 
11
- belongs_to :menu, foreign_key: :panda_cms_menu_id, class_name: "Panda::CMS::Menu", inverse_of: :menu_items, touch: true
12
- belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::Page", inverse_of: :menu_items, optional: true
13
+ belongs_to :menu, foreign_key: :panda_cms_menu_id, class_name: "Panda::CMS::Menu", inverse_of: :menu_items,
14
+ touch: true
15
+ belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::Page", inverse_of: :menu_items,
16
+ optional: true
13
17
 
14
18
  validates :text, presence: true, uniqueness: {scope: :panda_cms_menu_id, case_sensitive: false}
15
19
  validates :page, presence: true, unless: -> { external_url.present? }
@@ -48,10 +52,10 @@ module Panda
48
52
  errors.add(:external_url, "must be a valid page or external link, neither are set")
49
53
  end
50
54
 
51
- if !page.nil? && !external_url.nil?
52
- errors.add(:page, "must be a valid page or external link, both are set")
53
- errors.add(:external_url, "must be a valid page or external link, both are set")
54
- end
55
+ return unless !page.nil? && !external_url.nil?
56
+
57
+ errors.add(:page, "must be a valid page or external link, both are set")
58
+ errors.add(:external_url, "must be a valid page or external link, both are set")
55
59
  end
56
60
  end
57
61
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "awesome_nested_set"
2
4
 
3
5
  module Panda
@@ -7,15 +9,10 @@ module Panda
7
9
  self.table_name = "panda_cms_pages"
8
10
  self.implicit_order_column = "lft"
9
11
 
10
- has_paper_trail versions: {
11
- class_name: "Panda::CMS::PageVersion"
12
- }
13
-
14
- after_save :after_save
15
-
16
- belongs_to :template, foreign_key: :panda_cms_template_id, class_name: "Panda::CMS::Template", inverse_of: :pages, optional: false, counter_cache: :pages_count
17
- has_many :blocks, through: :template
18
- has_many :block_contents, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::BlockContent", inverse_of: :page
12
+ belongs_to :template, class_name: "Panda::CMS::Template", foreign_key: :panda_cms_template_id
13
+ has_many :block_contents, class_name: "Panda::CMS::BlockContent", foreign_key: :panda_cms_page_id,
14
+ dependent: :destroy
15
+ has_many :blocks, through: :block_contents
19
16
  has_many :menu_items, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::MenuItem", inverse_of: :page
20
17
  has_many :menus, through: :menu_items
21
18
  has_many :menus_of_parent, through: :parent, source: :menus
@@ -25,8 +22,9 @@ module Panda
25
22
 
26
23
  validates :path,
27
24
  presence: true,
28
- uniqueness: true,
29
- format: {with: /\A\/.*\z/, message: "must start with a forward slash"}
25
+ format: {with: %r{\A/.*\z}, message: "must start with a forward slash"}
26
+
27
+ validate :validate_unique_path_in_scope
30
28
 
31
29
  validates :parent,
32
30
  presence: true,
@@ -44,6 +42,9 @@ module Panda
44
42
  archived: "archived"
45
43
  }
46
44
 
45
+ # Callbacks
46
+ after_save :handle_after_save
47
+
47
48
  #
48
49
  # Update any menus which include this page or its parent as a menu item
49
50
  #
@@ -57,13 +58,27 @@ module Panda
57
58
 
58
59
  private
59
60
 
61
+ def validate_unique_path_in_scope
62
+ # Skip validation if path is not present (other validations will catch this)
63
+ return if path.blank?
64
+
65
+ # Find any other pages with the same path
66
+ other_page = self.class.where(path: path).where.not(id: id).first
67
+
68
+ return unless other_page
69
+ # If there's another page with the same path, check if it has a different parent
70
+ return unless other_page.parent_id == parent_id
71
+
72
+ errors.add(:path, "has already been taken in this section")
73
+ end
74
+
60
75
  #
61
76
  # After save callbacks
62
77
  #
63
78
  # @return nil
64
79
  # @visibility private
65
80
  #
66
- def after_save
81
+ def handle_after_save
67
82
  generate_content_blocks
68
83
  update_existing_menu_items
69
84
  update_auto_menus
@@ -75,10 +90,10 @@ module Panda
75
90
  page_existing_block_ids = block_contents.map { |bc| bc.block.id }
76
91
  required_block_ids = template_block_ids - page_existing_block_ids
77
92
 
78
- if required_block_ids.count > 0
79
- required_block_ids.each do |block_id|
80
- Panda::CMS::BlockContent.find_or_create_by!(page: self, panda_cms_block_id: block_id, content: "")
81
- end
93
+ return unless required_block_ids.count.positive?
94
+
95
+ required_block_ids.each do |block_id|
96
+ Panda::CMS::BlockContent.find_or_create_by!(page: self, panda_cms_block_id: block_id, content: "")
82
97
  end
83
98
  end
84
99
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "awesome_nested_set"
2
4
 
3
5
  module Panda
@@ -10,12 +12,10 @@ module Panda
10
12
 
11
13
  self.table_name = "panda_cms_posts"
12
14
 
13
- has_paper_trail versions: {
14
- class_name: "Panda::CMS::PostVersion"
15
- }
16
-
17
15
  belongs_to :user, class_name: "Panda::CMS::User"
18
- belongs_to :author, class_name: "Panda::CMS::User"
16
+ belongs_to :author, class_name: "Panda::CMS::User", optional: true
17
+ has_many :block_contents, as: :blockable, dependent: :destroy
18
+ has_many :blocks, through: :block_contents
19
19
 
20
20
  validates :title, presence: true
21
21
  validates :slug,
@@ -49,11 +49,13 @@ module Panda
49
49
 
50
50
  def year
51
51
  return nil unless slug.match?(%r{\A/\d{4}/})
52
+
52
53
  slug.split("/")[1]
53
54
  end
54
55
 
55
56
  def month
56
57
  return nil unless slug.match?(%r{\A/\d{4}/\d{2}/})
58
+
57
59
  slug.split("/")[2]
58
60
  end
59
61
 
@@ -98,13 +100,13 @@ module Panda
98
100
  self.slug = CGI.unescape(slug.strip.gsub(%r{^/+|/+$}, ""))
99
101
 
100
102
  # Handle the case where we already have a properly formatted slug
101
- if slug.match?(%r{\A\d{4}/\d{2}/[^/]+\z})
102
- return self.slug = "/#{slug}"
103
- end
103
+ return self.slug = "/#{slug}" if slug.match?(%r{\A\d{4}/\d{2}/[^/]+\z})
104
104
 
105
105
  # Handle the case where we have a date-prefixed slug (from JS)
106
- if (match = slug.match(%r{\A(\d{4})-(\d{2})-(.+)\z}))
107
- year, month, base_slug = match[1], match[2], match[3]
106
+ if (match = slug.match(/\A(\d{4})-(\d{2})-(.+)\z/))
107
+ year = match[1]
108
+ month = match[2]
109
+ base_slug = match[3]
108
110
  return self.slug = "/#{year}/#{month}/#{base_slug}"
109
111
  end
110
112
 
@@ -1,11 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Redirect < ApplicationRecord
4
6
  belongs_to :origin_page, class_name: "Panda::CMS::Page", foreign_key: :origin_panda_cms_page_id, optional: true
5
- belongs_to :destination_page, class_name: "Panda::CMS::Page", foreign_key: :destination_panda_cms_page_id, optional: true
7
+ belongs_to :destination_page, class_name: "Panda::CMS::Page", foreign_key: :destination_panda_cms_page_id,
8
+ optional: true
6
9
 
7
10
  validates :status_code, presence: true
8
11
  validates :visits, presence: true
12
+ validates :origin_path, presence: true
13
+ validates :destination_path, presence: true
14
+
15
+ validates :origin_path, format: {with: %r{\A/.*\z}, message: "must start with a forward slash"}
16
+ validates :destination_path, format: {with: %r{\A/.*\z}, message: "must start with a forward slash"}
9
17
  end
10
18
  end
11
19
  end
@@ -1,17 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  # Represents a template in the Panda CMS application.
4
6
  class Template < ApplicationRecord
5
7
  self.table_name = "panda_cms_templates"
6
8
 
7
- # Enables versioning for the Template model using the `has_paper_trail` gem.
8
- has_paper_trail versions: {
9
- class_name: "Panda::CMS::TemplateVersion"
10
- }
11
-
12
9
  # Associations
13
- has_many :pages, class_name: "Panda::CMS::Page", dependent: :restrict_with_error, inverse_of: :template, foreign_key: :panda_cms_template_id
14
- has_many :blocks, class_name: "Panda::CMS::Block", dependent: :restrict_with_error, inverse_of: :template, foreign_key: :panda_cms_template_id
10
+ has_many :pages, class_name: "Panda::CMS::Page", dependent: :restrict_with_error, inverse_of: :template,
11
+ foreign_key: :panda_cms_template_id, counter_cache: :pages_count
12
+ has_many :blocks, class_name: "Panda::CMS::Block", dependent: :restrict_with_error, inverse_of: :template,
13
+ foreign_key: :panda_cms_template_id
15
14
  has_many :block_contents, through: :blocks
16
15
 
17
16
  # Validations
@@ -20,19 +19,23 @@ module Panda
20
19
  validates :file_path,
21
20
  presence: true,
22
21
  uniqueness: true,
23
- format: {with: /\Alayouts\/.*\z/, message: "must be a valid layout file path"}
22
+ format: {with: %r{\Alayouts/.*\z}, message: "must be a valid layout file path"}
24
23
 
25
24
  validate :validate_template_file_exists
26
25
 
27
26
  # Scopes
28
- scope :available, -> {
29
- where("max_uses IS NULL OR (pages_count IS NOT NULL AND pages_count < max_uses)")
27
+ scope :available, lambda {
28
+ where("max_uses IS NULL OR (max_uses > 0 AND pages_count < max_uses)")
30
29
  }
31
30
 
32
31
  def self.default
33
32
  find_by(file_path: "layouts/page")
34
33
  end
35
34
 
35
+ def self.reset_counter_cache
36
+ find_each { |template| template.update_column(:pages_count, template.pages.count) }
37
+ end
38
+
36
39
  # Generate missing blocks for all templates
37
40
  # @return [void]
38
41
  def self.generate_missing_blocks
@@ -44,7 +47,7 @@ module Panda
44
47
  # Matches:
45
48
  # Panda::CMS::RichTextComponent.new(key: :value)
46
49
  # Panda::CMS::RichTextComponent.new key: :value, key: value
47
- line.match(/Panda::CMS::([a-zA-Z]+)Component\.new[ \(]+([^\)]+)[\)]*/) do |match|
50
+ line.match(/Panda::CMS::([a-zA-Z]+)Component\.new[ (]+([^)]+)\)*/) do |match|
48
51
  # Extract the hash values
49
52
  template_path = file.gsub("app/views/", "").gsub(".html.erb", "")
50
53
  template_name = template_path.gsub("layouts/", "").titleize
@@ -69,7 +72,8 @@ module Panda
69
72
  # Create the block if it doesn't exist
70
73
  # TODO: +/- the output if it's created or removed
71
74
  begin
72
- block = Panda::CMS::Block.find_or_create_by!(template: template, kind: block_kind, key: block_name) do |block|
75
+ block = Panda::CMS::Block.find_or_create_by!(template: template, kind: block_kind,
76
+ key: block_name) do |block|
73
77
  block.name = block_name.titleize
74
78
  end
75
79
  rescue ActiveRecord::RecordInvalid => e
@@ -109,7 +113,7 @@ module Panda
109
113
  # Extract the file path from the Rails root
110
114
  file_path = file.to_s.sub("#{Rails.root}/app/views/", "").sub(".html.erb", "")
111
115
 
112
- next if file_path == "layouts/application" || file_path == "layouts/mailer"
116
+ next if ["layouts/application", "layouts/mailer"].include?(file_path)
113
117
 
114
118
  # Find or create the template based on the file path
115
119
  find_or_create_by(file_path: file_path) do |t|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class User < ApplicationRecord
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class Visit < ApplicationRecord
4
- belongs_to :page, class_name: "Panda::CMS::Page", foreign_key: :page_id, optional: true
6
+ belongs_to :page, class_name: "Panda::CMS::Page", foreign_key: :panda_cms_page_id, optional: true
5
7
  belongs_to :user, class_name: "Panda::CMS::User", foreign_key: :user_id, optional: true
6
8
  belongs_to :redirect, class_name: "Panda::CMS::Redirect", foreign_key: :redirect_id, optional: true
7
9
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Social
5
+ class InstagramPost < ApplicationRecord
6
+ self.table_name = "panda_social_instagram_posts"
7
+
8
+ has_one_attached :image
9
+
10
+ validates :instagram_id, presence: true, uniqueness: true
11
+ validates :caption, presence: true
12
+ validates :posted_at, presence: true
13
+
14
+ scope :ordered, -> { order(posted_at: :desc) }
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Panda
2
4
  module CMS
3
5
  class HtmlToEditorJsConverter
@@ -54,7 +56,7 @@ module Panda
54
56
  }
55
57
  when "p"
56
58
  text = process_inline_elements(child)
57
- paragraphs = text.split(/<br\s*\/?>\s*<br\s*\/?>/).map(&:strip)
59
+ paragraphs = text.split(%r{<br\s*/?>\s*<br\s*/?>}).map(&:strip)
58
60
  paragraphs.each do |paragraph|
59
61
  blocks << create_paragraph_block(paragraph) if paragraph.present?
60
62
  end
@@ -86,7 +88,7 @@ module Panda
86
88
  else
87
89
  # Handle p with nested content
88
90
  text = process_inline_elements(node)
89
- paragraphs = text.split(/<br\s*\/?>\s*<br\s*\/?>/).map(&:strip)
91
+ paragraphs = text.split(%r{<br\s*/?>\s*<br\s*/?>}).map(&:strip)
90
92
  paragraphs.each do |paragraph|
91
93
  blocks << create_paragraph_block(paragraph) if paragraph.present?
92
94
  end
@@ -131,26 +133,19 @@ module Panda
131
133
  end
132
134
  end
133
135
 
134
- # Add any remaining text as a final paragraph
135
- if current_text.present?
136
- # Split any remaining text on double line breaks
137
- paragraphs = current_text.split(/\n\n+/).map(&:strip)
138
- paragraphs.each do |paragraph|
139
- blocks << create_paragraph_block(paragraph) if paragraph.present?
140
- end
141
- end
142
-
143
- raise ConversionError, "No valid content blocks found" if blocks.empty?
136
+ # Add any remaining text
137
+ blocks << create_paragraph_block(current_text) if current_text.present?
144
138
 
139
+ # Return the complete EditorJS structure
145
140
  {
146
141
  "time" => Time.current.to_i * 1000,
147
142
  "blocks" => blocks,
148
143
  "version" => "2.28.2"
149
144
  }
150
- rescue Nokogiri::SyntaxError => e
151
- raise ConversionError, "Invalid HTML syntax: #{e.message}"
152
145
  rescue => e
153
- raise ConversionError, "Conversion failed: #{e.message}"
146
+ Rails.logger.error "HTML to EditorJS conversion failed: #{e.message}"
147
+ Rails.logger.error e.backtrace.join("\n")
148
+ raise ConversionError, "Failed to convert HTML to EditorJS format: #{e.message}"
154
149
  end
155
150
  end
156
151
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http"
4
+ require "down"
5
+
6
+ module Panda
7
+ module Social
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
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,6 @@
1
+ <%= render "shared/header" %>
2
+ <h1><%= @page.title %></h1>
3
+ <h2>Different Page Layout</h2>
4
+
5
+ <%= yield %>
6
+ <%= render "shared/footer" %>
@@ -0,0 +1,37 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
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 %>
9
+ </head>
10
+ <body>
11
+ <h1><%= @page.title %></h1>
12
+ <h2>Homepage Layout</h2>
13
+
14
+ <div class="prose">
15
+ <p>On this page we expect:</p>
16
+ <ul>
17
+ <li>Header</li>
18
+ <li>Footer</li>
19
+ <li>Some content</li>
20
+ <li>Tailwind-styled content</li>
21
+ <li>JS injected content through vanilla JS:
22
+ <div class="mt-0 font-bold" id="vanilla-injected-content">
23
+ Hello, Stimulus!
24
+ </div>
25
+ </li>
26
+ <li>JS injected content through <code>hello-controller</code>:
27
+ <div class="mt-0 font-bold" data-controller="hello" id="stimulus-injected-content">
28
+ Hello, Stimulus!
29
+ </div>
30
+ </li>
31
+ </ul>
32
+ </div>
33
+
34
+ <%= render Panda::CMS::RichTextComponent.new(key: :hero_content) %>
35
+ <%= yield %>
36
+ </body>
37
+ </html>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
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 %>
9
+ </head>
10
+ <body>
11
+ <h1><%= @page.title %></h1>
12
+ <h2>Basic Page Layout</h2>
13
+ <%= render Panda::CMS::TextComponent.new(key: :plain_text) %>
14
+ <%= render Panda::CMS::CodeComponent.new(key: :html_code) %>
15
+ <%= render Panda::CMS::RichTextComponent.new(key: :main_content) %>
16
+ <%= yield %>
17
+ </body>
18
+ </html>
@@ -9,6 +9,7 @@
9
9
  <section id="panda-main" class="flex flex-row h-full">
10
10
  <div class="flex-1 h-full" id="panda-cms-primary-content">
11
11
  <%= render "panda/cms/admin/shared/breadcrumbs" %>
12
+ <%= render "panda/cms/admin/shared/flash" %>
12
13
  <%= yield %>
13
14
  </div>
14
15
  <% if content_for :sidebar %>