admin_suite 0.2.1 → 0.2.3

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/admin_suite.css +129 -0
  3. data/app/controllers/admin_suite/application_controller.rb +31 -0
  4. data/app/controllers/admin_suite/dashboard_controller.rb +59 -226
  5. data/app/controllers/admin_suite/resources_controller.rb +14 -4
  6. data/app/helpers/admin_suite/base_helper.rb +147 -108
  7. data/app/helpers/admin_suite/panels_helper.rb +1 -1
  8. data/app/javascript/controllers/admin_suite/file_upload_controller.js +9 -9
  9. data/app/javascript/controllers/admin_suite/flash_controller.js +45 -0
  10. data/app/javascript/controllers/admin_suite/json_editor_controller.js +8 -8
  11. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +2 -2
  12. data/app/javascript/controllers/admin_suite/tag_select_controller.js +1 -1
  13. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +25 -16
  14. data/app/views/admin_suite/dashboard/index.html.erb +6 -15
  15. data/app/views/admin_suite/panels/_cards.html.erb +6 -6
  16. data/app/views/admin_suite/panels/_chart.html.erb +12 -12
  17. data/app/views/admin_suite/panels/_health.html.erb +14 -14
  18. data/app/views/admin_suite/panels/_recent.html.erb +11 -11
  19. data/app/views/admin_suite/panels/_stat.html.erb +24 -24
  20. data/app/views/admin_suite/panels/_table.html.erb +10 -10
  21. data/app/views/admin_suite/portals/show.html.erb +1 -1
  22. data/app/views/admin_suite/resources/_form.html.erb +1 -1
  23. data/app/views/admin_suite/resources/edit.html.erb +4 -4
  24. data/app/views/admin_suite/resources/index.html.erb +23 -23
  25. data/app/views/admin_suite/resources/new.html.erb +4 -4
  26. data/app/views/admin_suite/resources/show.html.erb +17 -17
  27. data/app/views/admin_suite/shared/_flash.html.erb +15 -2
  28. data/app/views/admin_suite/shared/_form.html.erb +8 -8
  29. data/app/views/admin_suite/shared/_json_editor_field.html.erb +4 -4
  30. data/app/views/admin_suite/shared/_sidebar.html.erb +4 -4
  31. data/app/views/admin_suite/shared/_toggle_cell.html.erb +4 -2
  32. data/app/views/admin_suite/shared/_topbar.html.erb +14 -1
  33. data/app/views/layouts/admin_suite/application.html.erb +4 -4
  34. data/docs/configuration.md +55 -0
  35. data/docs/portals.md +42 -0
  36. data/lib/admin_suite/configuration.rb +16 -0
  37. data/lib/admin_suite/engine.rb +9 -0
  38. data/lib/admin_suite/ui/field_renderer_registry.rb +2 -2
  39. data/lib/admin_suite/ui/form_field_renderer.rb +2 -2
  40. data/lib/admin_suite/ui/show_formatter_registry.rb +5 -5
  41. data/lib/admin_suite/ui/show_value_formatter.rb +31 -3
  42. data/lib/admin_suite/version.rb +1 -1
  43. data/lib/admin_suite.rb +31 -0
  44. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -0
  45. data/test/integration/dashboard_test.rb +57 -1
  46. metadata +7 -5
  47. data/test/dummy/log/test.log +0 -624
  48. data/test/dummy/tmp/local_secret.txt +0 -1
@@ -56,12 +56,12 @@
56
56
  <% end %>
57
57
  <% end %>
58
58
 
59
- <div class="flex flex-col lg:flex-row gap-6">
59
+ <div class="admin-suite-two-col flex flex-col md:flex-row gap-6">
60
60
  <!-- Filters Sidebar -->
61
61
  <% if resource_config.index_config&.filters_list&.any? || resource_config.index_config&.searchable_fields&.any? %>
62
- <div class="lg:w-64 flex-shrink-0">
62
+ <div class="admin-suite-two-col__sidebar admin-suite-two-col__sidebar--filters md:w-64 flex-shrink-0">
63
63
  <div class="bg-white rounded-xl border border-slate-200 p-4">
64
- <h3 class="font-medium text-slate-900 dark:text-white mb-4">Filters</h3>
64
+ <h3 class="font-medium text-slate-900 mb-4">Filters</h3>
65
65
  <%= form_with url: url_for(action: :index), method: :get,
66
66
  data: {
67
67
  turbo_frame: "resource_results",
@@ -136,7 +136,7 @@
136
136
  <% end %>
137
137
 
138
138
  <!-- Main Content -->
139
- <div class="flex-1">
139
+ <div class="flex-1 min-w-0">
140
140
  <%= turbo_frame_tag "resource_results", data: { turbo_action: "advance" } do %>
141
141
  <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
142
142
  <% if @collection.any? %>
@@ -216,25 +216,25 @@
216
216
 
217
217
  <!-- Pagination -->
218
218
  <% if @pagy.pages > 1 %>
219
- <div class="px-6 py-4 border-t border-slate-200 dark:border-slate-700">
219
+ <div class="px-6 py-4 border-t border-slate-200">
220
220
  <div class="flex items-center justify-between">
221
- <div class="text-sm text-slate-500 dark:text-slate-400">
222
- Showing <span class="font-medium text-slate-700 dark:text-slate-300"><%= @pagy.from %></span> to
223
- <span class="font-medium text-slate-700 dark:text-slate-300"><%= @pagy.to %></span> of
224
- <span class="font-medium text-slate-700 dark:text-slate-300"><%= number_with_delimiter(@pagy.count) %></span> results
221
+ <div class="text-sm text-slate-500">
222
+ Showing <span class="font-medium text-slate-700"><%= @pagy.from %></span> to
223
+ <span class="font-medium text-slate-700"><%= @pagy.to %></span> of
224
+ <span class="font-medium text-slate-700"><%= number_with_delimiter(@pagy.count) %></span> results
225
225
  </div>
226
226
 
227
227
  <nav class="flex items-center gap-1">
228
228
  <% if @pagy.prev %>
229
229
  <%= link_to url_for(params.permit!.merge(page: @pagy.prev)),
230
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" do %>
230
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
231
231
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
232
232
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
233
233
  </svg>
234
234
  Prev
235
235
  <% end %>
236
236
  <% else %>
237
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed">
237
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
238
238
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
239
239
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
240
240
  </svg>
@@ -245,10 +245,10 @@
245
245
  <div class="hidden sm:flex items-center gap-1 mx-2">
246
246
  <% @pagy.series.each do |item| %>
247
247
  <% if item == :gap %>
248
- <span class="px-2 py-1 text-sm text-slate-400 dark:text-slate-500">…</span>
248
+ <span class="px-2 py-1 text-sm text-slate-400">…</span>
249
249
  <% elsif item.is_a?(Integer) %>
250
250
  <%= link_to item, url_for(params.permit!.merge(page: item)),
251
- class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" %>
251
+ class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" %>
252
252
  <% elsif item.is_a?(String) %>
253
253
  <span class="px-3 py-1.5 text-sm font-medium text-white rounded-lg" style="background: var(--admin-suite-primary); border: 1px solid var(--admin-suite-primary);">
254
254
  <%= item %>
@@ -257,20 +257,20 @@
257
257
  <% end %>
258
258
  </div>
259
259
 
260
- <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500 dark:text-slate-400">
260
+ <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500">
261
261
  Page <%= @pagy.page %> of <%= @pagy.pages %>
262
262
  </span>
263
263
 
264
264
  <% if @pagy.next %>
265
265
  <%= link_to url_for(params.permit!.merge(page: @pagy.next)),
266
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" do %>
266
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
267
267
  Next
268
268
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
269
269
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
270
270
  </svg>
271
271
  <% end %>
272
272
  <% else %>
273
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed">
273
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
274
274
  Next
275
275
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
276
276
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
@@ -281,22 +281,22 @@
281
281
  </div>
282
282
  </div>
283
283
  <% else %>
284
- <div class="px-6 py-3 border-t border-slate-200 dark:border-slate-700">
285
- <div class="text-sm text-slate-500 dark:text-slate-400">
286
- Showing <span class="font-medium text-slate-700 dark:text-slate-300"><%= @pagy.count %></span> results
284
+ <div class="px-6 py-3 border-t border-slate-200">
285
+ <div class="text-sm text-slate-500">
286
+ Showing <span class="font-medium text-slate-700"><%= @pagy.count %></span> results
287
287
  </div>
288
288
  </div>
289
289
  <% end %>
290
290
  <% else %>
291
291
  <div class="p-12 text-center">
292
- <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-900 mb-4">
292
+ <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
293
293
  <svg class="w-6 h-6 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
294
294
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
295
295
  </svg>
296
296
  </div>
297
- <h3 class="text-lg font-medium text-slate-900 dark:text-white">No <%= resource_config.human_name_plural.downcase %> found</h3>
297
+ <h3 class="text-lg font-medium text-slate-900">No <%= resource_config.human_name_plural.downcase %> found</h3>
298
298
  <% if has_new_route %>
299
- <p class="text-slate-500 dark:text-slate-400 mt-1">Get started by creating your first one.</p>
299
+ <p class="text-slate-500 mt-1">Get started by creating your first one.</p>
300
300
  <%= link_to url_for(action: :new),
301
301
  class: theme_btn_primary_class + " mt-4",
302
302
  data: { turbo_frame: "_top" } do %>
@@ -304,7 +304,7 @@
304
304
  New <%= resource_config.human_name %>
305
305
  <% end %>
306
306
  <% else %>
307
- <p class="text-slate-500 dark:text-slate-400 mt-1">No records have been created yet.</p>
307
+ <p class="text-slate-500 mt-1">No records have been created yet.</p>
308
308
  <% end %>
309
309
  </div>
310
310
  <% end %>
@@ -3,18 +3,18 @@
3
3
  <div class="px-4 sm:px-6 lg:px-8 max-w-3xl mx-auto">
4
4
  <!-- Header -->
5
5
  <div class="mb-6">
6
- <nav class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 mb-2">
6
+ <nav class="flex items-center gap-2 text-sm text-slate-500 mb-2">
7
7
  <%= link_to "Dashboard", root_path, class: theme_link_hover_text_class %>
8
8
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
9
9
  <%= link_to resource_config.human_name_plural, url_for(action: :index), class: theme_link_hover_text_class %>
10
10
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
11
- <span class="text-slate-900 dark:text-white">New</span>
11
+ <span class="text-slate-900">New</span>
12
12
  </nav>
13
- <h1 class="text-2xl font-bold text-slate-900 dark:text-white">New <%= resource_config.human_name %></h1>
13
+ <h1 class="text-2xl font-bold text-slate-900">New <%= resource_config.human_name %></h1>
14
14
  </div>
15
15
 
16
16
  <!-- Form -->
17
- <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
17
+ <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-visible">
18
18
  <div class="p-6">
19
19
  <%= render "admin_suite/shared/form", resource: @resource %>
20
20
  </div>
@@ -26,23 +26,23 @@
26
26
  <div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
27
27
  <!-- Header -->
28
28
  <div class="mb-6">
29
- <nav class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 mb-2">
29
+ <nav class="flex items-center gap-2 text-sm text-slate-500 mb-2">
30
30
  <%= link_to "Dashboard", root_path, class: theme_link_hover_text_class %>
31
31
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
32
32
  <%= link_to resource_config.human_name_plural, url_for(action: :index), class: theme_link_hover_text_class %>
33
33
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
34
- <span class="text-slate-900 dark:text-white">
34
+ <span class="text-slate-900">
35
35
  <%= resource.respond_to?(:name) ? resource.name.to_s.truncate(30) : "##{resource.id}" %>
36
36
  </span>
37
37
  </nav>
38
38
 
39
39
  <div class="flex items-center justify-between">
40
- <h1 class="text-2xl font-bold text-slate-900 dark:text-white">
40
+ <h1 class="text-2xl font-bold text-slate-900">
41
41
  <%= resource.respond_to?(:name) ? resource.name : "#{resource_config.human_name} ##{resource.id}" %>
42
42
  </h1>
43
43
  <div class="flex items-center gap-2">
44
44
  <% if has_edit_route %>
45
- <%= link_to url_for(action: :edit, id: resource.to_param), class: "inline-flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-900 dark:text-white text-sm font-medium rounded-lg transition-colors" do %>
45
+ <%= link_to url_for(action: :edit, id: resource.to_param), class: "inline-flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-900 text-sm font-medium rounded-lg transition-colors" do %>
46
46
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
47
47
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
48
48
  </svg>
@@ -128,24 +128,24 @@
128
128
  <% end %>
129
129
 
130
130
  <!-- Two Column Layout -->
131
- <div class="flex flex-col lg:flex-row gap-6">
131
+ <div class="admin-suite-two-col flex flex-col md:flex-row gap-6">
132
132
  <!-- Sidebar (Left Column) -->
133
133
  <% if sidebar_sections.any? %>
134
- <div class="lg:w-80 flex-shrink-0 space-y-6">
134
+ <div class="admin-suite-two-col__sidebar admin-suite-two-col__sidebar--details md:w-80 flex-shrink-0 space-y-6">
135
135
  <% sidebar_sections.each do |section| %>
136
136
  <%= render_show_section(resource, section, :sidebar) %>
137
137
  <% end %>
138
138
  </div>
139
139
  <% else %>
140
- <div class="lg:w-80 flex-shrink-0 space-y-6">
141
- <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
142
- <div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
143
- <h3 class="font-medium text-slate-900 dark:text-white text-sm">Details</h3>
140
+ <div class="admin-suite-two-col__sidebar admin-suite-two-col__sidebar--details md:w-80 flex-shrink-0 space-y-6">
141
+ <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
142
+ <div class="px-4 py-3 border-b border-slate-200 bg-slate-50">
143
+ <h3 class="font-medium text-slate-900 text-sm">Details</h3>
144
144
  </div>
145
145
  <div class="p-4 space-y-3">
146
146
  <div class="flex justify-between items-start">
147
- <span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">ID</span>
148
- <span class="text-sm font-mono text-slate-900 dark:text-white"><%= resource.id %></span>
147
+ <span class="text-xs font-medium text-slate-500 uppercase tracking-wider">ID</span>
148
+ <span class="text-sm font-mono text-slate-900"><%= resource.id %></span>
149
149
  </div>
150
150
  </div>
151
151
  </div>
@@ -159,18 +159,18 @@
159
159
  <%= render_show_section(resource, section, :main) %>
160
160
  <% end %>
161
161
  <% else %>
162
- <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
163
- <div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
164
- <h3 class="font-medium text-slate-900 dark:text-white">All Attributes</h3>
162
+ <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
163
+ <div class="px-6 py-4 border-b border-slate-200 bg-slate-50">
164
+ <h3 class="font-medium text-slate-900">All Attributes</h3>
165
165
  </div>
166
166
  <div class="p-6">
167
167
  <dl class="space-y-6">
168
168
  <% resource.attributes.except("id", "created_at", "updated_at").each do |attr, _value| %>
169
169
  <div>
170
- <dt class="text-sm font-medium text-slate-500 dark:text-slate-400 mb-2">
170
+ <dt class="text-sm font-medium text-slate-500 mb-2">
171
171
  <%= attr.humanize %>
172
172
  </dt>
173
- <dd class="text-sm text-slate-900 dark:text-white">
173
+ <dd class="text-sm text-slate-900">
174
174
  <%= format_show_value(resource, attr) %>
175
175
  </dd>
176
176
  </div>
@@ -1,5 +1,5 @@
1
1
  <% if flash.any? %>
2
- <div class="fixed top-4 right-4 z-50 space-y-2 max-w-md">
2
+ <div class="fixed top-20 right-4 z-50 space-y-2 max-w-md" data-controller="admin-suite--flash">
3
3
  <% flash.each do |type, message| %>
4
4
  <% type = type.to_sym %>
5
5
  <% classes = case type
@@ -13,7 +13,10 @@
13
13
  "admin-suite-flash--info"
14
14
  end %>
15
15
 
16
- <div class="flex items-start gap-3 px-5 py-4 rounded-lg shadow-lg border <%= classes %>">
16
+ <div
17
+ data-admin-suite--flash-target="notification"
18
+ class="flex items-start gap-3 px-5 py-4 rounded-lg shadow-lg border <%= classes %>"
19
+ >
17
20
  <div class="flex-shrink-0 mt-0.5">
18
21
  <% icon_name = case type
19
22
  when :notice then "check-circle-2"
@@ -23,7 +26,17 @@
23
26
  end %>
24
27
  <%= admin_suite_icon(icon_name, class: "w-5 h-5") %>
25
28
  </div>
29
+
26
30
  <p class="flex-1 text-sm font-medium leading-snug"><%= message %></p>
31
+
32
+ <button
33
+ type="button"
34
+ class="flex-shrink-0 text-slate-400 hover:text-slate-600 transition-colors"
35
+ data-action="admin-suite--flash#dismiss"
36
+ aria-label="Dismiss notification"
37
+ >
38
+ <%= admin_suite_icon("x", class: "w-4 h-4") %>
39
+ </button>
27
40
  </div>
28
41
  <% end %>
29
42
  </div>
@@ -2,16 +2,16 @@
2
2
 
3
3
  <%= form_with model: resource, url: resource.new_record? ? url_for(action: :create) : url_for(action: :update, id: resource.to_param), local: true, class: "space-y-6" do |f| %>
4
4
  <% if resource.errors.any? %>
5
- <div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
5
+ <div class="p-4 bg-red-50 border border-red-200 rounded-lg">
6
6
  <div class="flex items-start gap-3">
7
7
  <svg class="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
8
8
  <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"/>
9
9
  </svg>
10
10
  <div>
11
- <h3 class="text-sm font-medium text-red-800 dark:text-red-200">
11
+ <h3 class="text-sm font-medium text-red-800">
12
12
  <%= pluralize(resource.errors.count, "error") %> prohibited this record from being saved:
13
13
  </h3>
14
- <ul class="mt-2 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
14
+ <ul class="mt-2 text-sm text-red-700 list-disc list-inside">
15
15
  <% resource.errors.full_messages.each do |message| %>
16
16
  <li><%= message %></li>
17
17
  <% end %>
@@ -24,10 +24,10 @@
24
24
  <% resource_config.form_config&.fields_list&.each do |field| %>
25
25
  <% case field %>
26
26
  <% when Admin::Base::Resource::SectionDefinition %>
27
- <div class="border-t border-slate-200 dark:border-slate-700 pt-6 mt-6">
28
- <h3 class="text-lg font-medium text-slate-900 dark:text-white mb-1"><%= field.title %></h3>
27
+ <div class="border-t border-slate-200 pt-6 mt-6">
28
+ <h3 class="text-lg font-medium text-slate-900 mb-1"><%= field.title %></h3>
29
29
  <% if field.description.present? %>
30
- <p class="text-sm text-slate-500 dark:text-slate-400 mb-4"><%= field.description %></p>
30
+ <p class="text-sm text-slate-500 mb-4"><%= field.description %></p>
31
31
  <% end %>
32
32
  </div>
33
33
  <% when Admin::Base::Resource::SectionEnd %>
@@ -51,8 +51,8 @@
51
51
  <% end %>
52
52
  <% end %>
53
53
 
54
- <div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-700">
55
- <%= link_to "Cancel", resource.new_record? ? url_for(action: :index) : url_for(action: :show, id: resource.to_param), class: "px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors" %>
54
+ <div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-200">
55
+ <%= link_to "Cancel", resource.new_record? ? url_for(action: :index) : url_for(action: :show, id: resource.to_param), class: "px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors" %>
56
56
  <%= f.button class: theme_btn_primary_small_class, data: { disable_with: "Saving..." } do %>
57
57
  <%= resource.new_record? ? "Create #{resource_config.human_name}" : "Save Changes" %>
58
58
  <% end %>
@@ -18,9 +18,9 @@
18
18
 
19
19
  <div data-controller="admin-suite--json-editor">
20
20
  <% if field.help.present? %>
21
- <p class="text-sm text-slate-500 dark:text-slate-400 mb-2"><%= field.help %></p>
21
+ <p class="text-sm text-slate-500 mb-2"><%= field.help %></p>
22
22
  <% else %>
23
- <p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Enter valid JSON. Use the Format button to pretty-print.</p>
23
+ <p class="text-sm text-slate-500 mb-2">Enter valid JSON. Use the Format button to pretty-print.</p>
24
24
  <% end %>
25
25
 
26
26
  <div class="relative">
@@ -38,7 +38,7 @@
38
38
  <button
39
39
  type="button"
40
40
  data-action="click->admin-suite--json-editor#format"
41
- class="px-3 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded transition-colors"
41
+ class="px-3 py-1.5 text-xs font-medium text-slate-700 bg-slate-100 hover:bg-slate-200 rounded transition-colors"
42
42
  >
43
43
  Format JSON
44
44
  </button>
@@ -47,6 +47,6 @@
47
47
 
48
48
  <div
49
49
  data-admin-suite--json-editor-target="error"
50
- class="hidden mt-2 text-sm text-red-600 dark:text-red-400"
50
+ class="hidden mt-2 text-sm text-red-600"
51
51
  ></div>
52
52
  </div>
@@ -44,13 +44,13 @@
44
44
  <!-- Portal Navigation -->
45
45
  <nav class="flex-1 overflow-y-auto px-3 py-4 space-y-2">
46
46
  <% sorted_portals.each do |portal_key, portal| %>
47
- <% portal_home = portal_path(portal: portal_key) %>
48
- <% portal_prefix = File.join(request.script_name.to_s, portal_key.to_s) %>
49
- <% active = request.path.start_with?(portal_prefix) %>
47
+ <% portal_home = portal_path(portal: portal_key).to_s %>
48
+ <% portal_prefix = portal_home.sub(%r{/*\z}, "") %>
49
+ <% active = portal_prefix.present? && (request.path == portal_prefix || request.path.start_with?("#{portal_prefix}/")) %>
50
50
  <% color = portal_color(portal_key) %>
51
51
 
52
52
  <div>
53
- <%= link_to (portal_home || root_path),
53
+ <%= link_to (portal_prefix.presence || root_path),
54
54
  class: "flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors #{active ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"}" do %>
55
55
  <div class="admin-suite-portal-accent admin-suite-portal-accent--<%= color %> admin-suite-portal-chip flex items-center justify-center w-8 h-8 rounded-lg <%= active ? "" : "bg-white/10" %>">
56
56
  <%= portal_icon(portal_key, class: "admin-suite-portal-icon w-4 h-4") %>
@@ -7,6 +7,7 @@
7
7
  %>
8
8
  <%
9
9
  value = record.public_send(field)
10
+ frame_id = dom_id(record, "#{field}_toggle")
10
11
  toggle_url ||= begin
11
12
  url_for(action: :toggle, id: record.id, field: field)
12
13
  rescue ActionController::UrlGenerationError
@@ -16,11 +17,12 @@
16
17
  end
17
18
  %>
18
19
 
19
- <%= turbo_frame_tag dom_id(record, :toggle), class: "admin-suite-toggle-wrap inline-flex align-middle" do %>
20
+ <%= turbo_frame_tag frame_id, class: "admin-suite-toggle-wrap inline-flex align-middle" do %>
20
21
  <% if toggle_url %>
21
22
  <%= button_to toggle_url,
22
23
  method: :post,
23
- form: { data: { turbo_frame: dom_id(record, :toggle) }, class: "m-0 inline-flex align-middle items-center" },
24
+ params: { field: field },
25
+ form: { data: { turbo_frame: frame_id }, class: "m-0 inline-flex align-middle items-center" },
24
26
  class: "admin-suite-toggle-track #{value ? 'is-on' : ''}" do %>
25
27
  <span class="sr-only">Toggle <%= field.to_s.humanize %></span>
26
28
  <span class="admin-suite-toggle-thumb"></span>
@@ -6,7 +6,7 @@
6
6
  <div class="flex items-center gap-4">
7
7
  <!-- Mobile menu button -->
8
8
  <button type="button"
9
- class="lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100"
9
+ class="admin-suite-mobile-menu-button lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100"
10
10
  data-action="click->admin-suite--sidebar#toggle">
11
11
  <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12
12
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
@@ -42,6 +42,19 @@
42
42
  <%= (admin_suite_actor&.respond_to?(:name) && admin_suite_actor.name.present?) ? admin_suite_actor.name.first.upcase : "A" %>
43
43
  </div>
44
44
  </div>
45
+
46
+ <% logout_path = admin_suite_logout_path %>
47
+ <% if logout_path.present? %>
48
+ <div class="pl-3 border-l border-slate-200">
49
+ <%= button_to logout_path,
50
+ method: admin_suite_logout_method,
51
+ form: { class: "inline-flex" },
52
+ class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:text-red-600 hover:bg-red-50 transition-colors" do %>
53
+ <%= admin_suite_icon("log-out", class: "w-4 h-4") %>
54
+ <span class="hidden sm:inline"><%= admin_suite_logout_label %></span>
55
+ <% end %>
56
+ </div>
57
+ <% end %>
45
58
  </div>
46
59
  </div>
47
60
  </header>
@@ -41,24 +41,24 @@
41
41
  <body class="admin-suite bg-slate-50">
42
42
  <div class="flex h-screen overflow-hidden" data-controller="admin-suite--sidebar">
43
43
  <!-- Sidebar -->
44
- <div class="hidden lg:flex lg:flex-shrink-0">
44
+ <div class="admin-suite-desktop-sidebar lg:flex lg:flex-shrink-0">
45
45
  <%= render "admin_suite/shared/sidebar" %>
46
46
  </div>
47
47
 
48
48
  <!-- Mobile sidebar overlay -->
49
49
  <div data-admin-suite--sidebar-target="overlay"
50
- class="hidden fixed inset-0 z-40 bg-slate-600 bg-opacity-75 lg:hidden"
50
+ class="admin-suite-mobile-overlay hidden fixed inset-0 z-40 bg-slate-600 bg-opacity-75 lg:hidden"
51
51
  data-action="click->admin-suite--sidebar#close">
52
52
  </div>
53
53
 
54
54
  <!-- Mobile sidebar -->
55
55
  <div data-admin-suite--sidebar-target="mobileSidebar"
56
- class="hidden fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out lg:hidden">
56
+ class="admin-suite-mobile-sidebar hidden fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out lg:hidden">
57
57
  <%= render "admin_suite/shared/sidebar" %>
58
58
  </div>
59
59
 
60
60
  <!-- Main content area -->
61
- <div class="flex flex-col flex-1 overflow-hidden">
61
+ <div class="admin-suite-main flex flex-col flex-1 overflow-hidden">
62
62
  <!-- Top bar -->
63
63
  <%= render "admin_suite/shared/topbar" %>
64
64
 
@@ -29,6 +29,9 @@ These are the defaults in `AdminSuite::Configuration` / `AdminSuite::Engine`:
29
29
  - `authenticate`: `nil`
30
30
  - `current_actor`: `nil`
31
31
  - `authorize`: `nil`
32
+ - `logout_path`: `nil`
33
+ - `logout_method`: `:delete`
34
+ - `logout_label`: `"Log out"`
32
35
  - `resource_globs`: defaults to:
33
36
  - `Rails.root/config/admin_suite/resources/*.rb`
34
37
  - `Rails.root/app/admin/resources/*.rb`
@@ -39,6 +42,11 @@ These are the defaults in `AdminSuite::Configuration` / `AdminSuite::Engine`:
39
42
  - `Rails.root/config/admin_suite/portals/*.rb`
40
43
  - `Rails.root/app/admin/portals/*.rb`
41
44
  - `Rails.root/app/admin_suite/portals/*.rb`
45
+ - `dashboard_globs`: defaults to:
46
+ - `Rails.root/config/admin_suite/dashboard.rb`
47
+ - `Rails.root/config/admin_suite/dashboard/*.rb`
48
+ - `Rails.root/app/admin_suite/dashboard.rb`
49
+ - `Rails.root/app/admin_suite/dashboard/*.rb`
42
50
 
43
51
  Note: AdminSuite definition files (resources, actions, portals) often don't follow
44
52
  Zeitwerk's path-to-constant naming conventions. To prevent eager-load `Zeitwerk::NameError`s
@@ -92,6 +100,33 @@ Optional authorization hook (you can wire Pundit/CanCan/ActionPolicy/etc).
92
100
 
93
101
  Note: this hook is available, but your app must call it from resource definitions / custom actions as needed (AdminSuite will not guess your authorization policy).
94
102
 
103
+ ### `logout_path`
104
+
105
+ Optional sign-out action shown in the top bar.
106
+
107
+ - **Type**: `Proc`, `String`, `Symbol`, or `nil`
108
+ - **Proc signature**: `->(view_context) { ... }`
109
+
110
+ Example:
111
+
112
+ ```ruby
113
+ config.logout_path = ->(view) { view.main_app.internal_developer_logout_path }
114
+ ```
115
+
116
+ ### `logout_method`
117
+
118
+ HTTP method for the topbar sign-out button.
119
+
120
+ - **Type**: `Symbol` or `String`
121
+ - **Default**: `:delete`
122
+
123
+ ### `logout_label`
124
+
125
+ Button label for the topbar sign-out action.
126
+
127
+ - **Type**: `String` (or `Proc` for dynamic label)
128
+ - **Default**: `"Log out"`
129
+
95
130
  ### `resource_globs`
96
131
 
97
132
  Where AdminSuite should load resource definition files from.
@@ -126,6 +161,26 @@ Where AdminSuite should load portal definition files from (files typically call
126
161
 
127
162
  - **Type**: `Array<String>`
128
163
 
164
+ ### `dashboard_globs`
165
+
166
+ Where AdminSuite should load the root dashboard definition file(s) from (files typically call `AdminSuite.root_dashboard { ... }`).
167
+
168
+ - **Type**: `Array<String>`
169
+
170
+ ### `root_dashboard_title`
171
+
172
+ Optional title shown on the root dashboard.
173
+
174
+ - **Type**: `String`, `Proc`, or `nil`
175
+ - **Proc signature**: `->(controller) { "Admin Suite" }`
176
+
177
+ ### `root_dashboard_description`
178
+
179
+ Optional description shown on the root dashboard.
180
+
181
+ - **Type**: `String`, `Proc`, or `nil`
182
+ - **Proc signature**: `->(controller) { "..." }`
183
+
129
184
  ### `portals`
130
185
 
131
186
  Portal metadata used for navigation (label/icon/color/order). This is separate from the portal DSL and can be used alone.
data/docs/portals.md CHANGED
@@ -96,3 +96,45 @@ Example:
96
96
  - `/internal/admin/ops`
97
97
  - `/internal/admin/ai`
98
98
 
99
+ ## Root dashboard (`AdminSuite.root_dashboard`)
100
+
101
+ The engine root (`/`, relative to the mount path) renders a default dashboard (portal cards + basic stats).
102
+
103
+ To customize it, create a dashboard definition file (loaded from `AdminSuite.config.dashboard_globs`, which defaults to paths like `config/admin_suite/dashboard.rb` and `app/admin_suite/dashboard.rb`):
104
+
105
+ ```ruby
106
+ # config/admin_suite/dashboard.rb
107
+ AdminSuite.configure do |config|
108
+ config.root_dashboard_title = "Developer Portal"
109
+ config.root_dashboard_description = "Internal tools for managing application resources."
110
+ end
111
+
112
+ AdminSuite.root_dashboard do
113
+ row do
114
+ cards_panel "Portals",
115
+ span: 12,
116
+ variant: :portals,
117
+ resources: ->(view) do
118
+ view.navigation_items
119
+ .sort_by { |(_k, meta)| (meta[:order] || 100).to_i }
120
+ .map do |portal_key, portal|
121
+ {
122
+ key: portal_key,
123
+ label: portal[:label] || portal_key.to_s.humanize,
124
+ description: portal[:description],
125
+ color: view.portal_color(portal_key),
126
+ icon: portal[:icon],
127
+ path: view.portal_path(portal: portal_key),
128
+ count: (portal[:sections] || {}).values.sum { |s| Array(s[:items]).size }
129
+ }
130
+ end
131
+ end
132
+ end
133
+
134
+ row do
135
+ stat_panel "Portals", ->(view) { view.navigation_items.keys.count }, span: 6, variant: :mini, color: :slate
136
+ stat_panel "Resources", -> { Admin::Base::Resource.registered_resources.count }, span: 6, variant: :mini, color: :slate
137
+ end
138
+ end
139
+ ```
140
+
@@ -6,9 +6,13 @@ module AdminSuite
6
6
  attr_accessor :authenticate,
7
7
  :current_actor,
8
8
  :authorize,
9
+ :logout_path,
10
+ :logout_method,
11
+ :logout_label,
9
12
  :resource_globs,
10
13
  :action_globs,
11
14
  :portal_globs,
15
+ :dashboard_globs,
12
16
  :portals,
13
17
  :custom_renderers,
14
18
  :icon_renderer,
@@ -18,6 +22,10 @@ module AdminSuite
18
22
  :theme,
19
23
  :host_stylesheet,
20
24
  :tailwind_cdn,
25
+ :root_dashboard_title,
26
+ :root_dashboard_description,
27
+ :root_dashboard_definition,
28
+ :root_dashboard_loaded,
21
29
  :on_action_executed,
22
30
  :resolve_action_handler
23
31
 
@@ -25,9 +33,13 @@ module AdminSuite
25
33
  @authenticate = nil
26
34
  @current_actor = nil
27
35
  @authorize = nil
36
+ @logout_path = nil
37
+ @logout_method = :delete
38
+ @logout_label = "Log out"
28
39
  @resource_globs = []
29
40
  @action_globs = []
30
41
  @portal_globs = []
42
+ @dashboard_globs = []
31
43
  @portals = {}
32
44
  @custom_renderers = {}
33
45
  @icon_renderer = nil
@@ -37,6 +49,10 @@ module AdminSuite
37
49
  @theme = { primary: :indigo, secondary: :purple }
38
50
  @host_stylesheet = nil
39
51
  @tailwind_cdn = true
52
+ @root_dashboard_title = nil
53
+ @root_dashboard_description = nil
54
+ @root_dashboard_definition = nil
55
+ @root_dashboard_loaded = false
40
56
  @on_action_executed = nil
41
57
  @resolve_action_handler = nil
42
58
  end