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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ module BulkActionTable
6
+ # Header bar with optional title, filter, action icons (+ button) and search form
7
+ # Renders using Phlex elements - no raw() needed
8
+ #
9
+ # @param title [String, nil] Page title (when present, filter/icons/search go on right)
10
+ # @param header_filter [String, nil] HTML for filter content (e.g. locale links)
11
+ # @param action_icons [Array<Hash>] Array of icon configs (url, title, color, icon, data)
12
+ # @param search_url [String] URL for search form
13
+ # @param search_param [String] Query param name (default: "q")
14
+ # @param turbo_frame [String, nil] Turbo Frame ID for search/filter updates
15
+ class BulkActionTableHeaderBar < BaseComponent
16
+ def initialize(
17
+ title: nil,
18
+ header_filter: nil,
19
+ action_icons: [],
20
+ search_url: "#",
21
+ search_param: "q",
22
+ turbo_frame: nil
23
+ )
24
+ super
25
+ @title = title
26
+ @header_filter = header_filter
27
+ @action_icons = action_icons || []
28
+ @search_url = search_url
29
+ @search_param = search_param
30
+ @turbo_frame = turbo_frame
31
+ end
32
+
33
+ def view_template
34
+ div(class: "px-6 py-4 border-b border-gray-200/80 bg-white") do
35
+ div(class: "flex flex-wrap items-center justify-between gap-4") do
36
+ render_title_group if @title.present?
37
+ div(class: "flex items-center gap-2 flex-wrap") do
38
+ render_header_filter if @header_filter.present?
39
+ render_action_icons
40
+ render_search_form
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def render_title_group
49
+ div(class: "min-w-0") do
50
+ h2(class: "text-sm font-semibold text-gray-900") { @title }
51
+ end
52
+ end
53
+
54
+ def render_header_filter
55
+ raw(@header_filter) # rubocop:disable Rails/OutputSafety -- filter HTML from trusted view
56
+ end
57
+
58
+ def render_action_icons
59
+ @action_icons.each {|icon_config| render_action_icon(icon_config) }
60
+ end
61
+
62
+ def render_action_icon(config)
63
+ url = config[:url] || "#"
64
+ title = config[:title]
65
+ color = config[:color] || "blue"
66
+ icon_path = config[:icon] || "M12 4.5v15m7.5-7.5h-15"
67
+ data_attrs = config[:data] || {}
68
+
69
+ color_class = icon_color_class(color)
70
+
71
+ a(**icon_link_attrs(url, title, data_attrs, color_class)) { render_icon_svg(icon_path) }
72
+ end
73
+
74
+ def render_search_form
75
+ form_options = {
76
+ url: @search_url,
77
+ method: :get,
78
+ class: "w-full sm:w-auto"
79
+ }
80
+ form_options[:data] = { turbo_frame: @turbo_frame } if @turbo_frame.present?
81
+
82
+ form_with(**form_options) do
83
+ div(class: "relative flex items-center") do
84
+ render_search_icon
85
+ render_search_input
86
+ end
87
+ end
88
+ end
89
+
90
+ def render_search_icon
91
+ span(class: "absolute left-3 text-gray-400 pointer-events-none") do
92
+ svg(class: "h-4 w-4", fill: "none", stroke: "currentColor",
93
+ viewBox: "0 0 24 24") do |s|
94
+ s.path(
95
+ stroke_linecap: "round",
96
+ stroke_linejoin: "round",
97
+ stroke_width: "2",
98
+ d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
99
+ )
100
+ end
101
+ end
102
+ end
103
+
104
+ def render_search_input
105
+ input(
106
+ type: "search",
107
+ name: @search_param,
108
+ placeholder: "Search",
109
+ class: "h-9 w-full sm:w-72 rounded-md border border-gray-200 bg-white pl-9 " \
110
+ "pr-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200",
111
+ value: search_value,
112
+ data: { action: "input->turbo-frame#submit" }
113
+ )
114
+ end
115
+
116
+ def search_value
117
+ helpers.params[@search_param.to_sym] if helpers.respond_to?(:params)
118
+ end
119
+
120
+ def icon_color_class(color)
121
+ icon_color_class_map.fetch(color.to_s, icon_color_class_map["blue"])
122
+ end
123
+
124
+ def icon_color_class_map
125
+ {
126
+ "blue" => "text-blue-600 hover:bg-blue-50",
127
+ "green" => "text-emerald-600 hover:bg-emerald-50",
128
+ "red" => "text-rose-600 hover:bg-rose-50",
129
+ "purple" => "text-violet-600 hover:bg-violet-50",
130
+ "gray" => "text-gray-700 hover:bg-gray-50",
131
+ "teal" => "text-teal-600 hover:bg-teal-50"
132
+ }
133
+ end
134
+
135
+ def icon_button_base_classes
136
+ "inline-flex items-center justify-center h-9 w-9 rounded-md border border-gray-200 " \
137
+ "bg-white shadow-sm transition-colors"
138
+ end
139
+
140
+ def icon_link_attrs(url, title, data_attrs, color_class)
141
+ {
142
+ href: url,
143
+ class: build_classes(icon_button_base_classes, color_class),
144
+ title: title,
145
+ data: data_attrs
146
+ }
147
+ end
148
+
149
+ def render_icon_svg(icon_path)
150
+ svg(class: "h-4 w-4", fill: "none", stroke: "currentColor",
151
+ viewBox: "0 0 24 24") do |s|
152
+ s.path(stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2",
153
+ d: icon_path)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ module BulkActionTable
6
+ # Pagination component
7
+ # Renders pagination controls with Previous/Next and page numbers
8
+ #
9
+ # @param pagination [Hash] Pagination hash with current_page, total_pages, etc.
10
+ # @param pagination_path [Proc] Lambda that generates pagination URLs
11
+ # @param turbo_frame [String, nil] Turbo Frame ID for updates
12
+ class BulkActionTablePagination < BaseComponent
13
+ def initialize(
14
+ pagination:,
15
+ pagination_path:,
16
+ turbo_frame: nil
17
+ )
18
+ super
19
+ @pagination = pagination || {}
20
+ @pagination_path = pagination_path
21
+ @turbo_frame = turbo_frame
22
+ end
23
+
24
+ def view_template
25
+ return unless @pagination[:total_pages] && @pagination[:total_pages] > 1
26
+
27
+ div(class: "px-6 py-3 flex items-center justify-between gap-4") do
28
+ render_pagination_info
29
+ render_pagination_controls
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def render_pagination_info
36
+ return unless @pagination[:start_item] && @pagination[:end_item] && @pagination[:total_count]
37
+
38
+ div(class: "text-sm text-gray-500") { pagination_info_text }
39
+ end
40
+
41
+ def pagination_info_text
42
+ start_item = @pagination[:start_item]
43
+ end_item = @pagination[:end_item]
44
+ total_count = @pagination[:total_count]
45
+ "Showing #{start_item}-#{end_item} of #{total_count} items"
46
+ end
47
+
48
+ def render_pagination_controls
49
+ nav(class: "inline-flex items-center gap-1") do
50
+ render_previous_button
51
+ render_page_numbers
52
+ render_next_button
53
+ end
54
+ end
55
+
56
+ def render_previous_button
57
+ if @pagination[:has_previous]
58
+ render_previous_link
59
+ else
60
+ render_previous_disabled
61
+ end
62
+ end
63
+
64
+ def render_previous_link
65
+ link_options = {
66
+ href: @pagination_path.call(@pagination[:previous_page]),
67
+ class: pagination_button_classes
68
+ }
69
+ link_options[:data] = { turbo_frame: @turbo_frame } if @turbo_frame
70
+ a(**link_options) { "Previous" }
71
+ end
72
+
73
+ def render_previous_disabled
74
+ span(class: pagination_button_disabled_classes) { "Previous" }
75
+ end
76
+
77
+ def pagination_button_classes
78
+ "inline-flex h-9 items-center justify-center rounded-md border border-gray-200 " \
79
+ "bg-white px-3 text-sm font-medium text-gray-900 shadow-sm hover:bg-gray-50 " \
80
+ "transition-colors"
81
+ end
82
+
83
+ def pagination_button_disabled_classes
84
+ "inline-flex h-9 items-center justify-center rounded-md border border-gray-200 " \
85
+ "bg-white px-3 text-sm font-medium text-gray-400 opacity-60 cursor-not-allowed"
86
+ end
87
+
88
+ def render_next_button
89
+ if @pagination[:has_next]
90
+ render_next_link
91
+ else
92
+ render_next_disabled
93
+ end
94
+ end
95
+
96
+ def render_next_link
97
+ link_options = {
98
+ href: @pagination_path.call(@pagination[:next_page]),
99
+ class: pagination_button_classes
100
+ }
101
+ link_options[:data] = { turbo_frame: @turbo_frame } if @turbo_frame
102
+ a(**link_options) { "Next" }
103
+ end
104
+
105
+ def render_next_disabled
106
+ span(class: pagination_button_disabled_classes) { "Next" }
107
+ end
108
+
109
+ def render_page_numbers
110
+ current_page = @pagination[:current_page] || 1
111
+ total_pages = @pagination[:total_pages] || 1
112
+ pages_to_show = calculate_pages_to_show(current_page, total_pages)
113
+
114
+ pages_to_show.each do |page_num|
115
+ render_page_number(page_num, current_page)
116
+ end
117
+ end
118
+
119
+ def render_page_number(page_num, current_page)
120
+ if page_num == :ellipsis
121
+ render_ellipsis
122
+ elsif page_num == current_page
123
+ render_current_page(page_num)
124
+ else
125
+ render_page_link(page_num)
126
+ end
127
+ end
128
+
129
+ def render_ellipsis
130
+ span(class: "px-2 text-sm text-gray-500") { "…" }
131
+ end
132
+
133
+ def render_current_page(page_num)
134
+ span(class: current_page_classes) { page_num.to_s }
135
+ end
136
+
137
+ def render_page_link(page_num)
138
+ link_options = {
139
+ href: @pagination_path.call(page_num),
140
+ class: pagination_button_classes
141
+ }
142
+ link_options[:data] = { turbo_frame: @turbo_frame } if @turbo_frame
143
+ a(**link_options) { page_num.to_s }
144
+ end
145
+
146
+ def current_page_classes
147
+ "inline-flex h-9 items-center justify-center rounded-md border border-gray-200 " \
148
+ "bg-gray-900 px-3 text-sm font-medium text-white shadow-sm"
149
+ end
150
+
151
+ def calculate_pages_to_show(current_page, total_pages)
152
+ return [1] if total_pages <= 1
153
+
154
+ max_pages = 7
155
+ if total_pages <= max_pages
156
+ all_pages_array(total_pages)
157
+ else
158
+ complex_pages_array(current_page, total_pages)
159
+ end
160
+ end
161
+
162
+ def all_pages_array(total_pages)
163
+ (1..total_pages).to_a
164
+ end
165
+
166
+ def complex_pages_array(current_page, total_pages)
167
+ pages = [1]
168
+ start_page = calculate_start_page(current_page)
169
+ end_page = calculate_end_page(current_page, total_pages)
170
+
171
+ add_middle_section(pages, start_page, end_page, total_pages)
172
+ pages << total_pages unless pages.include?(total_pages)
173
+ pages
174
+ end
175
+
176
+ def calculate_start_page(current_page)
177
+ [current_page - 1, 2].max
178
+ end
179
+
180
+ def calculate_end_page(current_page, total_pages)
181
+ [current_page + 1, total_pages - 1].min
182
+ end
183
+
184
+ def add_middle_section(pages, start_page, end_page, total_pages)
185
+ pages << :ellipsis if start_page > 2
186
+ (start_page..end_page).each {|p| pages << p unless pages.include?(p) }
187
+ pages << :ellipsis if end_page < total_pages - 1
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ module BulkActionTable
6
+ # Table row component
7
+ # Individual table row with clickable support and checkbox
8
+ #
9
+ # @param click_url [String, nil] URL to navigate when row is clicked
10
+ # @param data [Hash] Data attributes (e.g., { item_id: 1 })
11
+ # @param cells [Array, nil] Array of cell content (alternative to block)
12
+ # @param bulk_actions_enabled [Boolean] Whether to show checkbox cell
13
+ # @param controller_name [String] Stimulus controller identifier
14
+ # @param class [String, nil] Additional CSS classes
15
+ class BulkActionTableRow < BaseComponent
16
+ def initialize(
17
+ click_url: nil,
18
+ data: {},
19
+ cells: nil,
20
+ bulk_actions_enabled: true,
21
+ controller_name: "ruby-cms--bulk-action-table",
22
+ class: nil,
23
+ **user_attrs
24
+ )
25
+ super
26
+ @click_url = click_url
27
+ @data = data || {}
28
+ @cells = cells
29
+ @bulk_actions_enabled = bulk_actions_enabled
30
+ @controller_name = controller_name
31
+ @row_class = binding.local_variable_get(:class)
32
+ @user_attrs = user_attrs
33
+ end
34
+
35
+ def view_template(&block)
36
+ tr(**row_attributes) do
37
+ render_bulk_checkbox
38
+ render_cells_or_block(&block)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def row_attributes
45
+ {
46
+ class: build_row_classes,
47
+ data: (build_row_data_attributes || {}).merge(@data || {})
48
+ }
49
+ end
50
+
51
+ def render_bulk_checkbox
52
+ return unless @bulk_actions_enabled && @data&.[](:item_id)
53
+
54
+ render BulkActionTableCheckboxCell.new(
55
+ item_id: @data[:item_id],
56
+ controller_name: @controller_name
57
+ )
58
+ end
59
+
60
+ def render_cells_or_block(&block)
61
+ if @cells.nil? && block
62
+ # Block returns HTML from render partial - output as raw to avoid escaping
63
+ return raw(yield) # rubocop:disable Rails/OutputSafety -- partial output is trusted
64
+ end
65
+
66
+ Array(@cells).each do |cell|
67
+ if cell.kind_of?(Hash)
68
+ td(class: cell[:class]) { cell[:content] }
69
+ else
70
+ td { cell }
71
+ end
72
+ end
73
+ end
74
+
75
+ def build_row_classes
76
+ classes = ["hover:bg-gray-50 transition-colors"]
77
+ classes << @row_class if @row_class
78
+ classes << "cursor-pointer" if @click_url
79
+ build_classes(classes)
80
+ end
81
+
82
+ def build_row_data_attributes
83
+ attrs = {}
84
+ attrs[:item_id] = @data[:item_id] if @data[:item_id]
85
+
86
+ if @click_url
87
+ attrs[:controller] = "clickable-row"
88
+ attrs[:clickable_row_click_url_value] = @click_url
89
+ attrs[:action] = "click->clickable-row#navigate"
90
+ end
91
+
92
+ attrs
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ module BulkActionTable
6
+ # Bulk actions bar component
7
+ # Appears when items are selected, shows selected count and action buttons
8
+ #
9
+ # @param controller_name [String] Stimulus controller identifier
10
+ # @param item_name [String] Singular name for items
11
+ # @param bulk_actions_url [String, nil] URL for bulk delete action
12
+ # @param bulk_action_buttons [Array<Hash>] Array of custom bulk action button configs
13
+ class BulkActions < BaseComponent
14
+ def initialize(
15
+ controller_name: "ruby-cms--bulk-action-table",
16
+ item_name: "item",
17
+ bulk_actions_url: nil,
18
+ bulk_action_buttons: []
19
+ )
20
+ super
21
+ @controller_name = controller_name
22
+ @item_name = item_name
23
+ @bulk_actions_url = bulk_actions_url
24
+ @bulk_action_buttons = bulk_action_buttons || []
25
+ end
26
+
27
+ def view_template
28
+ div(
29
+ class: "hidden px-6 py-3",
30
+ data: {
31
+ "#{@controller_name}-target": "bulkBar"
32
+ }
33
+ ) do
34
+ div(
35
+ class: "flex items-center justify-between gap-3 rounded-lg border " \
36
+ "border-gray-200/80 bg-white px-4 py-3 shadow-sm"
37
+ ) do
38
+ render_selection_info
39
+ render_action_buttons
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def render_selection_info
47
+ div(class: "flex items-center gap-3 flex-wrap") do
48
+ span(
49
+ class: "text-sm font-medium text-gray-700",
50
+ data: {
51
+ "#{@controller_name}-target": "selectedCount"
52
+ }
53
+ ) { "0 #{@item_name}s selected:" }
54
+ button(
55
+ type: "button",
56
+ class: "text-sm font-medium text-gray-600 hover:text-gray-900 hover:underline " \
57
+ "transition-colors",
58
+ data: {
59
+ "#{@controller_name}-target": "selectAllButton",
60
+ action: "click->#{@controller_name}#selectAll"
61
+ }
62
+ ) { "Select all" }
63
+ button(
64
+ type: "button",
65
+ class: "text-sm font-medium text-gray-600 hover:text-gray-900 hover:underline " \
66
+ "transition-colors",
67
+ data: {
68
+ action: "click->#{@controller_name}#clearSelection"
69
+ }
70
+ ) { "Clear selection" }
71
+ end
72
+ end
73
+
74
+ def render_action_buttons
75
+ div(class: "flex items-center gap-2 flex-wrap") do
76
+ @bulk_action_buttons.each do |button_config|
77
+ render_custom_action_button(button_config)
78
+ end
79
+
80
+ render_delete_button if @bulk_actions_url
81
+ end
82
+ end
83
+
84
+ def render_custom_action_button(config)
85
+ label = config[:label] || config[:text] || config[:name]&.humanize || "Button"
86
+ action_name = config[:name] || config[:action_name]
87
+
88
+ button(
89
+ type: "button",
90
+ class: build_button_class(config),
91
+ data: build_button_data_attrs(config, label, action_name)
92
+ ) { label }
93
+ end
94
+
95
+ def build_button_class(config)
96
+ base = "inline-flex items-center justify-center rounded-md border border-gray-200 " \
97
+ "bg-white px-3 py-2 text-sm font-medium text-gray-900 shadow-sm " \
98
+ "hover:bg-gray-50 transition-colors"
99
+ config[:class].present? ? "#{base} #{config[:class]}" : base
100
+ end
101
+
102
+ def build_button_data_attrs(config, label, action_name)
103
+ data_attrs = {
104
+ action: "click->#{@controller_name}#showActionDialog",
105
+ action_name: action_name,
106
+ action_url: config[:url]&.to_s,
107
+ action_label: label
108
+ }
109
+
110
+ # Pass through action_type
111
+ data_attrs[:action_type] = config[:action_type] if config[:action_type].present?
112
+
113
+ data_attrs[:action_confirm] = config[:confirm] if config[:confirm].present?
114
+
115
+ data_attrs
116
+ end
117
+
118
+ def render_delete_button
119
+ button(
120
+ type: "button",
121
+ class: "inline-flex items-center justify-center rounded-md border border-rose-200 " \
122
+ "bg-white px-3 py-2 text-sm font-medium text-rose-700 shadow-sm " \
123
+ "hover:bg-rose-50 transition-colors",
124
+ data: {
125
+ action: "click->#{@controller_name}#showActionDialog",
126
+ action_name: "delete",
127
+ action_label: "Delete Selected",
128
+ action_confirm: "Are you sure you want to delete the selected \
129
+ items? This action cannot be undone.",
130
+ action_url: @bulk_actions_url&.to_s
131
+ }
132
+ ) { "Delete Selected" }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_cms/settings"
4
+
5
+ module RubyCms
6
+ # Unified pagination concern for all admin index pages.
7
+ # Uses RubyCms::Preference for per_page when configured via paginates(per_page: proc).
8
+ module AdminPagination
9
+ extend ActiveSupport::Concern
10
+
11
+ DEFAULT_MIN_PER_PAGE = 5
12
+ DEFAULT_MAX_PER_PAGE = 200
13
+
14
+ included do
15
+ class_attribute :pagination_per_page, default: 50
16
+ class_attribute :pagination_turbo_frame, default: nil
17
+ class_attribute :pagination_min_per_page, default: DEFAULT_MIN_PER_PAGE
18
+ class_attribute :pagination_max_per_page, default: DEFAULT_MAX_PER_PAGE
19
+ end
20
+
21
+ def set_pagination_vars(collection, per_page: nil, turbo_frame: nil)
22
+ per_page = calculate_per_page(per_page)
23
+ turbo_frame ||= self.class.pagination_turbo_frame
24
+
25
+ page = sanitize_page_param(params[:page])
26
+ paginated, total_count, total_pages, offset = paginate_collection_internal(collection, page,
27
+ per_page)
28
+
29
+ @pagination = build_pagination_hash(page, per_page, total_count, total_pages, offset)
30
+ @pagination_path = build_pagination_path_lambda
31
+ @turbo_frame = turbo_frame
32
+
33
+ paginated
34
+ end
35
+
36
+ def paginate_collection(collection, per_page: nil, turbo_frame: nil)
37
+ set_pagination_vars(collection, per_page:, turbo_frame:)
38
+ end
39
+
40
+ module ClassMethods
41
+ def paginates(per_page: 50, turbo_frame: nil, min_per_page: nil, max_per_page: nil)
42
+ self.pagination_per_page = per_page
43
+ self.pagination_turbo_frame = turbo_frame
44
+ self.pagination_min_per_page = min_per_page if min_per_page.present?
45
+ self.pagination_max_per_page = max_per_page if max_per_page.present?
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def calculate_per_page(per_page=nil)
52
+ per_page ||= self.class.pagination_per_page
53
+ per_page = per_page.call if per_page.respond_to?(:call)
54
+
55
+ min = RubyCms::Settings.get(:pagination_min_per_page,
56
+ default: self.class.pagination_min_per_page).to_i
57
+ max = RubyCms::Settings.get(:pagination_max_per_page,
58
+ default: self.class.pagination_max_per_page).to_i
59
+ max = [max, min].max
60
+
61
+ per_page.to_i.clamp(min, max)
62
+ end
63
+
64
+ def sanitize_page_param(page_param)
65
+ page = (page_param || params[:page]).to_i
66
+ [page, 1].max
67
+ end
68
+
69
+ def paginate_collection_internal(collection, page, per_page)
70
+ if collection.kind_of?(Array)
71
+ paginate_array(collection, page, per_page)
72
+ elsif defined?(Kaminari) && collection.respond_to?(:page)
73
+ paginated = collection.page(page).per(per_page)
74
+ [paginated, paginated.total_count, paginated.total_pages, paginated.offset_value]
75
+ else
76
+ paginate_relation(collection, page, per_page)
77
+ end
78
+ end
79
+
80
+ def paginate_array(array, page, per_page)
81
+ total_count = array.size
82
+ offset = (page - 1) * per_page
83
+ total_pages = total_count.positive? ? (total_count.to_f / per_page).ceil : 1
84
+ paginated = array.slice(offset, per_page) || []
85
+ [paginated, total_count, total_pages, offset]
86
+ end
87
+
88
+ def paginate_relation(collection, page, per_page)
89
+ total_count = collection.count
90
+ offset = (page - 1) * per_page
91
+ total_pages = total_count.positive? ? (total_count.to_f / per_page).ceil : 1
92
+ paginated = collection.limit(per_page).offset(offset)
93
+ [paginated, total_count, total_pages, offset]
94
+ end
95
+
96
+ def build_pagination_hash(page, per_page, total_count, total_pages, offset)
97
+ {
98
+ current_page: page,
99
+ total_pages: total_pages,
100
+ total_count: total_count,
101
+ per_page: per_page,
102
+ has_next: page < total_pages,
103
+ has_previous: page > 1,
104
+ next_page: page < total_pages ? page + 1 : nil,
105
+ previous_page: page > 1 ? page - 1 : nil,
106
+ start_item: total_count.positive? ? offset + 1 : 0,
107
+ end_item: [offset + per_page, total_count].min
108
+ }
109
+ end
110
+
111
+ def build_pagination_path_lambda
112
+ lambda do |page_num|
113
+ query_params = request.query_parameters.except(:page).merge(page: page_num)
114
+ base_path = request.path
115
+ query_string = query_params.to_query
116
+ query_string.present? ? "#{base_path}?#{query_string}" : base_path
117
+ end
118
+ end
119
+ end
120
+ end