panda_cms 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +71 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/builds/panda_cms.css +1 -0
  5. data/app/assets/config/panda_cms_manifest.js +1 -0
  6. data/app/assets/stylesheets/panda_cms/application.tailwind.css +61 -0
  7. data/app/builders/panda_cms/form_builder.rb +118 -0
  8. data/app/components/panda_cms/admin/button_component.rb +65 -0
  9. data/app/components/panda_cms/admin/container_component.html.erb +13 -0
  10. data/app/components/panda_cms/admin/container_component.rb +11 -0
  11. data/app/components/panda_cms/admin/flash_message_component.html.erb +30 -0
  12. data/app/components/panda_cms/admin/flash_message_component.rb +44 -0
  13. data/app/components/panda_cms/admin/heading_component.rb +43 -0
  14. data/app/components/panda_cms/admin/panel_component.html.erb +7 -0
  15. data/app/components/panda_cms/admin/panel_component.rb +11 -0
  16. data/app/components/panda_cms/admin/slideover_component.html.erb +9 -0
  17. data/app/components/panda_cms/admin/slideover_component.rb +13 -0
  18. data/app/components/panda_cms/admin/statistics_component.html.erb +4 -0
  19. data/app/components/panda_cms/admin/statistics_component.rb +15 -0
  20. data/app/components/panda_cms/admin/tab_bar_component.html.erb +35 -0
  21. data/app/components/panda_cms/admin/tab_bar_component.rb +13 -0
  22. data/app/components/panda_cms/admin/table_component.html.erb +21 -0
  23. data/app/components/panda_cms/admin/table_component.rb +43 -0
  24. data/app/components/panda_cms/admin/tag_component.rb +33 -0
  25. data/app/components/panda_cms/admin/user_activity_component.html.erb +5 -0
  26. data/app/components/panda_cms/admin/user_activity_component.rb +19 -0
  27. data/app/components/panda_cms/admin/user_display_component.html.erb +11 -0
  28. data/app/components/panda_cms/admin/user_display_component.rb +19 -0
  29. data/app/components/panda_cms/grid_component.html.erb +6 -0
  30. data/app/components/panda_cms/grid_component.rb +13 -0
  31. data/app/components/panda_cms/menu_component.html.erb +3 -0
  32. data/app/components/panda_cms/menu_component.rb +18 -0
  33. data/app/components/panda_cms/page_menu_component.html.erb +24 -0
  34. data/app/components/panda_cms/page_menu_component.rb +24 -0
  35. data/app/components/panda_cms/rich_text_component.html.erb +40 -0
  36. data/app/components/panda_cms/rich_text_component.rb +35 -0
  37. data/app/components/panda_cms/text_component.rb +67 -0
  38. data/app/constraints/panda_cms/admin_constraint.rb +16 -0
  39. data/app/controllers/panda_cms/admin/block_contents_controller.rb +44 -0
  40. data/app/controllers/panda_cms/admin/dashboard_controller.rb +31 -0
  41. data/app/controllers/panda_cms/admin/files_controller.rb +19 -0
  42. data/app/controllers/panda_cms/admin/forms_controller.rb +51 -0
  43. data/app/controllers/panda_cms/admin/menus_controller.rb +81 -0
  44. data/app/controllers/panda_cms/admin/pages_controller.rb +88 -0
  45. data/app/controllers/panda_cms/admin/posts_controller.rb +34 -0
  46. data/app/controllers/panda_cms/admin/sessions_controller.rb +83 -0
  47. data/app/controllers/panda_cms/admin/settings/bulk_editor_controller.rb +35 -0
  48. data/app/controllers/panda_cms/admin/settings_controller.rb +18 -0
  49. data/app/controllers/panda_cms/application_controller.rb +55 -0
  50. data/app/controllers/panda_cms/errors_controller.rb +31 -0
  51. data/app/controllers/panda_cms/form_submissions_controller.rb +21 -0
  52. data/app/controllers/panda_cms/pages_controller.rb +56 -0
  53. data/app/controllers/panda_cms/posts_controller.rb +17 -0
  54. data/app/helpers/panda_cms/admin/files_helper.rb +4 -0
  55. data/app/helpers/panda_cms/admin/pages_helper.rb +4 -0
  56. data/app/helpers/panda_cms/application_helper.rb +96 -0
  57. data/app/helpers/panda_cms/pages_helper.rb +4 -0
  58. data/app/helpers/panda_cms/theme_helper.rb +16 -0
  59. data/app/javascript/base.js +37 -0
  60. data/app/javascript/controllers/menu_controller.js +19 -0
  61. data/app/javascript/controllers/text_controller.js +78 -0
  62. data/app/javascript/controllers/text_field_update_controller.js +23 -0
  63. data/app/javascript/vendor/stimulus-components-rails-nested-form.js +2 -0
  64. data/app/javascript/vendor/tailwindcss-stimulus-components.js +2 -0
  65. data/app/jobs/panda_cms/application_job.rb +4 -0
  66. data/app/jobs/panda_cms/record_visit_job.rb +29 -0
  67. data/app/lib/panda_cms/bulk_editor.rb +169 -0
  68. data/app/lib/panda_cms/demo_site_generator.rb +70 -0
  69. data/app/lib/panda_cms/slug.rb +22 -0
  70. data/app/mailers/panda_cms/application_mailer.rb +6 -0
  71. data/app/mailers/panda_cms/form_mailer.rb +19 -0
  72. data/app/models/panda_cms/application_record.rb +5 -0
  73. data/app/models/panda_cms/block.rb +32 -0
  74. data/app/models/panda_cms/block_content.rb +16 -0
  75. data/app/models/panda_cms/block_content_version.rb +6 -0
  76. data/app/models/panda_cms/breadcrumb.rb +10 -0
  77. data/app/models/panda_cms/current.rb +15 -0
  78. data/app/models/panda_cms/form.rb +7 -0
  79. data/app/models/panda_cms/form_submission.rb +5 -0
  80. data/app/models/panda_cms/menu.rb +50 -0
  81. data/app/models/panda_cms/menu_item.rb +56 -0
  82. data/app/models/panda_cms/page.rb +81 -0
  83. data/app/models/panda_cms/page_version.rb +6 -0
  84. data/app/models/panda_cms/post.rb +25 -0
  85. data/app/models/panda_cms/post_version.rb +6 -0
  86. data/app/models/panda_cms/redirect.rb +9 -0
  87. data/app/models/panda_cms/template.rb +117 -0
  88. data/app/models/panda_cms/template_version.rb +6 -0
  89. data/app/models/panda_cms/user.rb +15 -0
  90. data/app/models/panda_cms/version.rb +6 -0
  91. data/app/models/panda_cms/visit.rb +7 -0
  92. data/app/views/layouts/panda_cms/application.html.erb +44 -0
  93. data/app/views/layouts/panda_cms/public.html.erb +3 -0
  94. data/app/views/panda_cms/admin/dashboard/show.html.erb +11 -0
  95. data/app/views/panda_cms/admin/files/index.html.erb +124 -0
  96. data/app/views/panda_cms/admin/files/show.html.erb +2 -0
  97. data/app/views/panda_cms/admin/forms/edit.html.erb +0 -0
  98. data/app/views/panda_cms/admin/forms/index.html.erb +13 -0
  99. data/app/views/panda_cms/admin/forms/new.html.erb +16 -0
  100. data/app/views/panda_cms/admin/forms/show.html.erb +35 -0
  101. data/app/views/panda_cms/admin/menus/_form.html.erb +21 -0
  102. data/app/views/panda_cms/admin/menus/_menu_item_fields.html.erb +7 -0
  103. data/app/views/panda_cms/admin/menus/edit.html.erb +58 -0
  104. data/app/views/panda_cms/admin/menus/index.html.erb +10 -0
  105. data/app/views/panda_cms/admin/menus/new.html.erb +5 -0
  106. data/app/views/panda_cms/admin/pages/edit.html.erb +26 -0
  107. data/app/views/panda_cms/admin/pages/index.html.erb +16 -0
  108. data/app/views/panda_cms/admin/pages/new.html.erb +16 -0
  109. data/app/views/panda_cms/admin/pages/show.html.erb +1 -0
  110. data/app/views/panda_cms/admin/posts/index.html.erb +16 -0
  111. data/app/views/panda_cms/admin/sessions/new.html.erb +18 -0
  112. data/app/views/panda_cms/admin/settings/bulk_editor/new.html.erb +68 -0
  113. data/app/views/panda_cms/admin/settings/index.html.erb +19 -0
  114. data/app/views/panda_cms/admin/shared/_breadcrumbs.html.erb +28 -0
  115. data/app/views/panda_cms/admin/shared/_flash.html.erb +5 -0
  116. data/app/views/panda_cms/admin/shared/_sidebar.html.erb +45 -0
  117. data/app/views/panda_cms/form_mailer/notification_email.html.erb +11 -0
  118. data/app/views/panda_cms/shared/_favicons.html.erb +9 -0
  119. data/app/views/panda_cms/shared/_footer.html.erb +2 -0
  120. data/app/views/panda_cms/shared/_header.html.erb +15 -0
  121. data/config/importmap.rb +9 -0
  122. data/config/initializers/panda_cms/form_errors.rb +38 -0
  123. data/config/initializers/panda_cms/healthcheck_log_silencer.rb +11 -0
  124. data/config/initializers/panda_cms.rb +52 -0
  125. data/config/locales/en.yml +29 -0
  126. data/config/routes.rb +43 -0
  127. data/config/tailwind.config.js +35 -0
  128. data/config/tailwind.embed.config.js +20 -0
  129. data/db/migrate/20240205223709_create_panda_cms_pages.rb +9 -0
  130. data/db/migrate/20240219213327_create_panda_cms_page_versions.rb +14 -0
  131. data/db/migrate/20240303002805_create_panda_cms_templates.rb +11 -0
  132. data/db/migrate/20240303003434_create_panda_cms_template_versions.rb +14 -0
  133. data/db/migrate/20240303022441_create_panda_cms_blocks.rb +13 -0
  134. data/db/migrate/20240303024256_create_panda_cms_block_contents.rb +10 -0
  135. data/db/migrate/20240303024746_create_panda_cms_block_content_versions.rb +14 -0
  136. data/db/migrate/20240303233238_add_panda_cms_menu_table.rb +10 -0
  137. data/db/migrate/20240303234724_add_panda_cms_menu_item_table.rb +12 -0
  138. data/db/migrate/20240304134343_add_parent_id_to_panda_cms_pages.rb +5 -0
  139. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +16 -0
  140. data/db/migrate/20240316212822_add_kind_to_panda_cms_menus.rb +6 -0
  141. data/db/migrate/20240316221425_add_start_page_to_panda_cms_menus.rb +5 -0
  142. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +24 -0
  143. data/db/migrate/20240317010532_create_panda_cms_users.rb +12 -0
  144. data/db/migrate/20240317161534_add_max_uses_to_panda_cms_template.rb +7 -0
  145. data/db/migrate/20240317163053_reset_counter_cache_on_panda_cms_template.rb +5 -0
  146. data/db/migrate/20240317214827_create_panda_cms_redirects.rb +14 -0
  147. data/db/migrate/20240317230622_create_panda_cms_visits.rb +13 -0
  148. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +58 -0
  149. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +5 -0
  150. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +22 -0
  151. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +28 -0
  152. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +8 -0
  153. data/db/migrate/20240804110225_add_status_to_panda_cms_pages.rb +7 -0
  154. data/db/migrate/20240804235210_create_panda_cms_forms.rb +11 -0
  155. data/db/migrate/20240805013612_create_panda_cms_form_submissions.rb +9 -0
  156. data/db/migrate/20240805121123_create_panda_cms_posts.rb +27 -0
  157. data/db/migrate/20240805123104_create_panda_cms_post_versions.rb +14 -0
  158. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +13 -0
  159. data/db/migrate/20240806204412_add_completion_path_to_panda_cms_forms.rb +5 -0
  160. data/db/migrate/20240820081917_change_form_submissions_to_submission_count.rb +5 -0
  161. data/db/seeds.rb +4 -0
  162. data/lib/generators/panda_cms/install_generator.rb +24 -0
  163. data/lib/panda_cms/engine.rb +167 -0
  164. data/lib/panda_cms/exceptions_app.rb +24 -0
  165. data/lib/panda_cms/version.rb +3 -0
  166. data/lib/panda_cms.rb +15 -0
  167. data/lib/tasks/panda_cms.rake +92 -0
  168. data/lib/templates/erb/scaffold/_form.html.erb.tt +43 -0
  169. data/lib/templates/erb/scaffold/edit.html.erb.tt +8 -0
  170. data/lib/templates/erb/scaffold/index.html.erb.tt +14 -0
  171. data/lib/templates/erb/scaffold/new.html.erb.tt +7 -0
  172. data/lib/templates/erb/scaffold/partial.html.erb.tt +22 -0
  173. data/lib/templates/erb/scaffold/show.html.erb.tt +15 -0
  174. data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
  175. data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
  176. data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
  177. data/public/panda-cms-assets/favicons/browserconfig.xml +9 -0
  178. data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
  179. data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
  180. data/public/panda-cms-assets/favicons/favicon.ico +0 -0
  181. data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
  182. data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +61 -0
  183. data/public/panda-cms-assets/favicons/site.webmanifest +14 -0
  184. data/public/panda-cms-assets/javascripts/base.js +37 -0
  185. data/public/panda-cms-assets/javascripts/controllers/menu_controller.js +19 -0
  186. data/public/panda-cms-assets/javascripts/controllers/text_field_update_controller.js +23 -0
  187. data/public/panda-cms-assets/javascripts/embed/editable.js +308 -0
  188. data/public/panda-cms-assets/javascripts/vendor/stimulus-components-rails-nested-form.js +2 -0
  189. data/public/panda-cms-assets/javascripts/vendor/stimulus-loading.js +113 -0
  190. data/public/panda-cms-assets/javascripts/vendor/tailwindcss-stimulus-components.js +2 -0
  191. data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
  192. data/public/panda-cms-assets/panda-nav.png +0 -0
  193. metadata +1034 -0
@@ -0,0 +1,56 @@
1
+ require "awesome_nested_set"
2
+
3
+ module PandaCms
4
+ class MenuItem < ApplicationRecord
5
+ acts_as_nested_set scope: [:panda_cms_menu_id], counter_cache: :children_count
6
+
7
+ self.implicit_order_column = "lft"
8
+ self.table_name = "panda_cms_menu_items"
9
+
10
+ belongs_to :menu, foreign_key: :panda_cms_menu_id, class_name: "PandaCms::Menu", inverse_of: :menu_items, touch: true
11
+ belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "PandaCms::Page", inverse_of: :menu_items, optional: true
12
+
13
+ validates :text, presence: true, uniqueness: {scope: :panda_cms_menu_id, case_sensitive: false}
14
+ validates :page, presence: true, unless: -> { external_url.present? }
15
+ validates :external_url, presence: true, unless: -> { page.present? }
16
+
17
+ validate :validate_is_actual_link
18
+
19
+ #
20
+ # Returns the resolved link for the menu item.
21
+ #
22
+ # If the menu item is associated with a page, it returns the path of the page.
23
+ # If the menu item is associated with an external URL, it returns the external URL.
24
+ #
25
+ # @return [String] Resolved link
26
+ # @visibility public
27
+ def resolved_link
28
+ if page
29
+ page.path
30
+ elsif external_url
31
+ external_url
32
+ else
33
+ ""
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ #
40
+ # Validate that the link is an actual link or a page
41
+ #
42
+ # @return nil
43
+ # @visibility private
44
+ def validate_is_actual_link
45
+ if page.nil? && external_url.nil?
46
+ errors.add(:page, "must be a valid page or external link, neither are set")
47
+ errors.add(:external_url, "must be a valid page or external link, neither are set")
48
+ end
49
+
50
+ if !page.nil? && !external_url.nil?
51
+ errors.add(:page, "must be a valid page or external link, both are set")
52
+ errors.add(:external_url, "must be a valid page or external link, both are set")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,81 @@
1
+ require "awesome_nested_set"
2
+
3
+ module PandaCms
4
+ class Page < ApplicationRecord
5
+ acts_as_nested_set counter_cache: :children_count
6
+ self.table_name = "panda_cms_pages"
7
+ self.implicit_order_column = "lft"
8
+
9
+ has_paper_trail versions: {
10
+ class_name: "PandaCms::PageVersion"
11
+ }
12
+
13
+ after_save :after_save
14
+
15
+ belongs_to :template, foreign_key: :panda_cms_template_id, class_name: "PandaCms::Template", inverse_of: :pages, optional: false, counter_cache: :pages_count
16
+ has_many :blocks, through: :template
17
+ has_many :block_contents, foreign_key: :panda_cms_page_id, class_name: "PandaCms::BlockContent", inverse_of: :page
18
+ has_many :menu_items, foreign_key: :panda_cms_page_id, class_name: "PandaCms::MenuItem", inverse_of: :page
19
+ has_many :menus, through: :menu_items
20
+ has_many :menus_of_parent, through: :parent, source: :menus
21
+ has_one :page_menu, foreign_key: :panda_cms_menu_id, class_name: "PandaCms::Menu", inverse_of: :start_page
22
+
23
+ validates :title, presence: true
24
+ validates :path,
25
+ presence: true,
26
+ uniqueness: true,
27
+ format: {with: /\A\/.*\z/, message: "must start with a forward slash"}
28
+ validates :parent, presence: true, unless: -> { path == "/" }
29
+ validates :panda_cms_template_id, presence: true
30
+
31
+ scope :ordered, -> { order(:lft) }
32
+
33
+ private
34
+
35
+ #
36
+ # After save callbacks
37
+ #
38
+ # @return nil
39
+ # @visibility private
40
+ #
41
+ def after_save
42
+ generate_content_blocks
43
+ update_existing_menu_items
44
+ update_auto_menus
45
+ end
46
+
47
+ def generate_content_blocks
48
+ template_block_ids = template.blocks.ids
49
+ page_existing_block_ids = block_contents.map { |bc| bc.block.id }
50
+ required_block_ids = template_block_ids - page_existing_block_ids
51
+
52
+ if required_block_ids.count > 0
53
+ required_block_ids.each do |block_id|
54
+ PandaCms::BlockContent.find_or_create_by!(page: self, panda_cms_block_id: block_id, content: "")
55
+ end
56
+ end
57
+ end
58
+
59
+ #
60
+ # Update text of existing menu items if the title differs
61
+ #
62
+ # @return nil
63
+ # @todo Only run this if the page title has changed
64
+ # @visibility private
65
+ #
66
+ def update_existing_menu_items
67
+ menu_items.where.not(text: title).update_all(text: title)
68
+ end
69
+
70
+ #
71
+ # Update any menus which include this page or its parent as a menu item
72
+ #
73
+ # @return nil
74
+ # @visibility private
75
+ #
76
+ def update_auto_menus
77
+ menus.find_each(&:generate_auto_menu_items)
78
+ menus_of_parent.find_each(&:generate_auto_menu_items)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,6 @@
1
+ module PandaCms
2
+ class PageVersion < Version
3
+ self.table_name = :panda_cms_page_versions
4
+ self.sequence_name = :panda_cms_page_versions_id_seq
5
+ end
6
+ end
@@ -0,0 +1,25 @@
1
+ require "awesome_nested_set"
2
+
3
+ module PandaCms
4
+ class Post < ApplicationRecord
5
+ self.table_name = "panda_cms_posts"
6
+
7
+ has_paper_trail versions: {
8
+ class_name: "PandaCms::PostVersion"
9
+ }
10
+
11
+ belongs_to :user, class_name: "PandaCms::User"
12
+
13
+ validates :title, presence: true
14
+
15
+ scope :ordered, -> { order(published_at: :desc) }
16
+
17
+ def excerpt(length = 100)
18
+ content.gsub(/<[^>]*>/, "").truncate(length)
19
+ end
20
+
21
+ def path
22
+ PandaCms.posts[:prefix] + "/" + slug.to_s
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ module PandaCms
2
+ class PostVersion < Version
3
+ self.table_name = :panda_cms_post_versions
4
+ self.sequence_name = :panda_cms_post_versions_id_seq
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module PandaCms
2
+ class Redirect < ApplicationRecord
3
+ belongs_to :origin_page, class_name: "PandaCms::Page", foreign_key: :origin_panda_cms_page_id
4
+ belongs_to :destination_page, class_name: "PandaCms::Page", foreign_key: :destination_panda_cms_page_id
5
+
6
+ validates :status_code, presence: true
7
+ validates :visits, presence: true
8
+ end
9
+ end
@@ -0,0 +1,117 @@
1
+ module PandaCms
2
+ # Represents a template in the Panda CMS application.
3
+ class Template < ApplicationRecord
4
+ self.table_name = "panda_cms_templates"
5
+
6
+ # Enables versioning for the Template model using the `has_paper_trail` gem.
7
+ has_paper_trail versions: {
8
+ class_name: "PandaCms::TemplateVersion"
9
+ }
10
+
11
+ # Associations
12
+ has_many :pages, class_name: "PandaCms::Page", dependent: :restrict_with_error, inverse_of: :template, foreign_key: :panda_cms_template_id
13
+ has_many :blocks, class_name: "PandaCms::Block", dependent: :restrict_with_error, inverse_of: :template, foreign_key: :panda_cms_template_id
14
+ has_many :block_contents, through: :blocks
15
+
16
+ # Validations
17
+ validates :name, presence: true, uniqueness: true
18
+
19
+ validates :file_path,
20
+ presence: true,
21
+ uniqueness: true,
22
+ format: {with: /\Alayouts\/.*\z/, message: "must be a valid layout file path"}
23
+
24
+ validate :validate_template_file_exists
25
+
26
+ # Scopes
27
+ scope :ordered, -> { order(:sort_order) }
28
+ scope :available, -> { where("max_uses IS NULL OR (pages_count < max_uses)") }
29
+
30
+ private
31
+
32
+ # Custom validation method to check if the file_path is a valid layout file path
33
+ # NB: Currently only supports .html.erb templates, may want to expand in future?
34
+ # @return [void]
35
+ def validate_template_file_exists
36
+ # Remove any directory traversal attempts from the file_path
37
+ safe_file_path = file_path.to_s.gsub("../", "")
38
+ # Check if the file_path is an ERB template that exists in app/views
39
+ template_path = Rails.root.join("app", "views", "#{safe_file_path}.html.erb")
40
+ # NB: file? checks for files and excludes directories (unlike exist?)
41
+ errors.add(:file_path, "must be an existing layout file path") unless File.file?(template_path)
42
+ end
43
+
44
+ # Import templates from the filesystem into the database
45
+ # @return [void]
46
+ def self.load_from_filesystem
47
+ Rails.root.glob("app/views/layouts/**/*.html.erb").each do |file|
48
+ # Extract the file path from the Rails root
49
+ file_path = file.to_s.sub("#{Rails.root}/app/views/", "").sub(".html.erb", "")
50
+
51
+ next if file_path == "layouts/application" || file_path == "layouts/mailer"
52
+
53
+ # Find or create the template based on the file path
54
+ find_or_create_by(file_path: file_path) do |t|
55
+ t.name = file_path.sub("layouts/", "").titleize
56
+ end
57
+ end
58
+ end
59
+
60
+ private_class_method :load_from_filesystem
61
+
62
+ # Generate missing blocks for all templates
63
+ # @return [void]
64
+ def self.generate_missing_blocks
65
+ # Loop through all templates in app/views/layouts/*.html.erb
66
+ Dir.glob("app/views/layouts/*.html.erb").each do |file|
67
+ # TODO: Delete all blocks which aren't in use by a template?
68
+
69
+ File.open(file).each_line do |line|
70
+ # Matches:
71
+ # PandaCms::RichTextComponent.new(key: :value)
72
+ # PandaCms::RichTextComponent.new key: :value, key: value
73
+ line.match(/PandaCms::([a-zA-Z]+)Component\.new[ \(]+([^\)]+)[\)]*/) do |match|
74
+ puts "- #{match[0]}"
75
+ # Extract the hash values
76
+ template_path = file.gsub("app/views/", "").gsub(".html.erb", "")
77
+ template_name = template_path.gsub("layouts/", "").titleize
78
+
79
+ # Create the template if it doesn't exist
80
+ template = PandaCms::Template.find_or_create_by!(file_path: template_path) do |template|
81
+ template.name = template_name
82
+ end
83
+
84
+ # Previously used match[1].underscore but this supports more complex database
85
+ # operations, and is more secure as it'll force the usage of a class
86
+ block_kind = "PandaCms::#{match[1]}Component".constantize::KIND
87
+
88
+ match[2].split(",").map do |keyvar|
89
+ key, value = keyvar.split(":", 2)
90
+ next if key != "key"
91
+
92
+ block_name = value.to_s.strip.tr(":", "")
93
+ # Create the block if it doesn't exist
94
+ # TODO: +/- the output if it's created or removed
95
+ begin
96
+ block = PandaCms::Block.find_or_create_by!(template: template, kind: block_kind, key: block_name) do |block|
97
+ block.name = block_name.titleize
98
+ end
99
+ rescue ActiveRecord::RecordInvalid => e
100
+ raise "Error creating block '#{block_name}' on template '#{template_name}': #{e.message}"
101
+ end
102
+
103
+ # For the given block, create the block_content for each page using the template
104
+ template.pages.each do |page|
105
+ PandaCms::BlockContent.find_or_create_by!(block: block, page: page) do |block_content|
106
+ block_content.content = ""
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ private_class_method :generate_missing_blocks
116
+ end
117
+ end
@@ -0,0 +1,6 @@
1
+ module PandaCms
2
+ class TemplateVersion < Version
3
+ self.table_name = :panda_cms_template_versions
4
+ self.sequence_name = :panda_cms_template_versions_id_seq
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ module PandaCms
2
+ class User < ApplicationRecord
3
+ validates :firstname, presence: true
4
+ validates :lastname, presence: true
5
+ validates :email, presence: true, uniqueness: {case_sensitive: true}
6
+ end
7
+
8
+ def is_admin?
9
+ admin
10
+ end
11
+
12
+ def name
13
+ "#{firstname} #{lastname}"
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module PandaCms
2
+ class Version < ApplicationRecord
3
+ include PaperTrail::VersionConcern
4
+ self.abstract_class = true
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module PandaCms
2
+ class Visit < ApplicationRecord
3
+ belongs_to :page, class_name: "PandaCms::Page", foreign_key: :page_id, optional: true
4
+ belongs_to :user, class_name: "PandaCms::User", foreign_key: :user_id, optional: true
5
+ belongs_to :redirect, class_name: "PandaCms::Redirect", foreign_key: :redirect_id, optional: true
6
+ end
7
+ end
@@ -0,0 +1,44 @@
1
+ <%= render "panda_cms/shared/header", html_class: "h-full bg-white" %>
2
+ <div class="flex h-full" id="panda-container">
3
+ <div class="absolute top-0 w-full lg:flex lg:fixed lg:inset-y-0 lg:z-50 lg:flex-col lg:w-72">
4
+ <div class="flex overflow-y-auto flex-col gap-y-5 px-4 pb-4 max-h-16 bg-gradient-to-br lg:max-h-full grow from-dark to-mid" data-transition-enter="transition-all ease-in-out duration-300" data-transition-enter-from="m-h-16" data-transition-enter-to="max-h-full" data-transition-leave="transition-all ease-in-out duration-300" data-transition-leave-from="max-h-full" data-transition-leave-to="max-h-16">
5
+ <%= render partial: "panda_cms/admin/shared/sidebar" %>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container" <% if content_for :sidebar %> data-controller="toggle" data-action="keydown.esc->modal#close" tabindex="-1"<% end %>>
10
+ <section id="panda-main" class="flex flex-row h-full">
11
+ <div class="flex-1 h-full" id="panda-cms-primary-content">
12
+ <%= render "panda_cms/admin/shared/breadcrumbs" %>
13
+ <%= yield %>
14
+ </div>
15
+
16
+ <% if content_for :sidebar %>
17
+ <div data-toggle-target="toggleable" class="flex hidden absolute right-0 flex-col h-full bg-white divide-y divide-gray-200 shadow-xl basis-3/12"
18
+ data-transition-enter="transform transition ease-in-out duration-500"
19
+ data-transition-enter-from="translate-x-full"
20
+ data-transition-enter-to="translate-x-0"
21
+ data-transition-leave="transform transition ease-in-out duration-500"
22
+ data-transition-leave-from="translate-x-full"
23
+ data-transition-leave-to="translate-x-0"
24
+ id="slideover">
25
+ <div class="overflow-y-auto flex-1 h-0">
26
+ <div class="py-3 px-4 mb-4 bg-black">
27
+ <div class="flex justify-between items-center">
28
+ <h2 class="text-base font-semibold leading-6 text-white" id="slideover-title"><i class="mr-2 fa-light fa-gear"></i> <%= yield :sidebar_title %> </h2>
29
+ <a href="#" data-action="click->toggle#toggle touch->toggle#toggle"><i class="font-bold text-white fa-regular fa-xmark right"></i></a>
30
+ </div>
31
+ </div>
32
+ <div class="flex flex-col flex-1 justify-between">
33
+ <div class="px-4 space-y-6">
34
+ <%= yield :sidebar %>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <% end %>
40
+ </section>
41
+
42
+ </div>
43
+ </div>
44
+ <%= render "panda_cms/shared/footer" %>
@@ -0,0 +1,3 @@
1
+ <%= render "panda_cms/shared/header", html_class: "h-full bg-gradient-to-br from-dark to-mid" %>
2
+ <%= yield %>
3
+ <%= render "panda_cms/shared/footer" %>
@@ -0,0 +1,11 @@
1
+ <%= render PandaCms::Admin::ContainerComponent.new do |container| %>
2
+ <% container.with_heading(text: "Dashboard", level: 1) do |heading| %>
3
+ <% heading.with_button(action: :add, text: "Add Page", link: new_admin_page_path) %>
4
+ <% end %>
5
+
6
+ <dl class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
7
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Today", value: PandaCms::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
8
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Week", value: PandaCms::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
9
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Month", value: PandaCms::Visit.group_by_month(:visited_at, last: 1).count.values.first) %>
10
+ </dl>
11
+ <% end %>
@@ -0,0 +1,124 @@
1
+ <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading(text: "Files", level: 1) %>
3
+
4
+ <% component.with_slideover(title: "File Details") do %>
5
+ <div class="pt-4 pb-16 space-y-6">
6
+ <div>
7
+ <div class="block overflow-hidden w-full rounded-lg aspect-h-7 aspect-w-10">
8
+ <img src="https://images.unsplash.com/photo-1582053433976-25c00369fc93?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=512&q=80" alt="" class="object-cover">
9
+ </div>
10
+ <div class="flex justify-between items-start mt-4">
11
+ <div>
12
+ <h2 class="text-lg font-medium text-gray-900"><span class="sr-only">Details for </span>IMG_4985.HEIC</h2>
13
+ <p class="text-sm font-medium text-gray-500">3.9 MB</p>
14
+ </div>
15
+ <button type="button" class="flex relative justify-center items-center ml-4 w-8 h-8 text-gray-400 bg-white rounded-full hover:text-gray-500 hover:bg-gray-100 focus:ring-2 focus:outline-none focus:ring-panda-dark">
16
+ <span class="absolute -inset-1.5"></span>
17
+ <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
18
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
19
+ </svg>
20
+ <span class="sr-only">Favorite</span>
21
+ </button>
22
+ </div>
23
+ </div>
24
+ <div>
25
+ <h3 class="font-medium text-gray-900">Information</h3>
26
+ <dl class="mt-2 border-t border-b border-gray-200 divide-y divide-gray-200">
27
+ <div class="flex justify-between py-3 text-sm font-medium">
28
+ <dt class="text-gray-500">Uploaded by</dt>
29
+ <dd class="text-gray-900 whitespace-nowrap">Marie Culver</dd>
30
+ </div>
31
+ <div class="flex justify-between py-3 text-sm font-medium">
32
+ <dt class="text-gray-500">Created</dt>
33
+ <dd class="text-gray-900 whitespace-nowrap">June 8, 2020</dd>
34
+ </div>
35
+ <div class="flex justify-between py-3 text-sm font-medium">
36
+ <dt class="text-gray-500">Last modified</dt>
37
+ <dd class="text-gray-900 whitespace-nowrap">June 8, 2020</dd>
38
+ </div>
39
+ <div class="flex justify-between py-3 text-sm font-medium">
40
+ <dt class="text-gray-500">Dimensions</dt>
41
+ <dd class="text-gray-900 whitespace-nowrap">4032 x 3024</dd>
42
+ </div>
43
+ <div class="flex justify-between py-3 text-sm font-medium">
44
+ <dt class="text-gray-500">Resolution</dt>
45
+ <dd class="text-gray-900 whitespace-nowrap">72 x 72</dd>
46
+ </div>
47
+ </dl>
48
+ </div>
49
+ <div>
50
+ <h3 class="font-medium text-gray-900">Description</h3>
51
+ <div class="flex justify-between items-center mt-2">
52
+ <p class="text-sm italic text-gray-500">Add a description to this image.</p>
53
+ <button type="button" class="flex relative justify-center items-center w-8 h-8 text-gray-400 bg-white rounded-full hover:text-gray-500 hover:bg-gray-100 focus:ring-2 focus:outline-none focus:ring-panda-dark">
54
+ <span class="absolute -inset-1.5"></span>
55
+ <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
56
+ <path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
57
+ </svg>
58
+ <span class="sr-only">Add description</span>
59
+ </button>
60
+ </div>
61
+ </div>
62
+ <div>
63
+ <h3 class="font-medium text-gray-900">Shared with</h3>
64
+ <ul role="list" class="mt-2 border-t border-b border-gray-200 divide-y divide-gray-200">
65
+ <li class="flex justify-between items-center py-3">
66
+ <div class="flex items-center">
67
+ <img src="https://images.unsplash.com/photo-1502685104226-ee32379fefbe?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=3&w=1024&h=1024&q=80" alt="" class="w-8 h-8 rounded-full">
68
+ <p class="ml-4 text-sm font-medium text-gray-900">Aimee Douglas</p>
69
+ </div>
70
+ <button type="button" class="ml-6 text-sm font-medium bg-white rounded-md focus:ring-2 focus:ring-offset-2 focus:outline-none text-panda-dark hover:text-panda-dark focus:ring-panda-dark">Remove<span class="sr-only"> Aimee Douglas</span></button>
71
+ </li>
72
+ <li class="flex justify-between items-center py-3">
73
+ <div class="flex items-center">
74
+ <img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixqx=oilqXxSqey&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="" class="w-8 h-8 rounded-full">
75
+ <p class="ml-4 text-sm font-medium text-gray-900">Andrea McMillan</p>
76
+ </div>
77
+ <button type="button" class="ml-6 text-sm font-medium bg-white rounded-md focus:ring-2 focus:ring-offset-2 focus:outline-none text-panda-dark hover:text-panda-dark focus:ring-panda-dark">Remove<span class="sr-only"> Andrea McMillan</span></button>
78
+ </li>
79
+
80
+ <li class="flex justify-between items-center py-2">
81
+ <button type="button" class="flex items-center p-1 -ml-1 bg-white rounded-md focus:ring-2 focus:outline-none group focus:ring-panda-dark">
82
+ <span class="flex justify-center items-center w-8 h-8 text-gray-400 rounded-full border-2 border-gray-300 border-dashed">
83
+ <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
84
+ <path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
85
+ </svg>
86
+ </span>
87
+ <span class="ml-4 text-sm font-medium text-panda-dark group-hover:text-panda-dark">Share</span>
88
+ </button>
89
+ </li>
90
+ </ul>
91
+ </div>
92
+ <div class="flex gap-x-3">
93
+ <button type="button" class="flex-1 py-2 px-3 text-sm font-semibold text-white bg-black rounded-md shadow-sm hover:bg-black focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-panda-dark">Download</button>
94
+ <button type="button" class="flex-1 py-2 px-3 text-sm font-semibold text-gray-900 bg-white rounded-md ring-1 ring-inset shadow-sm hover:bg-gray-50 ring-mid">Delete</button>
95
+ </div>
96
+ </div>
97
+ <% end %>
98
+
99
+ <% component.with_tab_bar do %>
100
+ <%= component.with_tab(name: "Recently Viewed", active: true) %>
101
+ <%= component.with_tab(name: "Recently Added") %>
102
+ <%= component.with_tab(name: "Favourited") %>
103
+ <% end %>
104
+
105
+ <section>
106
+ <h2 id="gallery-heading" class="sr-only">Recently viewed</h2>
107
+ <ul role="list" class="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
108
+ <li class="relative">
109
+ <!-- Current: "ring-2 ring-panda-dark ring-offset-2", Default: "focus-within:ring-2 focus-within:ring-panda-dark focus-within:ring-offset-2 focus-within:ring-offset-gray-100" -->
110
+ <div class="block overflow-hidden w-full bg-gray-100 rounded-lg ring-2 ring-offset-2 ring-panda-dark aspect-w-10 aspect-h-7 group">
111
+ <!-- Current: "", Default: "group-hover:opacity-75" -->
112
+ <img src="https://images.unsplash.com/photo-1582053433976-25c00369fc93?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=512&q=80" alt="" class="object-cover pointer-events-none">
113
+ <button type="button" class="absolute inset-0 focus:outline-none">
114
+ <span class="sr-only">View details for IMG_4985.HEIC</span>
115
+ </button>
116
+ </div>
117
+ <p class="block mt-2 text-sm font-medium text-gray-900 pointer-events-none truncate">IMG_4985.HEIC</p>
118
+ <p class="block text-sm font-medium text-gray-500 pointer-events-none">3.9 MB</p>
119
+ </li>
120
+
121
+ <!-- More files... -->
122
+ </ul>
123
+ </section>
124
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1>Admin::Files#show</h1>
2
+ <p>Find me in app/views/panda_cms/admin/files/show.html.erb</p>
File without changes
@@ -0,0 +1,13 @@
1
+ <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading(text: "Forms", level: 1) do |heading| %>
3
+ <% end %>
4
+
5
+ <%= render PandaCms::Admin::TableComponent.new(rows: forms) do |table| %>
6
+ <% table.column("Name") { |form| block_link_to form.name, admin_form_path(form) } %>
7
+ <% table.column("Status") { |form| render PandaCms::Admin::TagComponent.new(status: :active) } %>
8
+ <% table.column("Last Submission") do |form| %>
9
+ <%= "#{time_ago_in_words(form.form_submissions&.last&.created_at)} ago" if form.form_submissions&.last %>
10
+ <% end %>
11
+ <% table.column("Completion Path", &:completion_path) %>
12
+ <% end %>
13
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading(text: "Add Page", level: 1) do |heading| %>
3
+ <% end %>
4
+
5
+ <%= panda_cms_form_with model: page, url: admin_pages_path, method: :post do |f| %>
6
+ <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
7
+ <div data-controller="text-field-update">
8
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
9
+ <%= f.select :parent_id, options, {}, { "data-text-field-update-target": "input_select", "data-action": "change->text-field-update#setPrePath" } %>
10
+ <%= f.text_field :title, { data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
11
+ <%= f.text_field :path, { data: { prefix: PandaCms::Current.root, "text-field-update-target": "output_text" } } %>
12
+ <%= f.collection_select :panda_cms_template_id, PandaCms::Template.available, :id, :name %>
13
+ <%= f.button %>
14
+ </div>
15
+ <% end %>
16
+ <% end %>
@@ -0,0 +1,35 @@
1
+ <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading(text: form.name, level: 1) do |heading| %>
3
+ <% end %>
4
+
5
+ <div class="overflow-x-auto -mx-4 rounded-lg ring-1 sm:mx-0 sm:rounded-lg ring-mid">
6
+ <table class="min-w-full divide-y divide-gray-300 border-collapse table-auto">
7
+ <thead>
8
+ <tr>
9
+ <% fields.each do |field, title| %>
10
+ <th scope="col" class="py-3.5 px-3 text-sm font-semibold text-left text-gray-900"><%= title %></th>
11
+ <% end %>
12
+ <th scope="col" class="py-3.5 px-3 text-sm font-semibold text-left text-gray-900">Submitted</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody class="bg-white divide-y divide-gray-200">
16
+ <% submissions.each do |submission| %>
17
+ <tr class="relative bg-white cursor-pointer hover:bg-gray-50">
18
+ <% fields.each do |field| %>
19
+ <td class="py-5 px-3 text-sm text-gray-500">
20
+ <% if field[0] == "email" || field[0] == "email_address" %>
21
+ <a href="mailto:<%= submission.data[field[0]] %>" class="border-b border-gray-500 hover:text-gray-900"><%= submission.data[field[0]] %></a>
22
+ <% else %>
23
+ <%= simple_format(submission.data[field[0]]) %>
24
+ <% end %>
25
+ </td>
26
+ <% end %>
27
+ <td class="py-5 px-3 text-sm text-gray-500 whitespace-nowrap">
28
+ <div class="text-gray-500"><%= time_ago_in_words(submission.created_at) %> ago</div>
29
+ </td>
30
+ </tr>
31
+ <% end %>
32
+ </tbody>
33
+ </table>
34
+ </div>
35
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <%= panda_cms_form_with model: menu, url: admin_menus_path, method: :post do |form| %>
2
+ <%= form.text_field :name %>
3
+
4
+ <div data-controller="nested-form" nested_form_wrapper_selector_value=".nested-form-wrapper">
5
+ <template data-nested-form-target="template">
6
+ <%= form.fields_for :menu_items, PandaCms::MenuItem.new, child_index: 'NEW_RECORD' do |item| %>
7
+ <%= render "menu_item_fields", form: item %>
8
+ <% end %>
9
+ </template>
10
+
11
+ <%= form.fields_for :menu_items do |item| %>
12
+ <%= render "menu_item_fields", form: item %>
13
+ <% end %>
14
+
15
+ <div data-nested-form-target="target"></div>
16
+
17
+ <%= render PandaCms::Admin::ButtonComponent.new(text: "Add Menu Item", action: :add, data: {action: "nested-form#add"}, size: :small) %>
18
+ </div>
19
+
20
+ <%= form.submit "Create" %>
21
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <div class="p-4 mt-2 mb-4 rounded-md border nested-form-wrapper border-grey-900" data-new-record="<%= form.object.new_record? %>">
2
+ <%= form.text_field :text %>
3
+ <%= form.text_field :external_url %>
4
+ <%= form.collection_select :panda_cms_page_id, PandaCms::Page.all, :id, :title, include_blank: true %>
5
+ <%= render PandaCms::Admin::ButtonComponent.new(text: "Remove Menu Item", action: :delete, data: {action: "nested-form#remove"}, size: :small) %>
6
+ <%= form.hidden_field :_destroy %>
7
+ </div>