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,66 @@
1
+ <%
2
+ bulk_action_buttons = []
3
+ begin
4
+ bulk_publish_url = bulk_publish_ruby_cms_admin_content_blocks_path
5
+ bulk_action_buttons << {
6
+ name: "publish",
7
+ label: "Publish Selected",
8
+ url: bulk_publish_url,
9
+ confirm: "Are you sure you want to publish the selected content blocks?"
10
+ }
11
+ rescue NoMethodError, ActionController::UrlGenerationError
12
+ end
13
+
14
+ begin
15
+ bulk_unpublish_url = bulk_unpublish_ruby_cms_admin_content_blocks_path
16
+ bulk_action_buttons << {
17
+ name: "unpublish",
18
+ label: "Unpublish Selected",
19
+ url: bulk_unpublish_url,
20
+ confirm: "Are you sure you want to unpublish the selected content blocks?"
21
+ }
22
+ rescue NoMethodError, ActionController::UrlGenerationError
23
+ end
24
+
25
+ begin
26
+ bulk_delete_url = bulk_delete_ruby_cms_admin_content_blocks_path
27
+ rescue NoMethodError, ActionController::UrlGenerationError
28
+ bulk_delete_url = nil
29
+ end
30
+ %>
31
+
32
+ <% locale_filter_url = ruby_cms_admin_content_blocks_path %>
33
+ <% locale_filter_html = capture do %>
34
+ <%= tag.div class: "flex items-center gap-1" do %>
35
+ <%= tag.span "Locale:", class: "text-xs font-medium text-gray-500 mr-1" %>
36
+ <% ([nil] + I18n.available_locales.to_a).each do |loc| %>
37
+ <% is_active = (params[:locale].blank? && loc.nil?) || (loc && params[:locale].to_s == loc.to_s) %>
38
+ <%= link_to (loc ? ruby_cms_locale_display_name(loc) : "All"), (loc ? "#{locale_filter_url}?locale=#{loc}" : locale_filter_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" } %>
39
+ <% end %>
40
+ <% end %>
41
+ <% end %>
42
+ <%= render partial: "ruby_cms/admin/shared/bulk_action_table_index", locals: {
43
+ header_filter: locale_filter_html,
44
+ title: "Content blocks",
45
+ collection: @content_blocks || [],
46
+ headers: ["Key", "Locale", "Title", "Type", "Published", { text: "Actions", class: "text-right" }],
47
+ action_icons: [
48
+ {
49
+ url: new_ruby_cms_admin_content_block_path,
50
+ title: "New content block",
51
+ color: "blue",
52
+ icon: "M12 4.5v15m7.5-7.5h-15",
53
+ data: { turbo_frame: "_top" }
54
+ }
55
+ ],
56
+ search_url: ruby_cms_admin_content_blocks_path,
57
+ search_param: "q",
58
+ turbo_frame: "admin_table_content",
59
+ bulk_actions_url: bulk_delete_url,
60
+ bulk_action_buttons: bulk_action_buttons,
61
+ item_name: "content block",
62
+ pagination: @pagination,
63
+ pagination_path: @pagination_path,
64
+ row_partial: "ruby_cms/admin/content_blocks/row",
65
+ row_click_url: ->(item) { ruby_cms_admin_content_block_path(item.respond_to?(:content_block) ? item.content_block : item) }
66
+ } %>
@@ -0,0 +1,5 @@
1
+ <h1 class="text-2xl font-semibold text-gray-900 mb-4">New content block</h1>
2
+
3
+ <%= render "form" %>
4
+
5
+ <p class="mt-4"><%= link_to "Back", ruby_cms_admin_content_blocks_path, class: "text-blue-600 hover:text-blue-800" %></p>
@@ -0,0 +1,110 @@
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">Content block</h1>
3
+ <div class="flex items-center gap-2">
4
+ <%= link_to "Edit",
5
+ edit_ruby_cms_admin_content_block_path(@content_block),
6
+ 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 no-underline" %>
7
+ <%= link_to "Back",
8
+ ruby_cms_admin_content_blocks_path,
9
+ 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" %>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
14
+
15
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-6 p-6 border-b border-gray-100">
16
+ <div>
17
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">ID</div>
18
+ <div class="text-sm text-gray-900"><%= @content_block.id %></div>
19
+ </div>
20
+
21
+ <div>
22
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Key</div>
23
+ <div class="text-sm font-mono text-gray-900"><%= @content_block.key %></div>
24
+ </div>
25
+
26
+ <div>
27
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Locale</div>
28
+ <div class="text-sm text-gray-900"><%= ruby_cms_locale_display_name(@content_block.locale) %></div>
29
+ </div>
30
+
31
+ <div>
32
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Type</div>
33
+ <div class="text-sm text-gray-900"><%= @content_block.content_type %></div>
34
+ </div>
35
+
36
+ <div>
37
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Published</div>
38
+ <div class="text-sm text-gray-900">
39
+ <% if @content_block.published? %>
40
+ <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">Published</span>
41
+ <% else %>
42
+ <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">Draft</span>
43
+ <% end %>
44
+ </div>
45
+ </div>
46
+
47
+ <div>
48
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Created At</div>
49
+ <div class="text-sm text-gray-900"><%= @content_block.created_at.strftime("%B %d, %Y at %I:%M %p") %></div>
50
+ </div>
51
+
52
+ <div>
53
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Last Updated</div>
54
+ <div class="text-sm text-gray-900"><%= @content_block.updated_at.strftime("%B %d, %Y at %I:%M %p") %></div>
55
+ </div>
56
+
57
+ <% if @content_block.updated_by.present? %>
58
+ <div>
59
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Updated By</div>
60
+ <div class="text-sm text-gray-900"><%= ruby_cms_user_display(@content_block.updated_by) %></div>
61
+ </div>
62
+ <% end %>
63
+ </div>
64
+
65
+ <% if @blocks_by_locale.present? %>
66
+ <div class="grid gap-4 p-6" style="grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));">
67
+ <% @blocks_by_locale.each do |locale_s, block| %>
68
+ <div class="rounded-md border border-gray-200 bg-gray-50 overflow-hidden">
69
+ <div class="px-3 py-2 border-b border-gray-200 bg-gray-100">
70
+ <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-white text-gray-600 ring-1 ring-gray-200">
71
+ <%= ruby_cms_locale_display_name(locale_s) %>
72
+ </span>
73
+ </div>
74
+
75
+ <div class="p-4 grid grid-cols-2 gap-4">
76
+ <div>
77
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Title</div>
78
+ <div class="text-sm text-gray-900"><%= block.title.presence || "—" %></div>
79
+ </div>
80
+
81
+ <div class="col-span-2">
82
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Content</div>
83
+ <div class="text-sm text-gray-900 whitespace-pre-wrap"><%= block.content_body.presence || "—" %></div>
84
+ </div>
85
+
86
+ <% if block.respond_to?(:rich_content) && block.rich_content.present? %>
87
+ <div class="col-span-2">
88
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Rich content</div>
89
+ <div class="text-sm text-gray-900 whitespace-pre-wrap"><%= block.rich_content %></div>
90
+ </div>
91
+ <% end %>
92
+ </div>
93
+ </div>
94
+ <% end %>
95
+ </div>
96
+ <% else %>
97
+ <div class="grid grid-cols-2 gap-6 p-6">
98
+ <div>
99
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Title</div>
100
+ <div class="text-sm text-gray-900"><%= @content_block.title %></div>
101
+ </div>
102
+
103
+ <div>
104
+ <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Content</div>
105
+ <div class="text-sm text-gray-900 whitespace-pre-wrap"><%= @content_block.content_body %></div>
106
+ </div>
107
+ </div>
108
+ <% end %>
109
+
110
+ </div>
@@ -0,0 +1,198 @@
1
+ <%= AdminPage(
2
+ title: "Dashboard",
3
+ subtitle: "Welcome back! Here's an overview of your CMS.",
4
+ content_card: false
5
+ ) do %>
6
+ <div class="space-y-6">
7
+
8
+ <%# Stats %>
9
+ <div class="grid grid-cols-4 gap-4">
10
+
11
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
12
+ <div class="flex items-start justify-between gap-3">
13
+ <div class="min-w-0">
14
+ <p class="text-sm font-medium text-gray-500">Content blocks</p>
15
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= @content_blocks_count %></p>
16
+ <p class="mt-1 text-xs text-gray-500"><%= @content_blocks_published_count %> published</p>
17
+ </div>
18
+ <div class="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-100">
19
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h8"/></svg>
20
+ </div>
21
+ </div>
22
+ <div class="mt-4 border-t border-gray-100 pt-3">
23
+ <%= link_to ruby_cms_admin_content_blocks_path, class: "inline-flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
24
+ Manage <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
30
+ <div class="flex items-start justify-between gap-3">
31
+ <div class="min-w-0">
32
+ <p class="text-sm font-medium text-gray-500">Users</p>
33
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= @users_count %></p>
34
+ <p class="mt-1 text-xs text-gray-500">registered</p>
35
+ </div>
36
+ <div class="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-100">
37
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-4-4h-1M9 20H4v-2a4 4 0 014-4h1m4-4a4 4 0 110-8 4 4 0 010 8z"/></svg>
38
+ </div>
39
+ </div>
40
+ <div class="mt-4 border-t border-gray-100 pt-3">
41
+ <%= link_to ruby_cms_admin_users_path, class: "inline-flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
42
+ Manage <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>
43
+ <% end %>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
48
+ <div class="flex items-start justify-between gap-3">
49
+ <div class="min-w-0">
50
+ <p class="text-sm font-medium text-gray-500">Permissions</p>
51
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= @permissions_count %></p>
52
+ <p class="mt-1 text-xs text-gray-500"><%= @user_permissions_count %> grants</p>
53
+ </div>
54
+ <div class="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-100">
55
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
56
+ </div>
57
+ </div>
58
+ <div class="mt-4 border-t border-gray-100 pt-3">
59
+ <%= link_to ruby_cms_admin_permissions_path, class: "inline-flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
60
+ Manage <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>
61
+ <% end %>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
66
+ <div class="flex items-start justify-between gap-3">
67
+ <div class="min-w-0">
68
+ <p class="text-sm font-medium text-gray-500">Visitor errors</p>
69
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= @visitor_errors_count %></p>
70
+ <p class="mt-1 text-xs font-medium">
71
+ <% if @unresolved_errors_count.to_i > 0 %>
72
+ <span class="text-rose-600"><%= @unresolved_errors_count %> unresolved</span>
73
+ <% else %>
74
+ <span class="text-emerald-600">all clear</span>
75
+ <% end %>
76
+ </p>
77
+ </div>
78
+ <% icon_bg = @unresolved_errors_count.to_i > 0 ? "bg-rose-50 text-rose-700 ring-rose-100" : "bg-emerald-50 text-emerald-700 ring-emerald-100" %>
79
+ <div class="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg ring-1 ring-inset <%= icon_bg %>">
80
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
81
+ </div>
82
+ </div>
83
+ <div class="mt-4 border-t border-gray-100 pt-3">
84
+ <%= link_to ruby_cms_admin_visitor_errors_path, class: "inline-flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
85
+ View all <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>
86
+ <% end %>
87
+ </div>
88
+ </div>
89
+
90
+ </div>
91
+
92
+ <div class="grid grid-cols-3 gap-4">
93
+ <div class="col-span-2 rounded-lg border border-gray-200/80 bg-white shadow-sm">
94
+ <div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-gray-100">
95
+ <div>
96
+ <p class="text-sm font-semibold text-gray-900">Quick actions</p>
97
+ <p class="text-sm text-gray-500">Jump to common admin tasks.</p>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="divide-y divide-gray-100">
102
+ <%= link_to new_ruby_cms_admin_content_block_path, class: "group flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors" do %>
103
+ <div class="flex items-center gap-3">
104
+ <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-100">
105
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
106
+ </div>
107
+ <div>
108
+ <p class="text-sm font-medium text-gray-900">New content block</p>
109
+ <p class="text-sm text-gray-500">Create a new block</p>
110
+ </div>
111
+ </div>
112
+ <svg class="h-4 w-4 text-gray-300 group-hover:text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
113
+ <% end %>
114
+
115
+ <%= link_to ruby_cms_admin_visual_editor_path, class: "group flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors" do %>
116
+ <div class="flex items-center gap-3">
117
+ <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-100">
118
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536M9 11l6 6M3 21h18"/></svg>
119
+ </div>
120
+ <div>
121
+ <p class="text-sm font-medium text-gray-900">Visual editor</p>
122
+ <p class="text-sm text-gray-500">Edit content visually</p>
123
+ </div>
124
+ </div>
125
+ <svg class="h-4 w-4 text-gray-300 group-hover:text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
126
+ <% end %>
127
+
128
+ <%= link_to ruby_cms_admin_visitor_errors_path, class: "group flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors" do %>
129
+ <div class="flex items-center gap-3">
130
+ <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-100">
131
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
132
+ </div>
133
+ <div>
134
+ <p class="text-sm font-medium text-gray-900">Visitor errors</p>
135
+ <p class="text-sm text-gray-500">Review recent issues</p>
136
+ </div>
137
+ </div>
138
+ <svg class="h-4 w-4 text-gray-300 group-hover:text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
139
+ <% end %>
140
+
141
+ <%= link_to ruby_cms_admin_settings_path, class: "group flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors" do %>
142
+ <div class="flex items-center gap-3">
143
+ <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
144
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
145
+ </div>
146
+ <div>
147
+ <p class="text-sm font-medium text-gray-900">Settings</p>
148
+ <p class="text-sm text-gray-500">Configure RubyCMS</p>
149
+ </div>
150
+ </div>
151
+ <svg class="h-4 w-4 text-gray-300 group-hover:text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
152
+ <% end %>
153
+ </div>
154
+ </div>
155
+
156
+ <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
157
+ <div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-gray-100">
158
+ <div>
159
+ <p class="text-sm font-semibold text-gray-900">Recent errors</p>
160
+ <p class="text-sm text-gray-500">Latest visitor errors.</p>
161
+ </div>
162
+ <%= link_to "View all", ruby_cms_admin_visitor_errors_path, class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
163
+ </div>
164
+
165
+ <% if @recent_errors.any? %>
166
+ <div class="divide-y divide-gray-100">
167
+ <% @recent_errors.first(5).each do |error| %>
168
+ <div class="px-6 py-4">
169
+ <p class="text-sm font-medium text-gray-900 truncate">
170
+ <span class="text-gray-500 font-normal"><%= error.error_class %>:</span>
171
+ <%= error.error_message.truncate(70) %>
172
+ </p>
173
+ <div class="mt-2 flex items-center justify-between gap-3">
174
+ <div class="flex items-center gap-2">
175
+ <span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset <%= error.resolved? ? 'bg-emerald-50 text-emerald-700 ring-emerald-200' : 'bg-rose-50 text-rose-700 ring-rose-200' %>">
176
+ <%= error.resolved? ? 'Resolved' : 'Unresolved' %>
177
+ </span>
178
+ <span class="text-xs text-gray-500"><%= time_ago_in_words(error.created_at) %> ago</span>
179
+ </div>
180
+ <%= link_to "Open", ruby_cms_admin_visitor_error_path(error), class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
181
+ </div>
182
+ </div>
183
+ <% end %>
184
+ </div>
185
+ <% else %>
186
+ <div class="px-6 py-12 text-center">
187
+ <div class="mx-auto mb-3 inline-flex h-11 w-11 items-center justify-center rounded-lg bg-emerald-50 ring-1 ring-inset ring-emerald-100">
188
+ <svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
189
+ </div>
190
+ <p class="text-sm font-medium text-gray-900">No errors recorded</p>
191
+ <p class="mt-1 text-sm text-gray-500">Everything is running smoothly.</p>
192
+ </div>
193
+ <% end %>
194
+ </div>
195
+ </div>
196
+
197
+ </div>
198
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%# Row partial for permissions table %>
2
+ <% permission = local_assigns[:permission] %>
3
+ <%= tag.td permission.key, class: "px-6 py-3 font-medium text-gray-900" %>
4
+ <%= tag.td permission.name, class: "px-6 py-3 text-gray-700" %>
5
+ <%= tag.td class: "px-6 py-3 text-right" do %>
6
+ <%= render RubyCms::Admin::BulkActionTable::BulkActionTableActions.new(
7
+ delete_path: ruby_cms_admin_permission_path(permission),
8
+ item_id: permission.id,
9
+ controller_name: "ruby-cms--bulk-action-table"
10
+ ) %>
11
+ <% end %>
@@ -0,0 +1,62 @@
1
+ <%
2
+ begin
3
+ bulk_delete_url = bulk_delete_ruby_cms_admin_permissions_path
4
+ rescue NoMethodError, ActionController::UrlGenerationError
5
+ bulk_delete_url = nil
6
+ end
7
+ %>
8
+
9
+ <% if flash[:alert] %>
10
+ <div class="rounded-md border border-rose-200 bg-rose-50 p-4">
11
+ <p class="text-sm text-rose-700 m-0"><%= flash[:alert] %></p>
12
+ </div>
13
+ <% end %>
14
+
15
+ <div id="new-permission-form" class="hidden rounded-lg border border-gray-200/80 bg-white shadow-sm p-6">
16
+ <h2 class="text-base font-semibold text-gray-900 mb-4">Create Permission</h2>
17
+ <%= form_with model: [:ruby_cms_admin, RubyCms::Permission.new], url: ruby_cms_admin_permissions_path, method: :post do |f| %>
18
+ <div class="space-y-4 max-w-sm">
19
+ <div class="flex flex-col gap-1">
20
+ <%= f.label :key, class: "text-sm font-medium text-gray-700" %>
21
+ <%= f.text_field :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", placeholder: "manage_something" %>
22
+ <p class="text-xs text-gray-500 m-0">URL-friendly key (e.g. manage_something)</p>
23
+ </div>
24
+ <div class="flex flex-col gap-1">
25
+ <%= f.label :name, class: "text-sm font-medium text-gray-700" %>
26
+ <%= f.text_field :name, 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: "Manage Something" %>
27
+ </div>
28
+ <div class="flex items-center gap-2">
29
+ <%= f.submit "Create Permission", 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" %>
30
+ <%= 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-permission-form" } %>
31
+ </div>
32
+ </div>
33
+ <% end %>
34
+ </div>
35
+
36
+ <%= render partial: "ruby_cms/admin/shared/bulk_action_table_index", locals: {
37
+ title: "Permissions",
38
+ collection: @permissions || [],
39
+ headers: ["Key", "Name", { text: "Actions", class: "text-right" }],
40
+ action_icons: [
41
+ {
42
+ url: "#",
43
+ title: "New permission",
44
+ color: "blue",
45
+ icon: "M12 4.5v15m7.5-7.5h-15",
46
+ data: {
47
+ controller: "ruby-cms--toggle",
48
+ action: "click->ruby-cms--toggle#toggle",
49
+ toggle_target_id: "new-permission-form"
50
+ }
51
+ }
52
+ ],
53
+ search_url: ruby_cms_admin_permissions_path,
54
+ search_param: "q",
55
+ turbo_frame: "admin_table_content",
56
+ bulk_actions_url: bulk_delete_url,
57
+ bulk_action_buttons: [],
58
+ item_name: "permission",
59
+ pagination: @pagination,
60
+ pagination_path: @pagination_path,
61
+ row_partial: "ruby_cms/admin/permissions/row"
62
+ } %>