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,220 @@
|
|
|
1
|
+
<%
|
|
2
|
+
nav_main_section = "main"
|
|
3
|
+
nav_bottom_section = "Settings"
|
|
4
|
+
nav_entries = ruby_cms_nav_entries
|
|
5
|
+
nav_main_entries = nav_entries.select { |e| (e[:section].to_s.presence || nav_main_section) == nav_main_section }
|
|
6
|
+
nav_bottom_entries = nav_entries.select { |e| (e[:section].to_s.presence || nav_main_section) != nav_main_section }
|
|
7
|
+
nav_item_entries = @active_tab == "navigation" ? @entries_for_tab.reject { |e| e.key == "nav_order" } : []
|
|
8
|
+
nav_sub = params[:nav_sub].to_s.presence || "order"
|
|
9
|
+
entries_for_grid = @active_tab == "navigation" ? [] : @entries_for_tab
|
|
10
|
+
is_navigation = @active_tab == "navigation"
|
|
11
|
+
settings_url = ruby_cms_admin_settings_path
|
|
12
|
+
nav_order_url = ruby_cms_admin_settings_nav_order_path
|
|
13
|
+
%>
|
|
14
|
+
<%= AdminPage(
|
|
15
|
+
title: t("ruby_cms.admin.settings.title", default: "Settings"),
|
|
16
|
+
subtitle: t("ruby_cms.admin.settings.subtitle", default: "Configure your admin interface preferences."),
|
|
17
|
+
content_card: false
|
|
18
|
+
) do %>
|
|
19
|
+
<div class="grid gap-6 lg:grid-cols-[240px_1fr]">
|
|
20
|
+
<%# Left nav %>
|
|
21
|
+
<aside class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
|
|
22
|
+
<div class="px-4 py-3 border-b border-gray-100 bg-white">
|
|
23
|
+
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
24
|
+
<%= t("ruby_cms.admin.settings.categories_title", default: "Categories") %>
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
<nav class="p-2 space-y-1" aria-label="Settings categories">
|
|
28
|
+
<% @categories.each do |category| %>
|
|
29
|
+
<% cfg = settings_tab_config(category) %>
|
|
30
|
+
<% category_s = category.to_s %>
|
|
31
|
+
<% active = (@active_tab.to_s == category_s) %>
|
|
32
|
+
<%= link_to ruby_cms_admin_settings_path(tab: category_s),
|
|
33
|
+
class: "group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors no-underline #{active ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50'}",
|
|
34
|
+
data: { turbo_frame: "_top" } do %>
|
|
35
|
+
<span class="inline-flex h-7 w-7 items-center justify-center rounded-md border text-sm #{active ? 'border-gray-200 bg-white text-gray-900' : 'border-transparent bg-gray-50 text-gray-600 group-hover:bg-white group-hover:border-gray-200'}" aria-hidden="true">
|
|
36
|
+
<%= ruby_cms_safe_svg_fragment(cfg[:icon]) %>
|
|
37
|
+
</span>
|
|
38
|
+
<span class="flex-1"><%= settings_tab_label(category_s) %></span>
|
|
39
|
+
<% if active %>
|
|
40
|
+
<span class="h-2 w-2 rounded-full bg-gray-900" aria-hidden="true"></span>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
</nav>
|
|
45
|
+
</aside>
|
|
46
|
+
|
|
47
|
+
<%# Right content %>
|
|
48
|
+
<div class="space-y-6">
|
|
49
|
+
<%= turbo_frame_tag "settings_content" do %>
|
|
50
|
+
<% tab_description = settings_tab_description(@active_tab) %>
|
|
51
|
+
<% if tab_description.present? %>
|
|
52
|
+
<p class="text-sm text-gray-500"><%= tab_description %></p>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<% if is_navigation %>
|
|
56
|
+
<div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
|
|
57
|
+
<div class="px-6 py-4 border-b border-gray-100">
|
|
58
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
59
|
+
<div>
|
|
60
|
+
<h2 class="text-base font-semibold text-gray-900"><%= t("ruby_cms.admin.settings.navigation_title", default: "Navigation") %></h2>
|
|
61
|
+
<p class="mt-1 text-sm text-gray-500"><%= t("ruby_cms.admin.settings.navigation_hint", default: "Control sidebar order and visibility.") %></p>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="inline-flex items-center rounded-md border border-gray-200 bg-white p-1 shadow-sm">
|
|
64
|
+
<%= link_to ruby_cms_admin_settings_path(tab: "navigation", nav_sub: "order"),
|
|
65
|
+
class: "px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline #{nav_sub == 'order' ? 'bg-gray-900 text-white shadow-sm' : 'text-gray-700 hover:bg-gray-50'}",
|
|
66
|
+
data: { turbo_frame: "settings_content" } do %>
|
|
67
|
+
<%= t("ruby_cms.admin.settings.nav_order_tab", default: "Order") %>
|
|
68
|
+
<% end %>
|
|
69
|
+
<%= link_to ruby_cms_admin_settings_path(tab: "navigation", nav_sub: "items"),
|
|
70
|
+
class: "px-3 py-1.5 rounded text-sm font-medium transition-colors no-underline #{nav_sub == 'items' ? 'bg-gray-900 text-white shadow-sm' : 'text-gray-700 hover:bg-gray-50'}",
|
|
71
|
+
data: { turbo_frame: "settings_content" } do %>
|
|
72
|
+
<%= t("ruby_cms.admin.settings.nav_items_tab", default: "Items") %>
|
|
73
|
+
<% end %>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="p-6 space-y-6">
|
|
79
|
+
<% if nav_sub == "order" %>
|
|
80
|
+
<%# Order tab: two sortable lists, auto-saved on drag %>
|
|
81
|
+
<%= form_with url: settings_url, method: :patch, local: true, html: { id: "nav_order_form", class: "space-y-6", data: { turbo: false } } do %>
|
|
82
|
+
<%= hidden_field_tag :tab, "navigation" %>
|
|
83
|
+
<div class="grid gap-6 sm:grid-cols-2">
|
|
84
|
+
<div>
|
|
85
|
+
<div class="flex items-center justify-between mb-2">
|
|
86
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500"><%= t("ruby_cms.nav.main", default: "Main") %></h3>
|
|
87
|
+
<span class="text-xs text-gray-400"><%= t("ruby_cms.admin.settings.drag_hint", default: "Drag to reorder") %></span>
|
|
88
|
+
</div>
|
|
89
|
+
<ul class="rounded-lg border border-gray-200 bg-white divide-y divide-gray-100"
|
|
90
|
+
data-list-type="main"
|
|
91
|
+
data-controller="ruby-cms--nav-order-sortable"
|
|
92
|
+
data-ruby-cms--nav-order-sortable-settings-url-value="<%= nav_order_url %>">
|
|
93
|
+
<% nav_main_entries.each do |entry| %>
|
|
94
|
+
<li class="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-50 transition-colors" data-key="<%= entry[:key] %>" draggable="true">
|
|
95
|
+
<span class="cursor-grab text-gray-400 select-none text-sm" aria-hidden="true">⋮⋮</span>
|
|
96
|
+
<span class="flex-1 text-sm font-medium text-gray-900"><%= entry[:label] %></span>
|
|
97
|
+
<%= hidden_field_tag "nav_order_main[]", entry[:key], id: nil %>
|
|
98
|
+
</li>
|
|
99
|
+
<% end %>
|
|
100
|
+
</ul>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div>
|
|
104
|
+
<div class="flex items-center justify-between mb-2">
|
|
105
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500"><%= t("ruby_cms.nav.settings", default: "Settings") %></h3>
|
|
106
|
+
<span class="text-xs text-gray-400"><%= t("ruby_cms.admin.settings.drag_hint", default: "Drag to reorder") %></span>
|
|
107
|
+
</div>
|
|
108
|
+
<ul class="rounded-lg border border-gray-200 bg-white divide-y divide-gray-100"
|
|
109
|
+
data-list-type="bottom"
|
|
110
|
+
data-controller="ruby-cms--nav-order-sortable"
|
|
111
|
+
data-ruby-cms--nav-order-sortable-settings-url-value="<%= nav_order_url %>">
|
|
112
|
+
<% nav_bottom_entries.each do |entry| %>
|
|
113
|
+
<li class="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-50 transition-colors" data-key="<%= entry[:key] %>" draggable="true">
|
|
114
|
+
<span class="cursor-grab text-gray-400 select-none text-sm" aria-hidden="true">⋮⋮</span>
|
|
115
|
+
<span class="flex-1 text-sm font-medium text-gray-900"><%= entry[:label] %></span>
|
|
116
|
+
<%= hidden_field_tag "nav_order_bottom[]", entry[:key], id: nil %>
|
|
117
|
+
</li>
|
|
118
|
+
<% end %>
|
|
119
|
+
</ul>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div class="rounded-md border border-gray-200 bg-gray-50 px-4 py-3">
|
|
124
|
+
<p class="text-sm text-gray-700">
|
|
125
|
+
<%= t("ruby_cms.admin.settings.nav_order_hint", default: "Drag items to reorder. Changes save automatically.") %>
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
<% end %>
|
|
129
|
+
<% else %>
|
|
130
|
+
<%# Items tab: Show in sidebar — list of toggles %>
|
|
131
|
+
<div>
|
|
132
|
+
<h3 class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.settings.show_in_sidebar", default: "Show in sidebar") %></h3>
|
|
133
|
+
<p class="mt-1 text-sm text-gray-500"><%= t("ruby_cms.admin.settings.show_in_sidebar_hint", default: "Choose which sections appear in your sidebar.") %></p>
|
|
134
|
+
|
|
135
|
+
<% if nav_item_entries.any? %>
|
|
136
|
+
<ul class="mt-4 divide-y divide-gray-100 rounded-lg border border-gray-200 bg-white">
|
|
137
|
+
<% nav_item_entries.each do |entry| %>
|
|
138
|
+
<li class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-gray-50 transition-colors">
|
|
139
|
+
<div class="min-w-0 flex-1">
|
|
140
|
+
<span class="block text-sm font-medium text-gray-900"><%= setting_label(entry) %></span>
|
|
141
|
+
<% if entry.description.present? %>
|
|
142
|
+
<span class="mt-0.5 block text-xs text-gray-500"><%= entry.description %></span>
|
|
143
|
+
<% else %>
|
|
144
|
+
<span class="mt-0.5 block text-xs text-gray-500"><%= t("ruby_cms.admin.settings.show_in_nav", default: "Show in navigation") %></span>
|
|
145
|
+
<% end %>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="flex-shrink-0">
|
|
148
|
+
<%= render_setting_field(entry: entry, value: @values[entry.key], tab: @active_tab) %>
|
|
149
|
+
</div>
|
|
150
|
+
</li>
|
|
151
|
+
<% end %>
|
|
152
|
+
</ul>
|
|
153
|
+
<% else %>
|
|
154
|
+
<p class="py-8 text-center text-sm text-gray-500"><%= t("ruby_cms.admin.settings.no_nav_items", default: "No item toggles in this category.") %></p>
|
|
155
|
+
<% end %>
|
|
156
|
+
</div>
|
|
157
|
+
<% end %>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<% else %>
|
|
161
|
+
<% if entries_for_grid.any? %>
|
|
162
|
+
<div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
|
|
163
|
+
<div class="px-6 py-4 border-b border-gray-100">
|
|
164
|
+
<h2 class="text-base font-semibold text-gray-900"><%= settings_tab_label(@active_tab) %></h2>
|
|
165
|
+
<p class="mt-1 text-sm text-gray-500"><%= t("ruby_cms.admin.settings.form_hint", default: "Changes are saved when you click Save Changes, or automatically when toggles change.") %></p>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<%= form_with url: settings_url, method: :patch, local: true, data: { turbo: false } do %>
|
|
169
|
+
<%= hidden_field_tag :tab, @active_tab %>
|
|
170
|
+
<div class="p-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
171
|
+
<% entries_for_grid.each do |entry| %>
|
|
172
|
+
<div class="space-y-2">
|
|
173
|
+
<%= label_tag "pref_#{entry.key}", setting_label(entry), class: "block text-sm font-medium text-gray-700" %>
|
|
174
|
+
<% if entry.description.present? %>
|
|
175
|
+
<p class="text-xs text-gray-500"><%= entry.description %></p>
|
|
176
|
+
<% end %>
|
|
177
|
+
<%= render_setting_field(entry: entry, value: @values[entry.key], tab: @active_tab) %>
|
|
178
|
+
</div>
|
|
179
|
+
<% end %>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div class="px-6 py-4 border-t border-gray-100 bg-gray-50 flex flex-wrap items-center justify-between gap-3">
|
|
183
|
+
<p class="text-xs text-gray-500">
|
|
184
|
+
<%= t("ruby_cms.admin.settings.save_hint", default: "Tip: toggles autosave, other fields save on submit.") %>
|
|
185
|
+
</p>
|
|
186
|
+
<%= submit_tag t("ruby_cms.admin.settings.save_changes", default: "Save Changes"),
|
|
187
|
+
class: "inline-flex h-9 items-center justify-center rounded-md bg-gray-900 px-4 text-sm font-medium text-white shadow-sm hover:bg-gray-800 transition-colors" %>
|
|
188
|
+
</div>
|
|
189
|
+
<% end %>
|
|
190
|
+
</div>
|
|
191
|
+
<% else %>
|
|
192
|
+
<div class="rounded-lg border border-dashed border-gray-200 bg-white p-10 text-center shadow-sm">
|
|
193
|
+
<p class="text-sm text-gray-500"><%= t("ruby_cms.admin.settings.no_settings", default: "No settings in this category") %></p>
|
|
194
|
+
</div>
|
|
195
|
+
<% end %>
|
|
196
|
+
<% end %>
|
|
197
|
+
|
|
198
|
+
<%# Danger zone %>
|
|
199
|
+
<div class="rounded-lg border border-rose-200 bg-rose-50 shadow-sm overflow-hidden">
|
|
200
|
+
<div class="px-6 py-4 border-b border-rose-200/60">
|
|
201
|
+
<h3 class="text-base font-semibold text-rose-900"><%= t("ruby_cms.admin.settings.danger_zone", default: "Danger zone") %></h3>
|
|
202
|
+
<p class="mt-1 text-sm text-rose-800/80"><%= t("ruby_cms.admin.settings.reset_description", default: "Reset this category’s settings back to their default values.") %></p>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="px-6 py-4 flex flex-wrap items-center justify-between gap-3">
|
|
205
|
+
<p class="text-xs text-rose-800/80">
|
|
206
|
+
<%= t("ruby_cms.admin.settings.reset_confirm_hint", default: "This can’t be undone.") %>
|
|
207
|
+
</p>
|
|
208
|
+
<%= form_with url: ruby_cms_admin_settings_reset_defaults_path(tab: @active_tab),
|
|
209
|
+
method: :post,
|
|
210
|
+
local: true,
|
|
211
|
+
data: { turbo: false, turbo_confirm: t("ruby_cms.admin.settings.reset_confirm", default: "Reset all settings to default values?") } do %>
|
|
212
|
+
<%= submit_tag t("ruby_cms.admin.settings.reset_defaults", default: "Reset to Defaults"),
|
|
213
|
+
class: "inline-flex h-9 items-center justify-center rounded-md border border-rose-300 bg-white px-4 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-100 transition-colors" %>
|
|
214
|
+
<% end %>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
<% end %>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<% end %>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<% title = local_assigns[:title] %>
|
|
2
|
+
<% collection = local_assigns[:collection] || [] %>
|
|
3
|
+
<% headers = local_assigns[:headers] || [] %>
|
|
4
|
+
<% action_icons = local_assigns[:action_icons] || [] %>
|
|
5
|
+
<% search_url = local_assigns[:search_url] || "#" %>
|
|
6
|
+
<% search_param = local_assigns[:search_param] || "q" %>
|
|
7
|
+
<% turbo_frame = local_assigns[:turbo_frame] || "admin_table_content" %>
|
|
8
|
+
<% bulk_actions_url = local_assigns[:bulk_actions_url] %>
|
|
9
|
+
<% bulk_action_buttons = local_assigns[:bulk_action_buttons] || [] %>
|
|
10
|
+
<% item_name = local_assigns[:item_name] || "item" %>
|
|
11
|
+
<% pagination = local_assigns[:pagination] %>
|
|
12
|
+
<% pagination_path = local_assigns[:pagination_path] %>
|
|
13
|
+
<% row_partial = local_assigns[:row_partial] %>
|
|
14
|
+
<% row_block = local_assigns[:row_block] %>
|
|
15
|
+
<% row_click_url = local_assigns[:row_click_url] %>
|
|
16
|
+
|
|
17
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTable.new(
|
|
18
|
+
header_title: title,
|
|
19
|
+
header_filter: local_assigns[:header_filter],
|
|
20
|
+
header_action_icons: action_icons,
|
|
21
|
+
header_search_url: search_url,
|
|
22
|
+
header_search_param: search_param,
|
|
23
|
+
turbo_frame: turbo_frame,
|
|
24
|
+
pagination: pagination,
|
|
25
|
+
pagination_path: pagination_path,
|
|
26
|
+
bulk_actions_url: bulk_actions_url,
|
|
27
|
+
bulk_actions_buttons: bulk_action_buttons,
|
|
28
|
+
item_name: item_name,
|
|
29
|
+
controller_name: "ruby-cms--bulk-action-table",
|
|
30
|
+
csrf_token: form_authenticity_token
|
|
31
|
+
) do %>
|
|
32
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTableHeader.new(
|
|
33
|
+
headers: headers,
|
|
34
|
+
bulk_actions_enabled: true,
|
|
35
|
+
controller_name: "ruby-cms--bulk-action-table"
|
|
36
|
+
) %>
|
|
37
|
+
|
|
38
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTableBody.new do %>
|
|
39
|
+
<% collection.each do |item| %>
|
|
40
|
+
<% next if item.nil? %>
|
|
41
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTableRow.new(
|
|
42
|
+
click_url: row_click_url.is_a?(Proc) ? row_click_url.call(item) : nil,
|
|
43
|
+
data: { item_id: item.id },
|
|
44
|
+
bulk_actions_enabled: true,
|
|
45
|
+
controller_name: "ruby-cms--bulk-action-table",
|
|
46
|
+
class: local_assigns[:row_class].is_a?(Proc) ? local_assigns[:row_class].call(item) : nil
|
|
47
|
+
) do %>
|
|
48
|
+
<% if row_partial %>
|
|
49
|
+
<%= render partial: row_partial, locals: { item.model_name.element.to_sym => item } %>
|
|
50
|
+
<% elsif row_block %>
|
|
51
|
+
<%= row_block.call(item) %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%= AdminPage(
|
|
2
|
+
title: "User permissions",
|
|
3
|
+
subtitle: (@user.try(:email_address) || @user.try(:email) || "User ##{@user.id}"),
|
|
4
|
+
content_card: false
|
|
5
|
+
) do %>
|
|
6
|
+
<div class="space-y-6">
|
|
7
|
+
<div class="flex items-center justify-between gap-4">
|
|
8
|
+
<%= link_to "Back to users", ruby_cms_admin_users_path, class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="rounded-lg border border-gray-200/80 bg-white p-6 shadow-sm">
|
|
12
|
+
<p class="text-sm font-semibold text-gray-900">Grant permission</p>
|
|
13
|
+
<%= form_with url: ruby_cms_admin_user_permissions_path(@user), method: :post, class: "mt-4 flex items-end gap-2" do |f| %>
|
|
14
|
+
<div class="flex-1">
|
|
15
|
+
<%= f.select :permission_id,
|
|
16
|
+
options_from_collection_for_select(@permissions, :id, :key),
|
|
17
|
+
{ include_blank: "Select…" },
|
|
18
|
+
class: "w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200" %>
|
|
19
|
+
</div>
|
|
20
|
+
<%= f.submit "Grant", class: "h-9 inline-flex items-center justify-center rounded-md bg-gray-900 px-4 text-sm font-medium text-white shadow-sm hover:bg-gray-800 transition-colors" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<%
|
|
25
|
+
begin
|
|
26
|
+
bulk_delete_url = bulk_delete_ruby_cms_admin_user_permissions_path(@user)
|
|
27
|
+
rescue NoMethodError, ActionController::UrlGenerationError
|
|
28
|
+
bulk_delete_url = nil
|
|
29
|
+
end
|
|
30
|
+
%>
|
|
31
|
+
|
|
32
|
+
<%= render_bulk_action_table(
|
|
33
|
+
headers: ["Permission", { text: "Actions", class: "text-right" }],
|
|
34
|
+
turbo_frame: nil,
|
|
35
|
+
bulk_actions_url: bulk_delete_url,
|
|
36
|
+
item_name: "permission"
|
|
37
|
+
) do %>
|
|
38
|
+
<% @user_permissions.each do |up| %>
|
|
39
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTableRow.new(
|
|
40
|
+
data: { item_id: up.id },
|
|
41
|
+
controller_name: "ruby-cms--bulk-action-table"
|
|
42
|
+
) do %>
|
|
43
|
+
<td class="px-6 py-3 text-sm font-medium text-gray-900"><%= up.permission.key %></td>
|
|
44
|
+
<td class="px-6 py-3 text-right">
|
|
45
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTableActions.new(
|
|
46
|
+
delete_path: ruby_cms_admin_user_permission_path(@user, up),
|
|
47
|
+
item_id: up.id,
|
|
48
|
+
controller_name: "ruby-cms--bulk-action-table"
|
|
49
|
+
) %>
|
|
50
|
+
</td>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# Row partial for users table %>
|
|
2
|
+
<% user = local_assigns[:user] %>
|
|
3
|
+
<%= tag.td user.id, class: "px-6 py-3 font-medium text-gray-900 tabular-nums" %>
|
|
4
|
+
<%= tag.td (user.try(:email_address) || user.try(:email) || user.to_s), class: "px-6 py-3 text-gray-700" %>
|
|
5
|
+
<%= tag.td class: "px-6 py-3 text-right" do %>
|
|
6
|
+
<div class="flex items-center justify-end gap-2">
|
|
7
|
+
<%= link_to "Permissions", ruby_cms_admin_user_permissions_path(user), class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
|
|
8
|
+
<%= render RubyCms::Admin::BulkActionTable::BulkActionTableActions.new(
|
|
9
|
+
delete_path: ruby_cms_admin_user_path(user),
|
|
10
|
+
item_id: user.id,
|
|
11
|
+
controller_name: "ruby-cms--bulk-action-table"
|
|
12
|
+
) %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<%
|
|
2
|
+
begin
|
|
3
|
+
bulk_delete_url = bulk_delete_ruby_cms_admin_users_path
|
|
4
|
+
rescue NoMethodError, ActionController::UrlGenerationError
|
|
5
|
+
bulk_delete_url = nil
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
user_class = Object.const_get(Rails.application.config.ruby_cms.user_class_name.presence || "User")
|
|
9
|
+
email_attr = user_class.column_names.include?("email_address") ? :email_address : :email
|
|
10
|
+
%>
|
|
11
|
+
|
|
12
|
+
<% if flash[:alert] %>
|
|
13
|
+
<div class="rounded-md border border-rose-200 bg-rose-50 p-4">
|
|
14
|
+
<p class="text-sm text-rose-700 m-0"><%= flash[:alert] %></p>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<div id="new-user-form" class="hidden rounded-lg border border-gray-200/80 bg-white shadow-sm p-6">
|
|
19
|
+
<h2 class="text-base font-semibold text-gray-900 mb-4">Create User</h2>
|
|
20
|
+
<%= form_with url: ruby_cms_admin_users_path, method: :post do |f| %>
|
|
21
|
+
<div class="space-y-4 max-w-sm">
|
|
22
|
+
<div class="flex flex-col gap-1">
|
|
23
|
+
<%= f.label email_attr, class: "text-sm font-medium text-gray-700" %>
|
|
24
|
+
<%= f.text_field email_attr, class: "h-9 rounded-md border border-gray-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200", placeholder: "user@example.com" %>
|
|
25
|
+
</div>
|
|
26
|
+
<% if user_class.column_names.include?("password") %>
|
|
27
|
+
<div class="flex flex-col gap-1">
|
|
28
|
+
<%= f.label :password, class: "text-sm font-medium text-gray-700" %>
|
|
29
|
+
<%= f.password_field :password, class: "h-9 rounded-md border border-gray-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200" %>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="flex flex-col gap-1">
|
|
32
|
+
<%= f.label :password_confirmation, "Password Confirmation", class: "text-sm font-medium text-gray-700" %>
|
|
33
|
+
<%= f.password_field :password_confirmation, class: "h-9 rounded-md border border-gray-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200" %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
<div class="flex items-center gap-2">
|
|
37
|
+
<%= f.submit "Create User", class: "inline-flex h-9 items-center justify-center rounded-md bg-gray-900 px-4 text-sm font-medium text-white shadow-sm hover:bg-gray-800 transition-colors cursor-pointer" %>
|
|
38
|
+
<%= tag.button "Cancel", type: "button", class: "inline-flex h-9 items-center justify-center rounded-md border border-gray-200 bg-white px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors cursor-pointer", data: { controller: "ruby-cms--toggle", action: "click->ruby-cms--toggle#hide", toggle_target_id: "new-user-form" } %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<%= render partial: "ruby_cms/admin/shared/bulk_action_table_index", locals: {
|
|
45
|
+
title: "Users",
|
|
46
|
+
collection: @users || [],
|
|
47
|
+
headers: ["ID", "Email", { text: "Actions", class: "text-right" }],
|
|
48
|
+
action_icons: [
|
|
49
|
+
{
|
|
50
|
+
url: "#",
|
|
51
|
+
title: "New user",
|
|
52
|
+
color: "blue",
|
|
53
|
+
icon: "M12 4.5v15m7.5-7.5h-15",
|
|
54
|
+
data: {
|
|
55
|
+
controller: "ruby-cms--toggle",
|
|
56
|
+
action: "click->ruby-cms--toggle#toggle",
|
|
57
|
+
toggle_target_id: "new-user-form"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
search_url: ruby_cms_admin_users_path,
|
|
62
|
+
search_param: "q",
|
|
63
|
+
turbo_frame: "admin_table_content",
|
|
64
|
+
bulk_actions_url: bulk_delete_url,
|
|
65
|
+
bulk_action_buttons: [],
|
|
66
|
+
item_name: "user",
|
|
67
|
+
pagination: @pagination,
|
|
68
|
+
pagination_path: @pagination_path,
|
|
69
|
+
row_partial: "ruby_cms/admin/users/row"
|
|
70
|
+
} %>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<% visitor_error = local_assigns.fetch(:visitor_error) %>
|
|
2
|
+
<%= tag.td class: "px-6 py-3" do %>
|
|
3
|
+
<div class="text-sm text-gray-900">
|
|
4
|
+
<div class="font-medium tabular-nums"><%= visitor_error.created_at.strftime("%m/%d") %></div>
|
|
5
|
+
<div class="text-xs text-gray-500 tabular-nums"><%= visitor_error.created_at.strftime("%H:%M") %></div>
|
|
6
|
+
</div>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<%= tag.td class: "px-6 py-3" do %>
|
|
10
|
+
<div class="min-w-0">
|
|
11
|
+
<div class="text-sm font-medium text-gray-900 truncate"><%= visitor_error.request_path %></div>
|
|
12
|
+
<% if visitor_error.request_method != "GET" %>
|
|
13
|
+
<div class="mt-1">
|
|
14
|
+
<span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200"><%= visitor_error.request_method %></span>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%= tag.td class: "px-6 py-3" do %>
|
|
21
|
+
<div class="min-w-0">
|
|
22
|
+
<div class="text-sm font-medium text-gray-900 truncate"><%= visitor_error.error_class %></div>
|
|
23
|
+
<div class="text-sm text-gray-500 truncate" title="<%= visitor_error.error_message %>"><%= visitor_error.error_message.truncate(70) %></div>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<%= tag.td visitor_error.browser_info, class: "px-6 py-3 text-sm text-gray-700" %>
|
|
28
|
+
<%= tag.td visitor_error.ip_address, class: "px-6 py-3 text-sm text-gray-700 tabular-nums" %>
|
|
29
|
+
<%= tag.td class: "px-6 py-3" do %>
|
|
30
|
+
<% if visitor_error.resolved? %>
|
|
31
|
+
<span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-200"><%= t("ruby_cms.admin.visitor_errors.resolved") %></span>
|
|
32
|
+
<% else %>
|
|
33
|
+
<span class="inline-flex items-center rounded-md bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700 ring-1 ring-inset ring-rose-200"><%= t("ruby_cms.admin.visitor_errors.open") %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<%
|
|
2
|
+
begin
|
|
3
|
+
bulk_delete_url = bulk_delete_ruby_cms_admin_visitor_errors_path
|
|
4
|
+
rescue NoMethodError, ActionController::UrlGenerationError
|
|
5
|
+
bulk_delete_url = nil
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
bulk_action_buttons = []
|
|
9
|
+
begin
|
|
10
|
+
bulk_action_buttons << {
|
|
11
|
+
name: "mark_as_resolved",
|
|
12
|
+
label: t("ruby_cms.admin.visitor_errors.mark_as_resolved"),
|
|
13
|
+
url: bulk_mark_as_resolved_ruby_cms_admin_visitor_errors_path,
|
|
14
|
+
action_type: "direct"
|
|
15
|
+
}
|
|
16
|
+
rescue NoMethodError, ActionController::UrlGenerationError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
filter_base = ruby_cms_admin_visitor_errors_path
|
|
20
|
+
header_filter_html = capture do
|
|
21
|
+
%>
|
|
22
|
+
<%= tag.div class: "flex items-center gap-1" do %>
|
|
23
|
+
<%= tag.span "#{t("ruby_cms.admin.visitor_errors.status")}:", class: "text-xs font-medium text-gray-500 mr-1" %>
|
|
24
|
+
<% [[nil, t("ruby_cms.admin.visitor_errors.all")], ["false", t("ruby_cms.admin.visitor_errors.open")], ["true", t("ruby_cms.admin.visitor_errors.resolved")]].each do |resolved_val, label| %>
|
|
25
|
+
<% is_active = (params[:resolved].blank? && resolved_val.nil?) || params[:resolved] == resolved_val %>
|
|
26
|
+
<% q = { search: params[:search], error_type: params[:error_type] }.merge(resolved_val ? { resolved: resolved_val } : {}).compact %>
|
|
27
|
+
<% url = q.any? ? "#{filter_base}?#{q.to_query}" : filter_base %>
|
|
28
|
+
<%= link_to label, url, class: "inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors no-underline #{is_active ? 'bg-gray-900 text-white' : 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'}", data: { turbo_frame: "admin_table_content" } %>
|
|
29
|
+
<% end %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
|
|
33
|
+
<%= render partial: "ruby_cms/admin/shared/bulk_action_table_index", locals: {
|
|
34
|
+
header_filter: header_filter_html,
|
|
35
|
+
title: t("ruby_cms.admin.visitor_errors.title"),
|
|
36
|
+
collection: @visitor_errors || [],
|
|
37
|
+
headers: [
|
|
38
|
+
t("ruby_cms.admin.visitor_errors.time"),
|
|
39
|
+
t("ruby_cms.admin.visitor_errors.page"),
|
|
40
|
+
t("ruby_cms.admin.visitor_errors.error"),
|
|
41
|
+
t("ruby_cms.admin.visitor_errors.browser"),
|
|
42
|
+
t("ruby_cms.admin.visitor_errors.ip"),
|
|
43
|
+
t("ruby_cms.admin.visitor_errors.status")
|
|
44
|
+
],
|
|
45
|
+
action_icons: [],
|
|
46
|
+
search_url: ruby_cms_admin_visitor_errors_path,
|
|
47
|
+
search_param: "search",
|
|
48
|
+
turbo_frame: "admin_table_content",
|
|
49
|
+
bulk_actions_url: bulk_delete_url,
|
|
50
|
+
bulk_action_buttons: bulk_action_buttons,
|
|
51
|
+
item_name: t("ruby_cms.admin.visitor_errors.item_name"),
|
|
52
|
+
pagination: @pagination,
|
|
53
|
+
pagination_path: @pagination_path,
|
|
54
|
+
row_partial: "ruby_cms/admin/visitor_errors/row",
|
|
55
|
+
row_click_url: ->(err) { ruby_cms_admin_visitor_error_path(err) },
|
|
56
|
+
row_class: ->(err) { err.resolved? ? "" : "border-l-4 border-rose-500" }
|
|
57
|
+
} %>
|