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.
- checksums.yaml +7 -0
- data/.cursor/dhh.mdc +698 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/README.md +235 -0
- data/Rakefile +30 -0
- data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
- data/app/components/ruby_cms/admin/admin_page.rb +345 -0
- data/app/components/ruby_cms/admin/base_component.rb +78 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
- data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
- data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
- data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
- data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
- data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
- data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
- data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
- data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
- data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
- data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
- data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
- data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
- data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
- data/app/controllers/ruby_cms/errors_controller.rb +35 -0
- data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
- data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
- data/app/helpers/ruby_cms/application_helper.rb +41 -0
- data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
- data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
- data/app/helpers/ruby_cms/settings_helper.rb +160 -0
- data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
- data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
- data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
- data/app/javascript/controllers/ruby_cms/index.js +104 -0
- data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
- data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
- data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
- data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
- data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
- data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
- data/app/models/concerns/content_block/publishable.rb +54 -0
- data/app/models/concerns/content_block/searchable.rb +22 -0
- data/app/models/content_block.rb +155 -0
- data/app/models/ruby_cms/content_block.rb +8 -0
- data/app/models/ruby_cms/permission.rb +28 -0
- data/app/models/ruby_cms/permittable.rb +39 -0
- data/app/models/ruby_cms/preference.rb +111 -0
- data/app/models/ruby_cms/user_permission.rb +12 -0
- data/app/models/ruby_cms/visitor_error.rb +109 -0
- data/app/services/ruby_cms/analytics/report.rb +362 -0
- data/app/services/ruby_cms/security_tracker.rb +92 -0
- data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
- data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
- data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
- data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
- data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
- data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
- data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
- data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
- data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
- data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
- data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
- data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
- data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
- data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
- data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
- data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
- data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
- data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
- data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
- data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
- data/config/database.yml +6 -0
- data/config/importmap.rb +36 -0
- data/config/locales/en.yml +101 -0
- data/config/routes.rb +65 -0
- data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
- data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
- data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
- data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
- data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
- data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
- data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
- data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
- data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
- data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
- data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
- data/exe/ruby_cms +25 -0
- data/lib/generators/ruby_cms/install_generator.rb +1062 -0
- data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
- data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
- data/lib/ruby_cms/app_integration.rb +82 -0
- data/lib/ruby_cms/cli.rb +169 -0
- data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
- data/lib/ruby_cms/content_blocks_sync.rb +329 -0
- data/lib/ruby_cms/css_compiler.rb +35 -0
- data/lib/ruby_cms/engine.rb +498 -0
- data/lib/ruby_cms/settings.rb +145 -0
- data/lib/ruby_cms/settings_registry.rb +289 -0
- data/lib/ruby_cms/version.rb +5 -0
- data/lib/ruby_cms.rb +195 -0
- data/lib/tasks/ruby_cms.rake +27 -0
- data/log/test.log +17875 -0
- data/sig/ruby_cms.rbs +4 -0
- 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
|