panda-cms 0.8.2 → 0.10.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +75 -5
  3. data/app/components/panda/cms/code_component.rb +154 -39
  4. data/app/components/panda/cms/grid_component.rb +26 -6
  5. data/app/components/panda/cms/menu_component.rb +72 -34
  6. data/app/components/panda/cms/page_menu_component.rb +102 -13
  7. data/app/components/panda/cms/rich_text_component.rb +229 -139
  8. data/app/components/panda/cms/text_component.rb +107 -42
  9. data/app/controllers/panda/cms/admin/base_controller.rb +19 -3
  10. data/app/controllers/panda/cms/admin/dashboard_controller.rb +3 -3
  11. data/app/controllers/panda/cms/admin/files_controller.rb +7 -0
  12. data/app/controllers/panda/cms/admin/menus_controller.rb +47 -3
  13. data/app/controllers/panda/cms/admin/pages_controller.rb +11 -2
  14. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  15. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  16. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  17. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  18. data/app/helpers/panda/cms/application_helper.rb +17 -4
  19. data/app/helpers/panda/cms/asset_helper.rb +14 -61
  20. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  21. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  22. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +5 -1
  23. data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
  24. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  25. data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
  26. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  27. data/app/javascript/panda/cms/controllers/index.js +54 -13
  28. data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
  29. data/app/javascript/panda/cms/controllers/menu_form_controller.js +53 -0
  30. data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
  31. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  32. data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
  33. data/app/javascript/panda/cms/stimulus-loading.js +6 -7
  34. data/app/models/panda/cms/block_content.rb +9 -0
  35. data/app/models/panda/cms/menu.rb +12 -0
  36. data/app/models/panda/cms/page.rb +147 -0
  37. data/app/models/panda/cms/post.rb +98 -0
  38. data/app/views/layouts/homepage.html.erb +1 -4
  39. data/app/views/layouts/page.html.erb +1 -4
  40. data/app/views/panda/cms/admin/dashboard/show.html.erb +5 -5
  41. data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
  42. data/app/views/panda/cms/admin/files/index.html.erb +11 -118
  43. data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
  44. data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
  45. data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
  46. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
  47. data/app/views/panda/cms/admin/menus/edit.html.erb +62 -0
  48. data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
  49. data/app/views/panda/cms/admin/menus/new.html.erb +38 -0
  50. data/app/views/panda/cms/admin/pages/edit.html.erb +147 -22
  51. data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
  52. data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
  53. data/app/views/panda/cms/admin/posts/_form.html.erb +44 -15
  54. data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
  55. data/app/views/panda/cms/admin/posts/index.html.erb +6 -6
  56. data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
  57. data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
  58. data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
  59. data/app/views/shared/_header.html.erb +1 -4
  60. data/config/brakeman.ignore +38 -0
  61. data/config/importmap.rb +10 -10
  62. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
  63. data/config/initializers/panda/cms.rb +52 -10
  64. data/config/locales/en.yml +41 -0
  65. data/config/routes.rb +5 -3
  66. data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
  67. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
  68. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
  69. data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
  70. data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
  71. data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
  72. data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
  73. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  74. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  75. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  76. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  77. data/lib/generators/panda/cms/install_generator.rb +2 -5
  78. data/lib/panda/cms/asset_loader.rb +46 -76
  79. data/lib/panda/cms/bulk_editor.rb +288 -12
  80. data/lib/panda/cms/debug.rb +29 -0
  81. data/lib/panda/cms/engine/asset_config.rb +49 -0
  82. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  83. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  84. data/lib/panda/cms/engine/core_config.rb +106 -0
  85. data/lib/panda/cms/engine/helper_config.rb +20 -0
  86. data/lib/panda/cms/engine/route_config.rb +34 -0
  87. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  88. data/lib/panda/cms/engine.rb +44 -162
  89. data/lib/panda/cms/features.rb +52 -0
  90. data/lib/panda/cms.rb +10 -0
  91. data/lib/panda-cms/version.rb +1 -1
  92. data/lib/panda-cms.rb +20 -7
  93. data/lib/tasks/panda_cms_tasks.rake +16 -0
  94. metadata +41 -50
  95. data/app/components/panda/cms/admin/container_component.html.erb +0 -13
  96. data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
  97. data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
  98. data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
  99. data/app/components/panda/cms/admin/slideover_component.rb +0 -15
  100. data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
  101. data/app/components/panda/cms/admin/statistics_component.rb +0 -16
  102. data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
  103. data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
  104. data/app/components/panda/cms/admin/table_component.html.erb +0 -29
  105. data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
  106. data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
  107. data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
  108. data/app/components/panda/cms/admin/user_display_component.rb +0 -21
  109. data/app/components/panda/cms/grid_component.html.erb +0 -6
  110. data/app/components/panda/cms/menu_component.html.erb +0 -6
  111. data/app/components/panda/cms/page_menu_component.html.erb +0 -21
  112. data/app/components/panda/cms/rich_text_component.html.erb +0 -90
  113. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  114. data/app/views/layouts/panda/cms/application.html.erb +0 -42
  115. data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
  116. data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
  117. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
  118. data/app/views/panda/cms/shared/_footer.html.erb +0 -2
  119. data/app/views/panda/cms/shared/_header.html.erb +0 -25
  120. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  121. data/config/initializers/inflections.rb +0 -5
  122. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
  123. data/lib/tasks/assets.rake +0 -587
@@ -1,14 +1,13 @@
1
1
  // Stimulus loading utilities for Panda CMS
2
2
  // This provides the loading functionality that would normally come from stimulus-rails
3
3
 
4
- import { Application } from "@hotwired/stimulus"
4
+ // Import the shared Stimulus application from Panda Core
5
+ // This ensures all controllers (Core and CMS) are registered in the same application
6
+ // Use the importmap module name, not an absolute path
7
+ import { application } from "panda/core/application"
5
8
 
6
- const application = Application.start()
7
-
8
- // Configure debug mode based on environment
9
- const railsEnv = document.body?.dataset?.environment || "production";
10
- application.debug = railsEnv === "development"
11
- window.Stimulus = application
9
+ // The application is already started and configured in Core
10
+ // No need to start it again or configure debug mode
12
11
 
13
12
  // Auto-registration functionality
14
13
  function eagerLoadControllersFrom(context) {
@@ -12,8 +12,17 @@ module Panda
12
12
 
13
13
  validates :block, presence: true, uniqueness: {scope: :page}
14
14
 
15
+ after_save :refresh_page_cached_timestamp
16
+ after_destroy :refresh_page_cached_timestamp
17
+
15
18
  store_accessor :content, [], prefix: true
16
19
  store_accessor :cached_content, [], prefix: true
20
+
21
+ private
22
+
23
+ def refresh_page_cached_timestamp
24
+ page&.refresh_last_updated_at!
25
+ end
17
26
  end
18
27
  end
19
28
  end
@@ -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
@@ -38,12 +38,47 @@ module Panda
38
38
  enum :status, {
39
39
  active: "active",
40
40
  draft: "draft",
41
+ pending_review: "pending_review",
41
42
  hidden: "hidden",
42
43
  archived: "archived"
43
44
  }
44
45
 
46
+ enum :page_type, {
47
+ standard: "standard",
48
+ hidden_type: "hidden",
49
+ system: "system",
50
+ posts: "posts",
51
+ code: "code"
52
+ }, prefix: :type
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
+
45
79
  # Callbacks
46
80
  after_save :handle_after_save
81
+ before_save :update_cached_last_updated_at
47
82
 
48
83
  #
49
84
  # Update any menus which include this page or its parent as a menu item
@@ -56,6 +91,110 @@ module Panda
56
91
  menus_of_parent.find_each(&:generate_auto_menu_items)
57
92
  end
58
93
 
94
+ #
95
+ # Returns the most recent update time between the page and its block contents
96
+ # Uses cached value for performance
97
+ #
98
+ # @return [Time] The most recent updated_at timestamp
99
+ # @visibility public
100
+ #
101
+ def last_updated_at
102
+ cached_last_updated_at || updated_at
103
+ end
104
+
105
+ #
106
+ # Refresh the cached last updated timestamp
107
+ # Used when block contents are updated
108
+ #
109
+ # @return [Time] The updated timestamp
110
+ # @visibility public
111
+ #
112
+ def refresh_last_updated_at!
113
+ block_content_updated_at = block_contents.maximum(:updated_at)
114
+ new_timestamp = [updated_at, block_content_updated_at].compact.max
115
+ update_column(:cached_last_updated_at, new_timestamp)
116
+ new_timestamp
117
+ end
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
+
59
198
  private
60
199
 
61
200
  def validate_unique_path_in_scope
@@ -124,6 +263,14 @@ module Panda
124
263
  destination_path: new_path
125
264
  )
126
265
  end
266
+
267
+ def update_cached_last_updated_at
268
+ # Will be set to updated_at automatically during save
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")
272
+ self.cached_last_updated_at = Time.current
273
+ end
127
274
  end
128
275
  end
129
276
  end
@@ -33,10 +33,36 @@ module Panda
33
33
  enum :status, {
34
34
  active: "active",
35
35
  draft: "draft",
36
+ pending_review: "pending_review",
36
37
  hidden: "hidden",
37
38
  archived: "archived"
38
39
  }
39
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
+
40
66
  def to_param
41
67
  # For date-based URLs, return just the slug portion
42
68
  parts = CGI.unescape(slug).delete_prefix("/").split("/")
@@ -87,6 +113,78 @@ module Panda
87
113
  text.truncate(length).html_safe
88
114
  end
89
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
+
90
188
  private
91
189
 
92
190
  def clear_menu_cache
@@ -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,12 @@
1
1
  <div class="" data-controller="dashboard">
2
2
  <%= render Panda::Core::Admin::ContainerComponent.new do |container| %>
3
- <% container.with_heading(text: "Dashboard", level: 1) do |heading| %>
4
- <% heading.with_button(action: :add, text: "Add Page", link: new_admin_cms_page_path) %>
3
+ <% container.heading(text: "Dashboard", level: 1) do |heading| %>
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::CMS::Admin::StatisticsComponent.new(metric: "Views Today", value: Panda::CMS::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
8
- <%= render Panda::CMS::Admin::StatisticsComponent.new(metric: "Views Last Week", value: Panda::CMS::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
9
- <%= render Panda::CMS::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) %>
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) %>
10
10
  </dl>
11
11
  <% end %>
12
12
  </div>
@@ -0,0 +1,45 @@
1
+ <div>
2
+ <div class="block overflow-hidden w-full rounded-lg aspect-h-7 aspect-w-10">
3
+ <% if file.image? %>
4
+ <%= image_tag main_app.rails_blob_path(file, only_path: true), alt: file.filename.to_s, class: "object-cover" %>
5
+ <% else %>
6
+ <div class="flex items-center justify-center h-full bg-gray-100">
7
+ <div class="text-center">
8
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
9
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
10
+ </svg>
11
+ <p class="mt-1 text-xs text-gray-500 uppercase"><%= file.content_type&.split("/")&.last || "file" %></p>
12
+ </div>
13
+ </div>
14
+ <% end %>
15
+ </div>
16
+ <div class="flex justify-between items-start mt-4">
17
+ <div>
18
+ <h2 class="text-lg font-medium text-gray-900"><span class="sr-only">Details for </span><%= file.filename %></h2>
19
+ <p class="text-sm font-medium text-gray-500"><%= number_to_human_size(file.byte_size) %></p>
20
+ </div>
21
+ </div>
22
+ </div>
23
+
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">Created</dt>
29
+ <dd class="text-gray-900 whitespace-nowrap"><%= file.created_at.strftime("%B %-d, %Y") %></dd>
30
+ </div>
31
+ <div class="flex justify-between py-3 text-sm font-medium">
32
+ <dt class="text-gray-500">Content Type</dt>
33
+ <dd class="text-gray-900 whitespace-nowrap"><%= file.content_type %></dd>
34
+ </div>
35
+ <div class="flex justify-between py-3 text-sm font-medium">
36
+ <dt class="text-gray-500">Checksum</dt>
37
+ <dd class="text-gray-900 whitespace-nowrap font-mono text-xs"><%= file.checksum %></dd>
38
+ </div>
39
+ </dl>
40
+ </div>
41
+
42
+ <div class="flex gap-x-3">
43
+ <%= link_to "Download", main_app.rails_blob_path(file, disposition: "attachment"), class: "flex-1 py-2 px-3 text-sm font-semibold text-white bg-black rounded-md shadow-sm hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-panda-dark text-center" %>
44
+ <%= button_tag "Delete", 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", data: { confirm: "Are you sure you want to delete this file?" } %>
45
+ </div>
@@ -1,124 +1,17 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
- <% component.with_heading(text: "Files", level: 1) %>
2
+ <% component.heading(text: "Files", level: 1) %>
3
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>
4
+ <% component.slideover(title: "File Details") do %>
5
+ <div class="pt-4 pb-16 space-y-6" data-file-gallery-target="slideoverContent">
6
+ <% if @selected_file %>
7
+ <%= render partial: "file_details", locals: { file: @selected_file } %>
8
+ <% else %>
9
+ <p class="text-sm text-gray-500">Select a file to view details</p>
10
+ <% end %>
96
11
  </div>
97
12
  <% end %>
98
13
 
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>
14
+ <div data-controller="file-gallery" class="pb-24">
15
+ <%= Panda::Core::Admin::FileGalleryComponent.new(files: @files, selected_file: @selected_file) %>
16
+ </div>
124
17
  <% end %>
@@ -1,8 +1,8 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
- <% component.with_heading(text: "Forms", level: 1) do |heading| %>
2
+ <% component.heading(text: "Forms", level: 1, icon: "fa-solid fa-inbox") do |heading| %>
3
3
  <% end %>
4
4
 
5
- <%= render Panda::Core::Admin::TableComponent.new(term: "form", rows: forms) do |table| %>
5
+ <%= render Panda::Core::Admin::TableComponent.new(term: "form", rows: forms, icon: "fa-solid fa-inbox") do |table| %>
6
6
  <% table.column("Name") { |form| block_link_to form.name, admin_cms_form_path(form) } %>
7
7
  <% table.column("Status") { |form| render Panda::Core::Admin::TagComponent.new(status: :active) } %>
8
8
  <% table.column("Last Submission") do |form| %>
@@ -1,6 +1,5 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
- <% component.with_heading(text: "Add Page", level: 1) do |heading| %>
3
- <% end %>
2
+ <% component.heading(text: "Add Form", level: 1) %>
4
3
  <%= panda_cms_form_with model: page, url: admin_cms_pages_path, method: :post do |f| %>
5
4
  <% options = nested_set_options(Panda::CMS::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
6
5
  <div data-controller="slug">
@@ -1,35 +1,20 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
- <% component.with_heading(text: form.name, level: 1) do |heading| %>
2
+ <% component.heading(text: form.name, level: 1) do |heading| %>
3
3
  <% end %>
4
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>
5
+ <%= render Panda::Core::Admin::TableComponent.new(term: "submission", rows: submissions) do |table| %>
6
+ <% fields.each do |field, title| %>
7
+ <% table.column(title) do |submission| %>
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
+ <% else %>
11
+ <%= simple_format(submission.data[field]) %>
31
12
  <% end %>
32
- </tbody>
33
- </table>
34
- </div>
13
+ <% end %>
14
+ <% end %>
15
+
16
+ <% table.column("Submitted") do |submission| %>
17
+ <%= time_ago_in_words(submission.created_at) %> ago
18
+ <% end %>
19
+ <% end %>
35
20
  <% end %>