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,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
module BulkActionTableHelper
|
|
5
|
+
# Render the bulk action table delete dialog
|
|
6
|
+
# @param controller_name [String] The Stimulus controller name
|
|
7
|
+
# @return [String] Rendered HTML for the delete dialog
|
|
8
|
+
def render_bulk_action_table_delete_dialog(controller_name: "ruby-cms--bulk-action-table")
|
|
9
|
+
render partial: "ruby_cms/admin/shared/bulk_action_table_delete_dialog",
|
|
10
|
+
locals: { controller_name: }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Render bulk actions bar
|
|
14
|
+
# @param controller_name [String]
|
|
15
|
+
# @param item_name [String]
|
|
16
|
+
# @param bulk_actions_url [String]
|
|
17
|
+
# @param bulk_action_buttons [Array<Hash>]
|
|
18
|
+
def render_bulk_actions_bar(
|
|
19
|
+
controller_name: "ruby-cms--bulk-action-table",
|
|
20
|
+
item_name: "item",
|
|
21
|
+
bulk_actions_url: nil,
|
|
22
|
+
bulk_action_buttons: []
|
|
23
|
+
)
|
|
24
|
+
content_tag :div,
|
|
25
|
+
data: { "#{controller_name}_target": "bulkBar" },
|
|
26
|
+
class: "flex-shrink-0 hidden border border-gray-200
|
|
27
|
+
bg-white px-4 py-2 shadow-md" do
|
|
28
|
+
content_tag :div, class: "flex items-center justify-between max-w-full" do
|
|
29
|
+
render_bulk_selection_info(controller_name:, item_name:) +
|
|
30
|
+
render_bulk_action_buttons(controller_name:, bulk_action_buttons:, bulk_actions_url:)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def render_bulk_selection_info(controller_name:, item_name:)
|
|
38
|
+
content_tag :div, class: "flex items-center space-x-3" do
|
|
39
|
+
selected_count_span(controller_name:, item_name:) +
|
|
40
|
+
select_all_button(controller_name:) +
|
|
41
|
+
clear_selection_button
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def selected_count_span(controller_name:, item_name:)
|
|
46
|
+
content_tag(:span,
|
|
47
|
+
"0 #{item_name}(s) selected:",
|
|
48
|
+
data: { "#{controller_name}_target": "selectedCount" },
|
|
49
|
+
class: "text-sm font-medium text-gray-700")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def select_all_button(controller_name:)
|
|
53
|
+
content_tag(:button,
|
|
54
|
+
"Select all",
|
|
55
|
+
type: "button",
|
|
56
|
+
data: {
|
|
57
|
+
"#{controller_name}_target": "selectAllButton",
|
|
58
|
+
action: "click->#{controller_name}#selectAll"
|
|
59
|
+
},
|
|
60
|
+
class: "text-sm
|
|
61
|
+
text-gray-600
|
|
62
|
+
hover:text-gray-900
|
|
63
|
+
hover:underline
|
|
64
|
+
font-medium
|
|
65
|
+
transition-colors
|
|
66
|
+
duration-150")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear_selection_button
|
|
70
|
+
content_tag(:button,
|
|
71
|
+
"Clear selection",
|
|
72
|
+
type: "button",
|
|
73
|
+
data: { action: "click->ruby-cms--bulk-action-table#clearSelection" },
|
|
74
|
+
class: "text-sm
|
|
75
|
+
text-gray-600
|
|
76
|
+
hover:text-gray-900
|
|
77
|
+
hover:underline
|
|
78
|
+
font-medium
|
|
79
|
+
transition-colors
|
|
80
|
+
duration-150")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_bulk_action_buttons(controller_name:, bulk_action_buttons:, bulk_actions_url:)
|
|
84
|
+
content_tag(:div, class: "flex items-center space-x-2") do
|
|
85
|
+
safe_join(
|
|
86
|
+
bulk_action_buttons.map {|cfg| render_bulk_action_button(cfg, controller_name) } +
|
|
87
|
+
[render_bulk_delete_button(controller_name: controller_name, url: bulk_actions_url)]
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_bulk_action_button(button_config, controller_name)
|
|
93
|
+
label = button_label(button_config)
|
|
94
|
+
data_attrs = build_button_data_attrs(button_config, controller_name, label)
|
|
95
|
+
button_class = build_button_class(button_config)
|
|
96
|
+
|
|
97
|
+
content_tag(:button, label, type: "button", data: data_attrs, class: button_class)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def button_label(cfg)
|
|
101
|
+
cfg[:label] || cfg[:text] || cfg[:name]&.humanize || "Button"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_button_data_attrs(cfg, controller_name, label)
|
|
105
|
+
attrs = {
|
|
106
|
+
action: "click->#{controller_name}#showActionDialog",
|
|
107
|
+
action_name: cfg[:name] || cfg[:action_name],
|
|
108
|
+
action_url: cfg[:url]&.to_s
|
|
109
|
+
}
|
|
110
|
+
if %w[redirect].include?(cfg[:action_type]) || cfg[:action] == "redirect"
|
|
111
|
+
attrs[:action_type] =
|
|
112
|
+
"redirect"
|
|
113
|
+
end
|
|
114
|
+
if cfg[:confirm].present?
|
|
115
|
+
attrs[:action_confirm] = cfg[:confirm]
|
|
116
|
+
attrs[:action_label] = label
|
|
117
|
+
end
|
|
118
|
+
attrs
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_button_class(cfg)
|
|
122
|
+
base = "inline-flex
|
|
123
|
+
items-center justify-center rounded-md text-sm
|
|
124
|
+
font-medium ring-offset-background transition-colors
|
|
125
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
|
|
126
|
+
focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
|
|
127
|
+
border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
|
128
|
+
cfg[:class].present? ? "#{base} #{cfg[:class]}" : base
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def render_bulk_delete_button(controller_name:, url:)
|
|
132
|
+
content_tag(:button,
|
|
133
|
+
"Delete Selected",
|
|
134
|
+
type: "button",
|
|
135
|
+
data: {
|
|
136
|
+
action: "click->#{controller_name}#showActionDialog",
|
|
137
|
+
action_name: "delete",
|
|
138
|
+
action_label: "Delete Selected",
|
|
139
|
+
action_confirm: "Are you sure you want to delete the selected items?
|
|
140
|
+
This action cannot be undone.",
|
|
141
|
+
action_url: url&.to_s
|
|
142
|
+
},
|
|
143
|
+
class: "inline-flex items-center justify-center rounded-md text-sm
|
|
144
|
+
font-medium ring-offset-background transition-colors focus-visible:outline-none
|
|
145
|
+
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
|
146
|
+
disabled:pointer-events-none disabled:opacity-50 border border-input
|
|
147
|
+
bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2
|
|
148
|
+
text-red-600 hover:text-red-700 border-red-300 hover:border-red-400")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
module ContentBlocksHelper
|
|
5
|
+
# Renders a content block by key. Rendering uses the block's content_type from the DB
|
|
6
|
+
# (text, rich_text, image, link, list).
|
|
7
|
+
# Usage:
|
|
8
|
+
# content_block("hero_title")
|
|
9
|
+
# content_block("hero_title", "Welcome") # key + default when block missing
|
|
10
|
+
# content_block("hero_title", default: "Welcome") # same via keyword
|
|
11
|
+
# Wraps in a span with data-content-key, data-block-id, and .content-block for editor hooks.
|
|
12
|
+
def content_block(key, default_or_nil=nil, locale: nil, fallback: nil, default: nil,
|
|
13
|
+
translation_namespace: nil, **options)
|
|
14
|
+
# Support content_block("key", "Default") and content_block("key", default: "Default")
|
|
15
|
+
default = default_or_nil.kind_of?(Hash) ? default : (default_or_nil || default)
|
|
16
|
+
cache_opts = options.delete(:cache)
|
|
17
|
+
wrap = options.delete(:wrap)
|
|
18
|
+
wrap = true if wrap.nil?
|
|
19
|
+
|
|
20
|
+
cache_key = cache_key_for_content_block(key, cache_opts)
|
|
21
|
+
|
|
22
|
+
if cache_opts && cache_key
|
|
23
|
+
Rails.cache.fetch(cache_key) do
|
|
24
|
+
render_content_block(key, locale, default, fallback, translation_namespace, options,
|
|
25
|
+
wrap:)
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
render_content_block(key, locale, default, fallback, translation_namespace, options, wrap:)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
alias cms_content_block content_block
|
|
33
|
+
|
|
34
|
+
# Returns plain text (no HTML wrapper)
|
|
35
|
+
def content_block_text(key, locale: nil, default: nil, fallback: nil,
|
|
36
|
+
translation_namespace: nil)
|
|
37
|
+
locale = normalize_locale(locale)
|
|
38
|
+
block = find_content_block(key, locale)
|
|
39
|
+
|
|
40
|
+
content = content_for_block_text(
|
|
41
|
+
block, default, fallback, key, translation_namespace, locale
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
strip_html_tags(content)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
alias cms_content_block_text content_block_text
|
|
48
|
+
|
|
49
|
+
# Returns array of strings from list-type content block
|
|
50
|
+
def content_block_list_items(key, locale: nil, fallback: nil)
|
|
51
|
+
locale = normalize_locale(locale)
|
|
52
|
+
block = find_content_block(key, locale)
|
|
53
|
+
return Array(fallback) if block.blank?
|
|
54
|
+
|
|
55
|
+
raw = block.content.to_s
|
|
56
|
+
items = parse_list_items(raw)
|
|
57
|
+
items.presence || Array(fallback)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
alias cms_content_block_list_items content_block_list_items
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def normalize_locale(locale)
|
|
65
|
+
(locale || I18n.locale.to_s).to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def find_content_block(key, locale)
|
|
69
|
+
hash = content_blocks_from_context
|
|
70
|
+
return find_block_in_hash(hash, key, locale) if hash
|
|
71
|
+
|
|
72
|
+
::ContentBlock.published.find_by_key_and_locale(key, locale:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def content_blocks_from_context
|
|
76
|
+
instance_variable_get(:@content_blocks)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_block_in_hash(hash, key, locale)
|
|
80
|
+
hash.values.find {|b| b.key == key.to_s && b.locale == locale } ||
|
|
81
|
+
hash.values.find {|b| b.key == key.to_s }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_content_block(key, locale, default, fallback, translation_namespace, options,
|
|
85
|
+
wrap: true)
|
|
86
|
+
locale = normalize_locale(locale)
|
|
87
|
+
block = find_content_block(key, locale)
|
|
88
|
+
|
|
89
|
+
if wrap
|
|
90
|
+
render_wrapped_content_block(block, key, locale, default, fallback, translation_namespace, options)
|
|
91
|
+
else
|
|
92
|
+
render_unwrapped_content_block(block, key, locale, default, fallback, translation_namespace)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_wrapped_content_block(block, key, locale, default, fallback, translation_namespace, options)
|
|
97
|
+
content = content_for_block(block, default, fallback, key, translation_namespace, locale)
|
|
98
|
+
wrapper_options = options.dup
|
|
99
|
+
wrapper_options[:tag] = :div if block&.content_type.to_s == "rich_text"
|
|
100
|
+
render_block_wrapper(content, key, wrapper_options)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_unwrapped_content_block(block, key, locale, default, fallback, translation_namespace)
|
|
104
|
+
content = content_for_block_text(block, default, fallback, key, translation_namespace, locale)
|
|
105
|
+
strip_html_tags(content)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_block_wrapper(content, key, options)
|
|
109
|
+
tag_name = options.delete(:tag) || :span
|
|
110
|
+
tag.public_send(tag_name, content, class: build_css_class(options),
|
|
111
|
+
data: build_data_attributes(key, options))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_css_class(options)
|
|
115
|
+
["ruby_cms-content-block", "content-block", options.delete(:class)].compact.join(" ")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_data_attributes(key, options)
|
|
119
|
+
{ content_key: key, block_id: key.to_s }.merge(options.delete(:data).to_h)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def content_for_block(block, default, fallback, key, translation_namespace, locale)
|
|
123
|
+
if block.present? && !block_effectively_blank?(block)
|
|
124
|
+
render_content_by_type(block)
|
|
125
|
+
else
|
|
126
|
+
resolve_fallback(default, fallback, key,
|
|
127
|
+
translation_namespace, locale)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def content_for_block_text(block, default, fallback, key, translation_namespace, locale)
|
|
132
|
+
if block.present? && !block_effectively_blank?(block)
|
|
133
|
+
render_text_content_by_type(block)
|
|
134
|
+
else
|
|
135
|
+
resolve_fallback(default, fallback,
|
|
136
|
+
key, translation_namespace, locale)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Uses block.content_type from the DB (text, rich_text, image, link, list).
|
|
141
|
+
def render_content_by_type(block)
|
|
142
|
+
case block.content_type
|
|
143
|
+
when "rich_text" then render_rich_text_content(block)
|
|
144
|
+
when "image" then content_block_image(block)
|
|
145
|
+
when "link" then content_block_link(block)
|
|
146
|
+
when "list" then content_block_list(block)
|
|
147
|
+
else block.content.to_s
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_text_content_by_type(block)
|
|
152
|
+
case block.content_type
|
|
153
|
+
when "rich_text" then render_rich_text_as_text(block)
|
|
154
|
+
when "image", "link" then block_title_or_content(block)
|
|
155
|
+
when "list" then render_list_as_text(block)
|
|
156
|
+
else block.content.to_s
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Body-only HTML so content stays inside wrapper (no layout div.trix-content / comments).
|
|
161
|
+
def render_rich_text_content(block)
|
|
162
|
+
return block.content.to_s unless action_text_available?(block)
|
|
163
|
+
return block.content.to_s if block.content.present? && !rich_content_body_present?(block)
|
|
164
|
+
|
|
165
|
+
html = rich_content_body_html_for_view(block)
|
|
166
|
+
sanitize_rich_text_html(html.presence || block.content.to_s)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rich_content_body_html_for_view(block)
|
|
170
|
+
return "" unless block.rich_content.respond_to?(:body) && block.rich_content.body.present?
|
|
171
|
+
|
|
172
|
+
b = block.rich_content.body
|
|
173
|
+
out = b.respond_to?(:to_html) ? b.to_html : b.to_s
|
|
174
|
+
out.to_s.strip.presence || ""
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render_rich_text_as_text(block)
|
|
178
|
+
return block.content.to_s unless action_text_available?(block)
|
|
179
|
+
|
|
180
|
+
if rich_content_body_present?(block)
|
|
181
|
+
block.rich_content.to_plain_text
|
|
182
|
+
elsif block.content.present?
|
|
183
|
+
block.content.to_s
|
|
184
|
+
else
|
|
185
|
+
safe_rich_text_to_plain_text(block)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def action_text_available?(block)
|
|
190
|
+
block.class.respond_to?(:action_text_available?) &&
|
|
191
|
+
block.class.action_text_available? &&
|
|
192
|
+
block.respond_to?(:rich_content)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def rich_content_body_present?(block)
|
|
196
|
+
block.rich_content.respond_to?(:body) && block.rich_content.body.present?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def safe_rich_text_to_plain_text(block)
|
|
200
|
+
block.rich_content.to_plain_text
|
|
201
|
+
rescue StandardError
|
|
202
|
+
block.content.to_s
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def render_list_as_text(block)
|
|
206
|
+
parse_list_items(block.content.to_s).join(", ")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def content_block_list(block)
|
|
210
|
+
items = parse_list_items(block.content.to_s)
|
|
211
|
+
return block.content.to_s if items.blank?
|
|
212
|
+
|
|
213
|
+
tag.ul(safe_join(items.map {|i| tag.li(i) }))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parse_list_items(raw)
|
|
217
|
+
parsed = JSON.parse(raw)
|
|
218
|
+
parsed.kind_of?(Array) ? parsed.map(&:to_s) : split_list_lines(raw)
|
|
219
|
+
rescue JSON::ParserError, TypeError
|
|
220
|
+
split_list_lines(raw)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def split_list_lines(raw)
|
|
224
|
+
raw.split("\n").map(&:strip).compact_blank
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def content_block_image(block)
|
|
228
|
+
return block.content.to_s unless block.respond_to?(:image) && block.image.attached?
|
|
229
|
+
|
|
230
|
+
image_tag(block.image, alt: block.title.presence || block.key)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def content_block_link(block)
|
|
234
|
+
url = block.content.to_s.strip
|
|
235
|
+
return block.content.to_s if url.blank? || url.start_with?("javascript:", "data:")
|
|
236
|
+
|
|
237
|
+
link_to(block.title.presence || url, url)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def block_title_or_content(block)
|
|
241
|
+
block.title.presence || block.content.to_s
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Treat "blank" blocks like missing blocks so we can fall back to defaults/translations.
|
|
245
|
+
# This avoids confusing situations where an empty/unpublished DB record overrides a
|
|
246
|
+
# perfectly good I18n fallback (common during initial setup or partial edits).
|
|
247
|
+
def block_effectively_blank?(block)
|
|
248
|
+
case block.content_type.to_s
|
|
249
|
+
when "image" then image_block_blank?(block)
|
|
250
|
+
when "rich_text" then rich_text_block_blank?(block)
|
|
251
|
+
else other_block_blank?(block)
|
|
252
|
+
end
|
|
253
|
+
rescue StandardError
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def image_block_blank?(block)
|
|
258
|
+
return block.content.to_s.blank? unless block.respond_to?(:image)
|
|
259
|
+
|
|
260
|
+
!block.image.attached?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def rich_text_block_blank?(block)
|
|
264
|
+
return block.content.to_s.strip.blank? unless action_text_available?(block) && rich_content_body_present?(block)
|
|
265
|
+
|
|
266
|
+
rich_content_body_html_for_view(block).to_s.strip.blank?
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def other_block_blank?(block)
|
|
270
|
+
block.content.to_s.strip.blank? && block.title.to_s.strip.blank?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def resolve_fallback(default, fallback, key, translation_namespace, locale)
|
|
274
|
+
return fallback.to_s if fallback.present?
|
|
275
|
+
return default.to_s if default.present?
|
|
276
|
+
|
|
277
|
+
translation = find_translation_fallback(key, translation_namespace, locale)
|
|
278
|
+
translation || key.to_s.humanize
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def find_translation_fallback(key, translation_namespace, locale)
|
|
282
|
+
return unless respond_to?(:t)
|
|
283
|
+
|
|
284
|
+
I18n.with_locale(locale) do
|
|
285
|
+
namespace = translation_namespace || translation_namespace_from_config
|
|
286
|
+
try_namespaced_translation(namespace, key) || try_root_translation(key)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def translation_namespace_from_config
|
|
291
|
+
Rails.application.config.ruby_cms.content_blocks_translation_namespace.presence ||
|
|
292
|
+
"content_blocks"
|
|
293
|
+
rescue StandardError
|
|
294
|
+
"content_blocks"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def try_namespaced_translation(namespace, key)
|
|
298
|
+
return if namespace.blank?
|
|
299
|
+
|
|
300
|
+
namespaced_key = "#{namespace}.#{key}"
|
|
301
|
+
translation = t(namespaced_key, default: nil)
|
|
302
|
+
return nil if translation.blank? || translation == namespaced_key
|
|
303
|
+
|
|
304
|
+
translation.to_s
|
|
305
|
+
rescue I18n::MissingTranslationData
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def try_root_translation(key)
|
|
310
|
+
translation = t(key, default: nil)
|
|
311
|
+
return nil if translation.blank? || translation == key.to_s
|
|
312
|
+
|
|
313
|
+
translation.to_s
|
|
314
|
+
rescue I18n::MissingTranslationData
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def strip_html_tags(content)
|
|
319
|
+
if respond_to?(:strip_tags)
|
|
320
|
+
strip_tags(content.to_s)
|
|
321
|
+
else
|
|
322
|
+
content.to_s.gsub(/<[^>]*>/, "").strip
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Allow nested content blocks inside rich text to stay selectable in the visual editor
|
|
327
|
+
# by preserving the data attributes the preview click handler relies on.
|
|
328
|
+
def sanitize_rich_text_html(html)
|
|
329
|
+
str = html.to_s
|
|
330
|
+
return "" if str.blank?
|
|
331
|
+
|
|
332
|
+
base_tags = rails_sanitizer_allowed(:tags)
|
|
333
|
+
base_attrs = rails_sanitizer_allowed(:attributes)
|
|
334
|
+
|
|
335
|
+
extra_attrs = %w[
|
|
336
|
+
data-content-key
|
|
337
|
+
data-block-id
|
|
338
|
+
data-content-target
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
sanitize_with_extra_attrs(str, base_tags, base_attrs, extra_attrs) || sanitize(str)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def sanitize_with_extra_attrs(str, base_tags, base_attrs, extra_attrs)
|
|
345
|
+
return nil unless base_tags && base_attrs
|
|
346
|
+
|
|
347
|
+
sanitize(
|
|
348
|
+
str,
|
|
349
|
+
tags: base_tags,
|
|
350
|
+
attributes: (base_attrs + extra_attrs).uniq
|
|
351
|
+
)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def rails_sanitizer_allowed(kind)
|
|
355
|
+
return nil unless defined?(Rails::Html::SafeListSanitizer)
|
|
356
|
+
|
|
357
|
+
sanitizer = Rails::Html::SafeListSanitizer
|
|
358
|
+
if kind == :tags && sanitizer.respond_to?(:allowed_tags)
|
|
359
|
+
sanitizer.allowed_tags.to_a
|
|
360
|
+
elsif kind == :attributes && sanitizer.respond_to?(:allowed_attributes)
|
|
361
|
+
sanitizer.allowed_attributes.to_a
|
|
362
|
+
end
|
|
363
|
+
rescue StandardError
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def cache_key_for_content_block(key, cache_opts)
|
|
368
|
+
return nil unless cache_opts
|
|
369
|
+
|
|
370
|
+
block = ::ContentBlock.published.find_by(key: key.to_s)
|
|
371
|
+
part = block ? block.cache_key : "nil"
|
|
372
|
+
["ruby_cms", "content_block", key.to_s, part].join("/")
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
module SettingsHelper
|
|
5
|
+
TAB_CONFIG = {
|
|
6
|
+
"general" => { icon: "🏠", fallback_label: "General" },
|
|
7
|
+
"navigation" => { icon: "🧭", fallback_label: "Navigation" },
|
|
8
|
+
"pagination" => { icon: "📄", fallback_label: "Pagination" },
|
|
9
|
+
"analytics" => { icon: "📈", fallback_label: "Analytics" },
|
|
10
|
+
"dashboard" => { icon: "🗂️", fallback_label: "Dashboard" },
|
|
11
|
+
"content" => { icon: "🧱", fallback_label: "Content" }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def settings_tab_config(category)
|
|
15
|
+
TAB_CONFIG[category.to_s] || { icon: "⚙️", fallback_label: category.to_s.humanize }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def settings_tab_label(category)
|
|
19
|
+
cfg = settings_tab_config(category)
|
|
20
|
+
t("ruby_cms.admin.settings.categories.#{category}.label", default: cfg[:fallback_label])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def settings_tab_description(category)
|
|
24
|
+
t("ruby_cms.admin.settings.categories.#{category}.description", default: "")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def setting_label(entry)
|
|
28
|
+
key = entry.key.to_s
|
|
29
|
+
|
|
30
|
+
# Keep familiar labels for nav and pagination keys.
|
|
31
|
+
key = key.delete_prefix("nav_show_")
|
|
32
|
+
key = key.delete_suffix("_per_page")
|
|
33
|
+
|
|
34
|
+
key.tr("_", " ").humanize
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_setting_field(entry:, value:, tab:)
|
|
38
|
+
case entry.type.to_sym
|
|
39
|
+
when :integer
|
|
40
|
+
render_integer_setting_field(entry:, value:, tab:)
|
|
41
|
+
when :boolean
|
|
42
|
+
render_boolean_setting_field(entry:, value:, tab:)
|
|
43
|
+
when :json
|
|
44
|
+
render_json_setting_field(entry:, value:, tab:)
|
|
45
|
+
else
|
|
46
|
+
render_string_setting_field(entry:, value:, tab:)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def input_base_classes
|
|
53
|
+
"w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 " \
|
|
54
|
+
"shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def textarea_base_classes
|
|
58
|
+
"w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 " \
|
|
59
|
+
"shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def render_integer_setting_field(entry:, value:, tab:)
|
|
63
|
+
min, max = integer_bounds_for(entry)
|
|
64
|
+
|
|
65
|
+
number_field_tag(
|
|
66
|
+
"preferences[#{entry.key}]",
|
|
67
|
+
value,
|
|
68
|
+
id: setting_input_id(entry),
|
|
69
|
+
class: input_base_classes,
|
|
70
|
+
min: min,
|
|
71
|
+
max: max,
|
|
72
|
+
data: autosave_data(entry.key, tab)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_boolean_setting_field(entry:, value:, tab:)
|
|
77
|
+
checked = ActiveModel::Type::Boolean.new.cast(value)
|
|
78
|
+
hidden = hidden_field_tag("preferences[#{entry.key}]", "false")
|
|
79
|
+
checkbox = check_box_tag(
|
|
80
|
+
"preferences[#{entry.key}]",
|
|
81
|
+
"true",
|
|
82
|
+
checked,
|
|
83
|
+
id: setting_input_id(entry),
|
|
84
|
+
class: "peer sr-only",
|
|
85
|
+
data: autosave_data(entry.key, tab)
|
|
86
|
+
)
|
|
87
|
+
label = content_tag(
|
|
88
|
+
:label,
|
|
89
|
+
"",
|
|
90
|
+
for: setting_input_id(entry),
|
|
91
|
+
class: "relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full " \
|
|
92
|
+
"bg-gray-200 transition-colors peer-checked:bg-teal-600"
|
|
93
|
+
) do
|
|
94
|
+
content_tag(
|
|
95
|
+
:span,
|
|
96
|
+
"",
|
|
97
|
+
class: "inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition " \
|
|
98
|
+
"translate-x-0.5 peer-checked:translate-x-5"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
content_tag(:div, class: "inline-flex items-center") { hidden + checkbox + label }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_json_setting_field(entry:, value:, tab:)
|
|
106
|
+
formatted = if value.kind_of?(Hash) || value.kind_of?(Array)
|
|
107
|
+
JSON.pretty_generate(value)
|
|
108
|
+
else
|
|
109
|
+
value.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
text_area_tag(
|
|
113
|
+
"preferences[#{entry.key}]",
|
|
114
|
+
formatted,
|
|
115
|
+
id: setting_input_id(entry),
|
|
116
|
+
class: textarea_base_classes,
|
|
117
|
+
rows: 4,
|
|
118
|
+
data: autosave_data(entry.key, tab)
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def render_string_setting_field(entry:, value:, tab:)
|
|
123
|
+
text_field_tag(
|
|
124
|
+
"preferences[#{entry.key}]",
|
|
125
|
+
value,
|
|
126
|
+
id: setting_input_id(entry),
|
|
127
|
+
class: input_base_classes,
|
|
128
|
+
data: autosave_data(entry.key, tab)
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def autosave_data(key, tab)
|
|
133
|
+
{
|
|
134
|
+
controller: "ruby-cms--auto-save-preference",
|
|
135
|
+
action: "change->ruby-cms--auto-save-preference#save",
|
|
136
|
+
ruby_cms__auto_save_preference_preference_key_value: key.to_s,
|
|
137
|
+
ruby_cms__auto_save_preference_settings_url_value: ruby_cms_admin_settings_path,
|
|
138
|
+
ruby_cms__auto_save_preference_tab_value: tab.to_s
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def setting_input_id(entry)
|
|
143
|
+
"pref_#{entry.key}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def integer_bounds_for(entry)
|
|
147
|
+
key = entry.key.to_s
|
|
148
|
+
|
|
149
|
+
if key.end_with?("_per_page")
|
|
150
|
+
min = RubyCms::Settings.get(:pagination_min_per_page, default: 5).to_i
|
|
151
|
+
max = RubyCms::Settings.get(:pagination_max_per_page, default: 200).to_i
|
|
152
|
+
return [min, [max, min].max]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
[nil, nil]
|
|
156
|
+
rescue StandardError
|
|
157
|
+
[nil, nil]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|