ruby_cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,84 @@
1
+ <%
2
+ high_volume = @page_stats[:total_views].to_i > 100
3
+ suspicious_ua = @page_views.any? { |e| e.visit&.user_agent.to_s.include?("bot") || e.visit&.user_agent.to_s.include?("crawler") }
4
+ show_security = high_volume || suspicious_ua
5
+ %>
6
+ <%= AdminPage(
7
+ title: t("ruby_cms.admin.analytics.page_details_title", default: "Page details"),
8
+ subtitle: "#{@start_date} → #{@end_date}",
9
+ content_card: false
10
+ ) do %>
11
+ <div class="space-y-6">
12
+ <div class="flex items-start justify-between gap-4">
13
+ <div class="min-w-0">
14
+ <p class="text-sm font-medium text-gray-500">Page</p>
15
+ <p class="mt-1 text-lg font-semibold tracking-tight text-gray-900 truncate"><%= @page_name %></p>
16
+ </div>
17
+ <div class="flex items-center gap-2 flex-shrink-0">
18
+ <%= render "ruby_cms/admin/analytics/partials/security_alert" if show_security %>
19
+ <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="grid grid-cols-3 gap-4">
24
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
25
+ <p class="text-sm font-medium text-gray-500">Total views</p>
26
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@page_stats[:total_views]) %></p>
27
+ </div>
28
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
29
+ <p class="text-sm font-medium text-gray-500">Unique visitors</p>
30
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@page_stats[:unique_visitors]) %></p>
31
+ </div>
32
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
33
+ <p class="text-sm font-medium text-gray-500">Avg/day</p>
34
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_precision(@page_stats[:avg_views_per_day], precision: 1) %></p>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
39
+ <div class="px-6 py-4 border-b border-gray-100">
40
+ <p class="text-sm font-semibold text-gray-900">Page views</p>
41
+ <p class="text-sm text-gray-500">Recent views for this page.</p>
42
+ </div>
43
+
44
+ <div class="overflow-x-auto">
45
+ <table class="min-w-full text-sm">
46
+ <thead class="bg-gray-50">
47
+ <tr class="text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
48
+ <th class="px-6 py-3">IP Address</th>
49
+ <th class="px-6 py-3">Browser</th>
50
+ <th class="px-6 py-3">Referrer</th>
51
+ <th class="px-6 py-3">Time</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody class="divide-y divide-gray-100">
55
+ <% if @page_views.any? %>
56
+ <% @page_views.each do |event| %>
57
+ <% visit = event.visit %>
58
+ <% ip = visit&.ip %>
59
+ <tr class="hover:bg-gray-50 transition-colors">
60
+ <td class="px-6 py-3 font-medium text-gray-900 tabular-nums">
61
+ <% if ip.present? && ip != "Unknown" %>
62
+ <%= link_to ip,
63
+ visitor_details_ruby_cms_admin_analytics_path(ip_address: ip, start_date: @start_date, end_date: @end_date, period: @period),
64
+ class: "text-gray-900 hover:underline" %>
65
+ <% else %>
66
+ <span class="text-gray-500"><%= ip || "Unknown" %></span>
67
+ <% end %>
68
+ </td>
69
+ <td class="px-6 py-3 text-gray-700"><%= visit&.browser || "Unknown" %></td>
70
+ <td class="px-6 py-3 text-gray-700 truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
71
+ <td class="px-6 py-3 text-gray-700"><%= time_ago_in_words(event.time) %> ago</td>
72
+ </tr>
73
+ <% end %>
74
+ <% else %>
75
+ <tr>
76
+ <td colspan="4" class="px-6 py-10 text-center text-sm text-gray-500">No page views recorded for this page</td>
77
+ </tr>
78
+ <% end %>
79
+ </tbody>
80
+ </table>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <%= link_to path, class: "inline-flex items-center justify-center rounded-md border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-900 shadow-sm hover:bg-gray-50 transition-colors" do %>
2
+ ← <%= t("ruby_cms.admin.analytics.back", default: "Back") %>
3
+ <% end %>
@@ -0,0 +1,40 @@
1
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-gray-100">
3
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.browsers_devices", default: "Browsers & devices") %></p>
4
+ <p class="text-sm text-gray-500">Environment breakdown.</p>
5
+ </div>
6
+
7
+ <div class="px-6 py-4 space-y-4">
8
+ <% if @browser_stats.present? && @browser_stats.any? %>
9
+ <div>
10
+ <p class="text-xs font-semibold uppercase tracking-wider text-gray-500">Browsers</p>
11
+ <ul class="mt-2 space-y-2">
12
+ <% @browser_stats.sort_by { |_, v| -v }.first(4).each do |browser, count| %>
13
+ <li class="flex items-center justify-between gap-3">
14
+ <span class="text-sm text-gray-700 truncate"><%= browser %></span>
15
+ <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
16
+ </li>
17
+ <% end %>
18
+ </ul>
19
+ </div>
20
+ <% end %>
21
+
22
+ <% if @device_stats.present? && @device_stats.any? %>
23
+ <div>
24
+ <p class="text-xs font-semibold uppercase tracking-wider text-gray-500">Devices</p>
25
+ <ul class="mt-2 space-y-2">
26
+ <% @device_stats.sort_by { |_, v| -v }.first(4).each do |device, count| %>
27
+ <li class="flex items-center justify-between gap-3">
28
+ <span class="text-sm text-gray-700 truncate"><%= device %></span>
29
+ <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+ </div>
34
+ <% end %>
35
+
36
+ <% if (@browser_stats.blank? || @browser_stats.empty?) && (@device_stats.blank? || @device_stats.empty?) %>
37
+ <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
38
+ <% end %>
39
+ </div>
40
+ </div>
@@ -0,0 +1,58 @@
1
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
+ <div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-gray-100">
3
+ <div>
4
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.daily_activity", default: "Daily activity") %></p>
5
+ <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.page_views_visitors_over_time", default: "Page views and visitors over time") %></p>
6
+ </div>
7
+ <div class="flex items-center gap-3 text-sm text-gray-500">
8
+ <span class="inline-flex items-center gap-2"><span class="h-2 w-2 rounded-full bg-sky-500"></span><%= t("ruby_cms.admin.analytics.views", default: "Views") %></span>
9
+ <span class="inline-flex items-center gap-2"><span class="h-2 w-2 rounded-full bg-emerald-500"></span><%= t("ruby_cms.admin.analytics.visitors", default: "Visitors") %></span>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="px-6 py-4">
14
+ <% if @daily_activity.present? && @daily_activity.any? %>
15
+ <%
16
+ daily_activity_arr = @daily_activity.is_a?(Hash) ? @daily_activity.to_a : @daily_activity
17
+ max_views = daily_activity_arr.map { |_k, v| v.to_i }.max || 0
18
+ max_visitors = (@daily_visitors || {}).values.map(&:to_i).max || 0
19
+ max_value = [max_views, max_visitors].max
20
+ max_value = 1 if max_value.zero?
21
+ chart_height = 200
22
+ %>
23
+
24
+ <div class="grid grid-cols-[3.5rem_1fr] gap-4">
25
+ <div class="flex flex-col justify-between h-[<%= chart_height %>px] text-xs text-gray-500">
26
+ <% [100, 75, 50, 25, 0].each do |pct| %>
27
+ <div class="tabular-nums"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
28
+ <% end %>
29
+ </div>
30
+
31
+ <div class="relative">
32
+ <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
33
+ <% 5.times do %>
34
+ <div class="h-px bg-gray-100"></div>
35
+ <% end %>
36
+ </div>
37
+
38
+ <div class="h-[<%= chart_height %>px] flex items-end gap-2">
39
+ <% daily_activity_arr.each do |date, views_count| %>
40
+ <% visitors_count = (@daily_visitors || {})[date] || 0 %>
41
+ <% views_h = [((views_count.to_f / max_value) * chart_height).round, 2].max %>
42
+ <% visitors_h = [((visitors_count.to_f / max_value) * chart_height).round, 2].max %>
43
+ <div class="flex flex-col items-center gap-2 flex-1 min-w-0">
44
+ <div class="w-full flex items-end justify-center gap-1" title="<%= format_chart_date(date) %> — Views: <%= number_with_delimiter(views_count) %>, Visitors: <%= number_with_delimiter(visitors_count) %>">
45
+ <div class="w-2 rounded-sm bg-sky-500/90" style="height:<%= views_h %>px"></div>
46
+ <div class="w-2 rounded-sm bg-emerald-500/90" style="height:<%= visitors_h %>px"></div>
47
+ </div>
48
+ <div class="text-[11px] text-gray-500 truncate w-full text-center"><%= format_chart_date_short(date) %></div>
49
+ </div>
50
+ <% end %>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ <% else %>
55
+ <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_daily_data", default: "No daily activity data") %></p>
56
+ <% end %>
57
+ </div>
58
+ </div>
@@ -0,0 +1,51 @@
1
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-gray-100">
3
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly activity") %></p>
4
+ <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour") %></p>
5
+ </div>
6
+
7
+ <div class="px-6 py-4">
8
+ <% if @hourly_activity.present? && @hourly_activity.any? %>
9
+ <%
10
+ hourly_arr = @hourly_activity.is_a?(Hash) ? @hourly_activity.to_a : @hourly_activity
11
+ grouped = hourly_arr.each_slice(3).map do |group|
12
+ hours = group.map(&:first)
13
+ total = group.sum { |_, c| c.to_i }
14
+ [hours, total]
15
+ end
16
+ max_value = grouped.map { |_, c| c }.max || 1
17
+ chart_height = 200
18
+ %>
19
+
20
+ <div class="grid grid-cols-[3.5rem_1fr] gap-4">
21
+ <div class="flex flex-col justify-between h-[<%= chart_height %>px] text-xs text-gray-500">
22
+ <% [100, 75, 50, 25, 0].each do |pct| %>
23
+ <div class="tabular-nums"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
24
+ <% end %>
25
+ </div>
26
+
27
+ <div class="relative">
28
+ <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
29
+ <% 5.times do %>
30
+ <div class="h-px bg-gray-100"></div>
31
+ <% end %>
32
+ </div>
33
+
34
+ <div class="h-[<%= chart_height %>px] flex items-end gap-2">
35
+ <% grouped.each do |hours, count| %>
36
+ <% h = [((count.to_f / max_value) * chart_height).round, 2].max %>
37
+ <div class="flex flex-col items-center gap-2 flex-1 min-w-0">
38
+ <div class="w-full flex items-end justify-center" title="<%= hours.size > 1 ? "#{hours.first}:00-#{hours.last}:00" : "#{hours.first}:00" %> — <%= number_with_delimiter(count) %> views">
39
+ <div class="w-3 rounded-sm bg-emerald-500/90" style="height:<%= h %>px"></div>
40
+ </div>
41
+ <div class="text-[11px] text-gray-500 truncate w-full text-center"><%= hours.size > 1 ? "#{hours.first}-#{hours.last}h" : "#{hours.first}h" %></div>
42
+ </div>
43
+ <% end %>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ <% else %>
48
+ <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_hourly_data", default: "No hourly activity data") %></p>
49
+ <% end %>
50
+ </div>
51
+ </div>
@@ -0,0 +1,31 @@
1
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-gray-100">
3
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.recent_activity", default: "Recent activity") %></p>
4
+ <p class="text-sm text-gray-500">Latest page views.</p>
5
+ </div>
6
+
7
+ <% if @recent_page_views.present? && @recent_page_views.any? %>
8
+ <div class="divide-y divide-gray-100">
9
+ <% @recent_page_views.first(6).each do |event| %>
10
+ <% page_name = event.respond_to?(:page_name) ? event.page_name : nil %>
11
+ <% if page_name.present? %>
12
+ <%= link_to page_details_ruby_cms_admin_analytics_path(page_name: page_name, start_date: @start_date, end_date: @end_date, period: @period),
13
+ class: "block px-6 py-4 hover:bg-gray-50 transition-colors" do %>
14
+ <p class="text-sm font-medium text-gray-900 truncate"><%= page_name.to_s.humanize %></p>
15
+ <p class="mt-1 text-sm text-gray-500"><%= time_ago_in_words(event.time) %> ago</p>
16
+ <% end %>
17
+ <% else %>
18
+ <div class="px-6 py-4">
19
+ <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.analytics.page", default: "Page") %></p>
20
+ <p class="mt-1 text-sm text-gray-500"><%= time_ago_in_words(event.time) %> ago</p>
21
+ </div>
22
+ <% end %>
23
+ <% end %>
24
+ </div>
25
+ <% else %>
26
+ <div class="px-6 py-12 text-center">
27
+ <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.analytics.no_activity", default: "No activity recorded") %></p>
28
+ <p class="mt-1 text-sm text-gray-500">No page views in selected range.</p>
29
+ </div>
30
+ <% end %>
31
+ </div>
@@ -0,0 +1,4 @@
1
+ <div class="inline-flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm font-medium text-amber-900">
2
+ <span aria-hidden="true">⚠</span>
3
+ <span><%= t("ruby_cms.admin.analytics.security_alert", default: "Security alert") %></span>
4
+ </div>
@@ -0,0 +1,21 @@
1
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-gray-100">
3
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.top_referrers", default: "Top referrers") %></p>
4
+ <p class="text-sm text-gray-500">Where visitors came from.</p>
5
+ </div>
6
+
7
+ <div class="px-6 py-4">
8
+ <% if @top_referrers.present? && @top_referrers.any? %>
9
+ <ul class="space-y-2">
10
+ <% @top_referrers.first(8).each do |referrer, count| %>
11
+ <li class="flex items-center justify-between gap-3">
12
+ <span class="text-sm text-gray-700 truncate" title="<%= referrer %>"><%= referrer %></span>
13
+ <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
14
+ </li>
15
+ <% end %>
16
+ </ul>
17
+ <% else %>
18
+ <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_referrers", default: "No referrers recorded") %></p>
19
+ <% end %>
20
+ </div>
21
+ </div>
@@ -0,0 +1,125 @@
1
+ <%
2
+ high_volume = @visitor_stats[:total_views].to_i > 100
3
+ first_visit = @visitor_stats[:first_visit]
4
+ last_visit = @visitor_stats[:last_visit]
5
+ rapid = @visitor_views.count > 50 && first_visit && last_visit && (last_visit - first_visit).to_i < 1
6
+ suspicious_ua = @visitor_views.any? { |e| e.visit&.user_agent.to_s.include?("bot") || e.visit&.user_agent.to_s.include?("crawler") }
7
+ show_security = high_volume || rapid || suspicious_ua
8
+ %>
9
+ <%= AdminPage(
10
+ title: t("ruby_cms.admin.analytics.visitor_details_title", default: "Visitor details"),
11
+ subtitle: "#{@start_date} → #{@end_date}",
12
+ content_card: false
13
+ ) do %>
14
+ <div class="space-y-6">
15
+ <div class="flex items-start justify-between gap-4">
16
+ <div class="min-w-0">
17
+ <p class="text-sm font-medium text-gray-500">IP Address</p>
18
+ <p class="mt-1 text-lg font-semibold tracking-tight text-gray-900 tabular-nums truncate"><%= @ip_address %></p>
19
+ </div>
20
+ <div class="flex items-center gap-2 flex-shrink-0">
21
+ <%= render "ruby_cms/admin/analytics/partials/security_alert" if show_security %>
22
+ <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="grid grid-cols-4 gap-4">
27
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
28
+ <p class="text-sm font-medium text-gray-500">Total views</p>
29
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@visitor_stats[:total_views]) %></p>
30
+ </div>
31
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
32
+ <p class="text-sm font-medium text-gray-500">Pages visited</p>
33
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@visitor_stats[:unique_pages]) %></p>
34
+ </div>
35
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
36
+ <p class="text-sm font-medium text-gray-500">First visit</p>
37
+ <p class="mt-2 text-base font-semibold text-gray-900"><%= @visitor_stats[:first_visit].present? ? l(@visitor_stats[:first_visit], format: :short) : "—" %></p>
38
+ </div>
39
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
40
+ <p class="text-sm font-medium text-gray-500">Last visit</p>
41
+ <p class="mt-2 text-base font-semibold text-gray-900"><%= @visitor_stats[:last_visit].present? ? l(@visitor_stats[:last_visit], format: :short) : "—" %></p>
42
+ </div>
43
+ </div>
44
+
45
+ <% first_visit_record = Ahoy::Visit.where(ip: @ip_address, started_at: @start_date.beginning_of_day..@end_date.end_of_day).order(started_at: :asc).first %>
46
+ <% has_meta = first_visit_record && (first_visit_record.os.present? || first_visit_record.device_type.present? || first_visit_record.browser.present? || (first_visit_record.respond_to?(:landing_page) && first_visit_record.landing_page.present?)) %>
47
+ <% if has_meta %>
48
+ <div class="rounded-lg border border-gray-200/80 bg-white p-6 shadow-sm">
49
+ <p class="text-sm font-semibold text-gray-900">Environment</p>
50
+ <div class="mt-4 grid grid-cols-4 gap-4">
51
+ <% if first_visit_record.os.present? %>
52
+ <div>
53
+ <p class="text-xs font-medium text-gray-500">OS</p>
54
+ <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.os %></p>
55
+ </div>
56
+ <% end %>
57
+ <% if first_visit_record.device_type.present? %>
58
+ <div>
59
+ <p class="text-xs font-medium text-gray-500">Device</p>
60
+ <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.device_type %></p>
61
+ </div>
62
+ <% end %>
63
+ <% if first_visit_record.browser.present? %>
64
+ <div>
65
+ <p class="text-xs font-medium text-gray-500">Browser</p>
66
+ <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.browser %></p>
67
+ </div>
68
+ <% end %>
69
+ <% if first_visit_record.respond_to?(:landing_page) && first_visit_record.landing_page.present? %>
70
+ <div class="col-span-4">
71
+ <p class="text-xs font-medium text-gray-500">Landing</p>
72
+ <p class="mt-1 text-sm font-medium text-gray-900 truncate" title="<%= first_visit_record.landing_page %>"><%= truncate(first_visit_record.landing_page, length: 120) %></p>
73
+ </div>
74
+ <% end %>
75
+ </div>
76
+ </div>
77
+ <% end %>
78
+
79
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
80
+ <div class="px-6 py-4 border-b border-gray-100">
81
+ <p class="text-sm font-semibold text-gray-900">Activity</p>
82
+ <p class="text-sm text-gray-500">Pages visited by this IP.</p>
83
+ </div>
84
+
85
+ <div class="overflow-x-auto">
86
+ <table class="min-w-full text-sm">
87
+ <thead class="bg-gray-50">
88
+ <tr class="text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
89
+ <th class="px-6 py-3">Page</th>
90
+ <th class="px-6 py-3">Browser</th>
91
+ <th class="px-6 py-3">Referrer</th>
92
+ <th class="px-6 py-3">Time</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody class="divide-y divide-gray-100">
96
+ <% if @visitor_views.any? %>
97
+ <% @visitor_views.each do |event| %>
98
+ <% visit = event.visit %>
99
+ <tr class="hover:bg-gray-50 transition-colors">
100
+ <td class="px-6 py-3 font-medium text-gray-900">
101
+ <% page_name = event.respond_to?(:page_name) ? event.page_name : nil %>
102
+ <% if page_name.present? %>
103
+ <%= link_to page_name,
104
+ page_details_ruby_cms_admin_analytics_path(page_name:, start_date: @start_date, end_date: @end_date, period: @period),
105
+ class: "text-gray-900 hover:underline" %>
106
+ <% else %>
107
+ <span class="text-gray-500">Unknown</span>
108
+ <% end %>
109
+ </td>
110
+ <td class="px-6 py-3 text-gray-700"><%= visit&.browser || "Unknown" %></td>
111
+ <td class="px-6 py-3 text-gray-700 truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
112
+ <td class="px-6 py-3 text-gray-700"><%= time_ago_in_words(event.time) %> ago</td>
113
+ </tr>
114
+ <% end %>
115
+ <% else %>
116
+ <tr>
117
+ <td colspan="4" class="px-6 py-10 text-center text-sm text-gray-500">No activity recorded for this visitor</td>
118
+ </tr>
119
+ <% end %>
120
+ </tbody>
121
+ </table>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ <% end %>
@@ -0,0 +1,161 @@
1
+ <%= form_with model: @content_block,
2
+ url: @content_block.persisted? ? ruby_cms_admin_content_block_path(@content_block) : ruby_cms_admin_content_blocks_path,
3
+ method: (@content_block.persisted? ? :patch : :post),
4
+ id: "content-block-form",
5
+ data: { turbo: false } do |f| %>
6
+
7
+ <div class="flex items-start justify-between gap-8 px-6 py-4 border-b border-gray-100">
8
+ <div class="grid grid-cols-2 gap-4 flex-1">
9
+ <div class="flex flex-col gap-1">
10
+ <%= f.label :key, class: "text-sm font-medium text-gray-700" %>
11
+ <% if @content_block.persisted? %>
12
+ <%= f.hidden_field :key, name: nil, value: @content_block.key %>
13
+ <span class="text-sm font-mono text-gray-900 py-2"><%= @content_block.key %></span>
14
+ <% else %>
15
+ <%= f.text_field :key, name: "content_block[key]", 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" %>
16
+ <% end %>
17
+ </div>
18
+
19
+ <% if @content_block.persisted? %>
20
+ <div class="flex flex-col gap-1">
21
+ <label class="text-sm font-medium text-gray-700"><%= t("ruby_cms.admin.content_blocks.content_type") %></label>
22
+ <%= select_tag "content_block[content_type]",
23
+ options_for_select(::ContentBlock::CONTENT_TYPES, @content_block.content_type),
24
+ 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" %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+
29
+ <div class="flex items-center gap-2 flex-shrink-0">
30
+ <%= link_to "Cancel",
31
+ @content_block.persisted? ? ruby_cms_admin_content_block_path(@content_block) : ruby_cms_admin_content_blocks_path,
32
+ 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 no-underline" %>
33
+ <%= f.submit "Save", 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" %>
34
+ </div>
35
+ </div>
36
+
37
+ <% if @content_block.errors.any? %>
38
+ <div class="mx-6 mt-4 rounded-md border border-rose-200 bg-rose-50 p-4">
39
+ <ul class="list-disc pl-4 space-y-1">
40
+ <% @content_block.errors.full_messages.each do |msg| %>
41
+ <li class="text-sm text-rose-700"><%= msg %></li>
42
+ <% end %>
43
+ </ul>
44
+ </div>
45
+ <% end %>
46
+
47
+ <% if @content_block.persisted? && @blocks_by_locale.present? %>
48
+ <%# Multi-locale layout %>
49
+
50
+ <div class="flex items-center gap-2 px-6 py-4 border-b border-gray-100">
51
+ <%= hidden_field_tag "content_block[published]", "0" %>
52
+ <%= check_box_tag "content_block[published]", "1",
53
+ @blocks_by_locale.values.any?(&:published?),
54
+ class: "h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-200" %>
55
+ <label class="text-sm font-medium text-gray-700"><%= t("ruby_cms.admin.content_blocks.published") %> (all translations)</label>
56
+ </div>
57
+
58
+ <div class="rounded-md border border-gray-200 overflow-hidden mx-6 my-4" data-controller="ruby-cms--locale-tabs">
59
+ <div class="flex gap-1 p-2 bg-gray-50 border-b border-gray-200" role="tablist">
60
+ <% @blocks_by_locale.each_with_index do |(locale_s, _), idx| %>
61
+ <button type="button"
62
+ role="tab"
63
+ aria-selected="<%= idx == 0 %>"
64
+ aria-controls="locale-panel-<%= locale_s %>"
65
+ id="locale-tab-<%= locale_s %>"
66
+ data-ruby-cms--locale-tabs-target="tab"
67
+ data-panel-id="locale-panel-<%= locale_s %>"
68
+ data-action="click->ruby-cms--locale-tabs#switchTab"
69
+ class="px-3 py-1.5 rounded text-xs font-medium border-none cursor-pointer transition-colors <%= idx == 0 ? 'bg-white text-blue-600 shadow-sm ring-1 ring-gray-200' : 'bg-transparent text-gray-500 hover:bg-gray-100 hover:text-gray-900' %>">
70
+ <%= ruby_cms_locale_display_name(locale_s) %>
71
+ </button>
72
+ <% end %>
73
+ </div>
74
+
75
+ <% @blocks_by_locale.each_with_index do |(locale_s, block), idx| %>
76
+ <div id="locale-panel-<%= locale_s %>"
77
+ role="tabpanel"
78
+ aria-labelledby="locale-tab-<%= locale_s %>"
79
+ class="p-5 <%= idx > 0 ? 'hidden' : '' %>"
80
+ data-locale-panel>
81
+
82
+ <%= hidden_field_tag "content_block[locales][#{locale_s}][locale]", locale_s %>
83
+
84
+ <div class="grid grid-cols-2 gap-4">
85
+ <div class="flex flex-col gap-1">
86
+ <label class="text-sm font-medium text-gray-700"><%= t("ruby_cms.admin.content_blocks.title") %></label>
87
+ <%= text_field_tag "content_block[locales][#{locale_s}][title]",
88
+ block.title,
89
+ 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" %>
90
+ </div>
91
+
92
+ <div class="flex flex-col gap-1">
93
+ <label class="text-sm font-medium text-gray-700"><%= t("ruby_cms.admin.content_blocks.content") %></label>
94
+ <%= text_area_tag "content_block[locales][#{locale_s}][content]",
95
+ block.content,
96
+ rows: 5,
97
+ class: "rounded-md border border-gray-200 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200 resize-y" %>
98
+ </div>
99
+
100
+ <% if ::ContentBlock.respond_to?(:action_text_available?) && ::ContentBlock.action_text_available? && block.persisted? && block.respond_to?(:rich_content) %>
101
+ <div class="col-span-2 flex flex-col gap-1">
102
+ <label class="text-sm font-medium text-gray-700"><%= t("ruby_cms.admin.content_blocks.rich_content") %></label>
103
+ <%= rich_text_area_tag "content_block[locales][#{locale_s}][rich_content]",
104
+ block.rich_content.present? ? block.rich_content.to_s : "",
105
+ class: "rounded-md border border-gray-200 bg-white text-sm shadow-sm focus:outline-none" %>
106
+ </div>
107
+ <% end %>
108
+ </div>
109
+ </div>
110
+ <% end %>
111
+ </div>
112
+
113
+ <% else %>
114
+ <%# Single-locale (new block) %>
115
+
116
+ <div class="grid grid-cols-2 gap-4 p-6">
117
+ <div class="flex flex-col gap-1">
118
+ <%= f.label :locale, class: "text-sm font-medium text-gray-700" %>
119
+ <%= f.select :locale,
120
+ I18n.available_locales.map { |l| [ruby_cms_locale_display_name(l), l.to_s] },
121
+ { include_blank: t("ruby_cms.admin.content_blocks.select_locale") },
122
+ 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",
123
+ name: "content_block[locale]" %>
124
+ </div>
125
+
126
+ <div class="flex flex-col gap-1">
127
+ <%= f.label :title, class: "text-sm font-medium text-gray-700" %>
128
+ <%= f.text_field :title, 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" %>
129
+ </div>
130
+
131
+ <div class="flex flex-col gap-1">
132
+ <%= f.label :content_type, class: "text-sm font-medium text-gray-700" %>
133
+ <%= f.select :content_type, ::ContentBlock::CONTENT_TYPES, {}, 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" %>
134
+ </div>
135
+
136
+ <div class="col-span-2 flex flex-col gap-1">
137
+ <%= f.label :content, class: "text-sm font-medium text-gray-700" %>
138
+ <%= f.text_area :content, rows: 5, class: "rounded-md border border-gray-200 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200 resize-y" %>
139
+ </div>
140
+
141
+ <% if ::ContentBlock.respond_to?(:action_text_available?) && ::ContentBlock.action_text_available? %>
142
+ <div class="col-span-2 flex flex-col gap-1">
143
+ <%= f.label :rich_content, class: "text-sm font-medium text-gray-700" %>
144
+ <%= f.rich_text_area :rich_content, class: "rounded-md border border-gray-200 bg-white text-sm shadow-sm focus:outline-none" %>
145
+ </div>
146
+ <% else %>
147
+ <div class="col-span-2 rounded-md border border-gray-200 bg-gray-50 p-4">
148
+ <p class="text-sm text-gray-500">
149
+ Rich text requires Action Text. Run:
150
+ <code class="font-mono text-xs bg-gray-100 px-1 rounded">bin/rails action_text:install</code> and <code class="font-mono text-xs bg-gray-100 px-1 rounded">bin/rails db:migrate</code>
151
+ </p>
152
+ </div>
153
+ <% end %>
154
+
155
+ <div class="col-span-2 flex items-center gap-2">
156
+ <%= f.check_box :published, class: "h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-200" %>
157
+ <%= f.label :published, class: "text-sm font-medium text-gray-700" %>
158
+ </div>
159
+ </div>
160
+ <% end %>
161
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% content_block = local_assigns.fetch(:content_block) %>
2
+ <% block = content_block.respond_to?(:content_block) ? content_block.content_block : content_block %>
3
+ <%= tag.td class: "px-6 py-3" do %>
4
+ <%= link_to block.key, ruby_cms_admin_content_block_path(block), class: "text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline transition-colors", data: { turbo_frame: "_top" } %>
5
+ <% end %>
6
+ <%= tag.td (content_block.respond_to?(:locale) ? content_block.locale : block.locale), class: "px-6 py-3 text-sm text-gray-500" %>
7
+ <%= tag.td (content_block.respond_to?(:title) ? content_block.title : block.title), class: "px-6 py-3 text-sm text-gray-700" %>
8
+ <%= tag.td (content_block.respond_to?(:content_type) ? content_block.content_type : block.content_type), class: "px-6 py-3 text-sm text-gray-500" %>
9
+ <%= tag.td class: "px-6 py-3" do %>
10
+ <% published = content_block.respond_to?(:published?) ? content_block.published? : block.published? %>
11
+ <% if published %>
12
+ <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">Yes</span>
13
+ <% else %>
14
+ <span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-200">No</span>
15
+ <% end %>
16
+ <% end %>
17
+ <%= tag.td class: "px-6 py-3 text-right" do %>
18
+ <%= render RubyCms::Admin::BulkActionTable::BulkActionTableActions.new(
19
+ edit_path: edit_ruby_cms_admin_content_block_path(block),
20
+ delete_path: ruby_cms_admin_content_block_path(block),
21
+ item_id: block.id,
22
+ controller_name: "ruby-cms--bulk-action-table",
23
+ turbo_frame: "_top"
24
+ ) %>
25
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <div class="flex flex-wrap items-center justify-between gap-4 mb-4">
2
+ <h1 class="text-lg font-semibold tracking-tight text-gray-900">Edit content block</h1>
3
+ <div class="flex items-center gap-2">
4
+ <%= link_to "Cancel",
5
+ ruby_cms_admin_content_block_path(@content_block),
6
+ 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 no-underline" %>
7
+ <button type="submit"
8
+ form="content-block-form"
9
+ 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">
10
+ Save
11
+ </button>
12
+ </div>
13
+ </div>
14
+
15
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
16
+ <%= render "form" %>
17
+ </div>