ruby_cms 0.1.0

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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ class VisualEditorController < BaseController
6
+ before_action { require_permission!(:manage_content_blocks) }
7
+ before_action :apply_visual_editor_locale
8
+
9
+ def index
10
+ @available_pages = available_pages
11
+ @current_page = determine_current_page
12
+ @edit_mode = edit_mode_enabled?
13
+ end
14
+
15
+ def page_preview
16
+ @page_key = params[:page] || "home"
17
+ @page = @page_key
18
+ @edit_mode = edit_mode_enabled?
19
+ @content_blocks = load_content_blocks_for_locale
20
+ template = template_for_page(@page_key)
21
+
22
+ return render_invalid_page unless template
23
+
24
+ load_preview_data(@page_key)
25
+ render template: template, layout: "ruby_cms/minimal"
26
+ end
27
+
28
+ def quick_update
29
+ block = find_or_initialize_content_block
30
+ update_content_block_attributes(block)
31
+ assign_content_block_content(block)
32
+
33
+ if block.save
34
+ render json: success_response(block)
35
+ else
36
+ render json: error_response(block), status: :unprocessable_content
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def apply_visual_editor_locale
43
+ requested = params[:locale].presence
44
+ return if requested.blank?
45
+
46
+ locale = requested.to_s
47
+ return unless available_locales.include?(locale)
48
+
49
+ session[:ruby_cms_locale] = locale
50
+ I18n.locale = locale.to_sym
51
+ end
52
+
53
+ def available_locales
54
+ I18n.available_locales.map(&:to_s)
55
+ rescue StandardError
56
+ [I18n.default_locale.to_s]
57
+ end
58
+
59
+ def determine_current_page
60
+ requested = params[:page].presence
61
+ return requested if requested && @available_pages.key?(requested)
62
+ return @available_pages.keys.first if @available_pages.any?
63
+
64
+ "home"
65
+ end
66
+
67
+ def edit_mode_enabled?
68
+ params[:edit_mode].nil? || params[:edit_mode] == "true"
69
+ end
70
+
71
+ def template_for_page(page_key)
72
+ template_from_config(page_key) || template_from_auto_detect(page_key)
73
+ end
74
+
75
+ def template_from_config(page_key)
76
+ load_config_templates[page_key]
77
+ end
78
+
79
+ def template_from_auto_detect(page_key)
80
+ auto_detect_templates[page_key]
81
+ end
82
+
83
+ def load_config_templates
84
+ Rails.application.config.ruby_cms.preview_templates
85
+ rescue StandardError
86
+ {}
87
+ end
88
+
89
+ def render_invalid_page
90
+ all_templates = load_config_templates.merge(auto_detect_templates)
91
+ available_keys = all_templates.keys.join(", ")
92
+ message = if available_keys.present?
93
+ "Invalid page: #{@page_key}. Available pages: #{available_keys}"
94
+ else
95
+ "Invalid page: #{@page_key}. No pages found in app/views. " \
96
+ "Create a template like app/views/pages/home.html.erb or " \
97
+ "configure manually in config/initializers/ruby_cms.rb"
98
+ end
99
+ render plain: message, status: :bad_request
100
+ end
101
+
102
+ def load_content_blocks_for_locale
103
+ blocks = ::ContentBlock.all
104
+ blocks_by_locale = blocks.group_by(&:locale)
105
+ locale_blocks = blocks_by_locale[current_locale] || []
106
+ default_blocks = default_locale_blocks(blocks_by_locale)
107
+
108
+ content_blocks = {}
109
+ default_blocks&.each {|b| content_blocks[b.key] = b }
110
+ locale_blocks.each {|b| content_blocks[b.key] = b }
111
+ content_blocks
112
+ end
113
+
114
+ def default_locale_blocks(blocks_by_locale)
115
+ return nil if current_locale == default_locale
116
+
117
+ blocks_by_locale[default_locale] || []
118
+ end
119
+
120
+ def find_or_initialize_content_block
121
+ key = params[:key]
122
+ locale = content_block_locale
123
+ ::ContentBlock.find_or_initialize_by(key: key, locale: locale.to_s)
124
+ end
125
+
126
+ def content_block_locale
127
+ params[:locale].presence || session[:ruby_cms_locale].presence || I18n.locale.to_s
128
+ end
129
+
130
+ def update_content_block_attributes(block)
131
+ block.content_type = params[:content_type]
132
+ block.updated_by = current_user_cms
133
+ block.published = true
134
+ end
135
+
136
+ def assign_content_block_content(block)
137
+ if rich_text_content?
138
+ block.rich_content = params[:rich_content]
139
+ elsif params[:content].present?
140
+ block.content = params[:content]
141
+ end
142
+ end
143
+
144
+ def rich_text_content?
145
+ params[:content_type] == "rich_text" && params[:rich_content].present?
146
+ end
147
+
148
+ def success_response(block)
149
+ {
150
+ success: true,
151
+ message: "Content updated successfully",
152
+ content: content_block_content_text(block),
153
+ rich_content_html: rich_content_html(block),
154
+ content_type: block.content_type,
155
+ locale: block.locale,
156
+ updated_at: formatted_updated_at(block)
157
+ }
158
+ end
159
+
160
+ def content_block_content_text(block)
161
+ block.content_type == "rich_text" ? block.rich_content.to_plain_text : block.content
162
+ end
163
+
164
+ # Return body HTML only (no layout/comments) so preview and Trix get clean HTML.
165
+ def rich_content_html(block)
166
+ return nil unless block.content_type == "rich_text"
167
+ return nil unless block.respond_to?(:rich_content)
168
+ return nil unless block.rich_content.respond_to?(:body) && block.rich_content.body.present?
169
+
170
+ body = block.rich_content.body
171
+ body.respond_to?(:to_html) ? body.to_html : body.to_s
172
+ end
173
+
174
+ def formatted_updated_at(block)
175
+ block.updated_at.strftime("%B %d, %Y at %I:%M %p")
176
+ end
177
+
178
+ def error_response(block)
179
+ {
180
+ success: false,
181
+ message: block.errors.full_messages.join(", ")
182
+ }
183
+ end
184
+
185
+ def available_pages
186
+ pages = {}
187
+ add_config_pages(pages)
188
+ add_auto_detected_pages(pages) if pages.empty?
189
+ pages
190
+ end
191
+
192
+ def add_auto_detected_pages(pages)
193
+ auto_detect_templates.each do |key, template_path|
194
+ next if pages.key?(key)
195
+
196
+ pages[key] = build_page_hash(key, template_path)
197
+ end
198
+ end
199
+
200
+ def add_config_pages(pages)
201
+ config_pages.each do |key, template_path|
202
+ pages[key] ||= build_page_hash(key, template_path)
203
+ end
204
+ end
205
+
206
+ def build_page_hash(key, template_path)
207
+ {
208
+ name: key.humanize,
209
+ url: page_url_for(key),
210
+ key_prefix: "#{key}_",
211
+ template_path: template_path
212
+ }
213
+ end
214
+
215
+ def config_pages
216
+ Rails.application.config.ruby_cms.preview_templates
217
+ rescue StandardError
218
+ {}
219
+ end
220
+
221
+ def page_url_for(key)
222
+ public_page_path(key:)
223
+ rescue NoMethodError, ActionController::UrlGenerationError
224
+ "/p/#{key}"
225
+ end
226
+
227
+ def current_locale
228
+ (params[:locale] || session[:ruby_cms_locale] || I18n.locale.to_s).to_s
229
+ end
230
+
231
+ def default_locale
232
+ I18n.default_locale.to_s
233
+ rescue StandardError
234
+ "en"
235
+ end
236
+
237
+ def auto_detect_templates
238
+ @auto_detect_templates ||= detect_templates_at_runtime
239
+ end
240
+
241
+ def detect_templates_at_runtime
242
+ views_dir = Rails.root.join("app/views")
243
+ return {} unless Dir.exist?(views_dir)
244
+
245
+ templates = {}
246
+ scan_views_for_templates(views_dir, templates)
247
+ templates
248
+ rescue StandardError
249
+ {}
250
+ end
251
+
252
+ def scan_views_for_templates(dir_path, templates, relative_path="")
253
+ Dir.glob(File.join(dir_path, "*")).each do |path|
254
+ if File.directory?(path)
255
+ scan_subdirectory(path, templates, relative_path)
256
+ else
257
+ process_template_file(path, templates, relative_path)
258
+ end
259
+ end
260
+ end
261
+
262
+ def scan_subdirectory(path, templates, relative_path)
263
+ dir_name = File.basename(path)
264
+ return if skip_directory?(dir_name, relative_path)
265
+
266
+ new_relative_path = relative_path.empty? ? dir_name : "#{relative_path}/#{dir_name}"
267
+ return if depth_exceeded?(new_relative_path)
268
+
269
+ scan_views_for_templates(path, templates, new_relative_path)
270
+ end
271
+
272
+ def skip_directory?(dir_name, relative_path)
273
+ %w[
274
+ layouts shared mailers components
275
+ admin
276
+ ].include?(dir_name) || relative_path.start_with?("admin")
277
+ end
278
+
279
+ def depth_exceeded?(relative_path)
280
+ relative_path.split("/").length > 2
281
+ end
282
+
283
+ def process_template_file(file_path, templates, relative_path)
284
+ return unless file_path.match?(/\.(html\.erb|html\.haml|html\.slim)$/)
285
+
286
+ base_name = File.basename(file_path, ".*").sub(/\.html$/, "")
287
+ if base_name.start_with?("_") || relative_path == "layouts" ||
288
+ relative_path.start_with?("admin")
289
+ return
290
+ end
291
+
292
+ page_key, template_path = build_page_key_and_template_path(base_name, relative_path)
293
+ templates[page_key] ||= template_path
294
+ end
295
+
296
+ def build_page_key_and_template_path(base_name, relative_path)
297
+ if relative_path.empty?
298
+ [base_name, base_name]
299
+ elsif base_name == "index"
300
+ key = relative_path.split("/").last
301
+ [key, "#{relative_path}/index"]
302
+ else
303
+ [base_name, "#{relative_path}/#{base_name}"]
304
+ end
305
+ end
306
+
307
+ def load_preview_data(page_key)
308
+ @page_title = page_key.humanize
309
+
310
+ data_proc = Rails.application.config.ruby_cms.preview_data
311
+ return unless data_proc.respond_to?(:call)
312
+
313
+ data = data_proc.call(page_key, view_context)
314
+ return unless data.kind_of?(Hash)
315
+
316
+ data.each do |k, v|
317
+ instance_variable_set(:"@#{k}", v)
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ # Handles 404 errors from catch-all routes.
5
+ # Add this to the BOTTOM of your routes.rb to capture routing errors:
6
+ #
7
+ # match "*path", to: "ruby_cms/errors#not_found", via: :all,
8
+ # constraints: ->(req) { !req.path.start_with?("/rails/") }
9
+ #
10
+ class ErrorsController < ApplicationController
11
+ def not_found
12
+ # Log the routing error to VisitorError (skips in development)
13
+ RubyCms::VisitorError.log_routing_error(request)
14
+
15
+ respond_to do |format|
16
+ format.html do
17
+ render_html_not_found
18
+ end
19
+ format.json { render json: { error: "Not found" }, status: :not_found }
20
+ format.any { head :not_found }
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def render_html_not_found
27
+ static_not_found = Rails.public_path.join("404.html")
28
+ if static_not_found.exist?
29
+ render file: static_not_found, status: :not_found, layout: false
30
+ else
31
+ render :not_found, status: :not_found, layout: false
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ module AdminPageHelper
6
+ def admin_page(title: nil, subtitle: nil, footer: nil, **, &)
7
+ render RubyCms::Admin::AdminPage.new(
8
+ title:,
9
+ subtitle:,
10
+ footer:,
11
+ **,
12
+ &
13
+ )
14
+ end
15
+
16
+ def admin_table_content(id: "admin_table_content", **attrs, &)
17
+ render RubyCms::Admin::AdminPage::AdminTableContent.new(id:, **attrs, &)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ module BulkActionTableHelper
6
+ # Render a complete bulk action table using Phlex components
7
+ #
8
+ # @param collection [ActiveRecord::Relation] The collection to display
9
+ # @param headers [Array<String, Hash>] Array of header labels or hashes with :text and :class
10
+ # @param turbo_frame [String, nil] Turbo Frame ID for seamless updates
11
+ # @param pagination [Hash, nil] Pagination hash from AdminPagination concern
12
+ # @param pagination_path [Proc, nil] Lambda for generating pagination URLs
13
+ # @param bulk_actions_url [String, nil] URL for bulk delete action
14
+ # @param bulk_action_buttons [Array<Hash>] Array of custom bulk action button configs
15
+ # @param item_name [String] Singular name for items (default: "item")
16
+ # @param controller_name [String] Stimulus controller identifier
17
+ # @param block [Proc] Block that renders table rows
18
+ # @return [String] Rendered HTML
19
+ #
20
+ # @example Basic usage
21
+ # <%= render_bulk_action_table(
22
+ # collection: @items,
23
+ # headers: ["Name", "Status", { text: "Actions", class: "text-right" }],
24
+ # bulk_actions_url: bulk_delete_items_path,
25
+ # item_name: "item"
26
+ # ) do %>
27
+ # <% @items.each do |item| %>
28
+ # <%= render RubyCms::Admin::BulkActionTable::BulkActionTableRow.new(
29
+ # data: { item_id: item.id }
30
+ # ) do %>
31
+ # <td><%= item.name %></td>
32
+ # <td><%= item.status %></td>
33
+ # <td class="text-right">
34
+ # <%= render RubyCms::Admin::BulkActionTable::BulkActionTableActions.new(
35
+ # edit_path: edit_item_path(item),
36
+ # delete_path: item_path(item),
37
+ # item_id: item.id
38
+ # ) %>
39
+ # </td>
40
+ # <% end %>
41
+ # <% end %>
42
+ # <% end %>
43
+ def render_bulk_action_table(
44
+ headers:,
45
+ turbo_frame: "admin_table_content",
46
+ pagination: nil,
47
+ pagination_path: nil,
48
+ bulk_actions_url: nil,
49
+ bulk_action_buttons: [],
50
+ item_name: "item",
51
+ controller_name: "ruby-cms--bulk-action-table",
52
+ &block
53
+ )
54
+ render RubyCms::Admin::BulkActionTable::BulkActionTable.new(
55
+ turbo_frame: turbo_frame,
56
+ pagination: pagination,
57
+ pagination_path: pagination_path,
58
+ bulk_actions_url: bulk_actions_url,
59
+ bulk_actions_buttons: bulk_action_buttons,
60
+ item_name: item_name,
61
+ controller_name: controller_name,
62
+ csrf_token: form_authenticity_token
63
+ ) do
64
+ render RubyCms::Admin::BulkActionTable::BulkActionTableHeader.new(
65
+ headers: headers,
66
+ bulk_actions_enabled: bulk_actions_url.present? || bulk_action_buttons.any?,
67
+ controller_name: controller_name
68
+ )
69
+
70
+ render RubyCms::Admin::BulkActionTable::BulkActionTableBody.new(&block)
71
+ end
72
+ end
73
+
74
+ # Render an admin page with consistent layout
75
+ #
76
+ # @param title [String] Page title
77
+ # @param subtitle [String, nil] Optional subtitle
78
+ # @param actions [Array<Hash>, nil] Array of action button configs
79
+ # @param breadcrumbs [Array<Hash>, nil] Array of breadcrumb items
80
+ # @param padding [Boolean] Add padding classes (default: true)
81
+ # @param overflow [Boolean] Allow overflow (default: true)
82
+ # @param turbo_frame [String, nil] Turbo Frame ID for wrapping
83
+ # @param turbo_frame_options [Hash, nil] Custom Turbo Frame options
84
+ # @param block [Proc] Block that renders page content
85
+ # @return [String] Rendered HTML
86
+ #
87
+ # @example
88
+ # <%= render_admin_page(
89
+ # title: "Content Blocks",
90
+ # actions: [
91
+ # { label: "New Block", url: new_content_block_path, primary: true }
92
+ # ],
93
+ # turbo_frame: "admin_table_content"
94
+ # ) do %>
95
+ # <div class="ruby_cms-card">
96
+ # <!-- Content here -->
97
+ # </div>
98
+ # <% end %>
99
+ def render_admin_page(
100
+ title:,
101
+ subtitle: nil,
102
+ actions: nil,
103
+ breadcrumbs: nil,
104
+ padding: true,
105
+ overflow: true,
106
+ turbo_frame: nil,
107
+ turbo_frame_options: nil
108
+ )
109
+ render RubyCms::Admin::AdminPage.new(
110
+ title:,
111
+ subtitle:,
112
+ actions:,
113
+ breadcrumbs:,
114
+ padding:,
115
+ overflow:,
116
+ turbo_frame:,
117
+ turbo_frame_options:
118
+ ) do
119
+ yield if block_given?
120
+ end
121
+ end
122
+
123
+ # Build bulk action button configuration hash
124
+ #
125
+ # @param name [String] Action name (used internally)
126
+ # @param label [String] Button label text
127
+ # @param url [String] Action URL
128
+ # @param confirm [String, nil] Confirmation message
129
+ # @param action_type [String] "redirect" for redirect actions, nil for dialog actions
130
+ # @param class [String, nil] Additional CSS classes
131
+ # @return [Hash] Button configuration hash
132
+ #
133
+ # @example
134
+ # bulk_action_button(
135
+ # name: "publish",
136
+ # label: "Publish Selected",
137
+ # url: bulk_publish_path,
138
+ # confirm: "Are you sure?"
139
+ # )
140
+ def bulk_action_button(
141
+ name:,
142
+ label:,
143
+ url:,
144
+ confirm: nil,
145
+ action_type: nil,
146
+ class: nil
147
+ )
148
+ {
149
+ name: name,
150
+ label: label,
151
+ url: url,
152
+ confirm: confirm,
153
+ action_type: action_type,
154
+ class: binding.local_variable_get(:class)
155
+ }.compact
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module ApplicationHelper
5
+ include RubyCms::Engine.routes.url_helpers if defined?(RubyCms::Engine)
6
+
7
+ def ruby_cms_locale_display_name(locale)
8
+ key = "ruby_cms.admin.locales.#{locale}"
9
+ t(key, default: locale.to_s)
10
+ end
11
+
12
+ def ruby_cms_user_display(user)
13
+ return "—" if user.blank?
14
+
15
+ %i[email_address email username name].each do |attr|
16
+ return user.public_send(attr) if user.respond_to?(attr) && user.public_send(attr).present?
17
+ end
18
+ user.respond_to?(:id) ? "User ##{user.id}" : user.to_s
19
+ end
20
+
21
+ def ruby_cms_nav_entries
22
+ RubyCms.visible_nav_registry(
23
+ view_context: self,
24
+ user: (current_user_cms if respond_to?(:current_user_cms))
25
+ )
26
+ end
27
+
28
+ # Render an SVG fragment (typically <path ...>) safely.
29
+ # Used for nav icons which may come from host app configuration.
30
+ def ruby_cms_safe_svg_fragment(fragment)
31
+ sanitize(
32
+ fragment.to_s,
33
+ tags: %w[svg path g circle rect line polygon polyline ellipse],
34
+ attributes: %w[
35
+ fill stroke stroke-linecap stroke-linejoin stroke-width d
36
+ class viewBox cx cy r x y points x1 y1 x2 y2 aria-hidden aria-label focusable
37
+ ]
38
+ )
39
+ end
40
+ end
41
+ end