ruby_cms 0.2.0.9 → 0.2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73750814275495274cd4153a97c081ff9b93e63b1afb62714ed9eec06ef9aa76
4
- data.tar.gz: dfd98b9a461601b9feadbcf55817d23dc62eb8bfacfa3992548d7d623bb91b69
3
+ metadata.gz: 7267ef45796f4fa3970c9737eaa93ae97acf466e312d471e111a30a2c8161a2d
4
+ data.tar.gz: 1a38b722589679fd1ea1884580f8e05d597676aa3d4b8cf92611c68a5303d705
5
5
  SHA512:
6
- metadata.gz: e8245f92e21637fd81d019629b800774261bf7aa83a8810c54836a5089164be05b039822201413bb8e5c80e90036a4a5b969344069b171d81882b89a1563e0cf
7
- data.tar.gz: cf0669c97fc3f289a111e76d38a93e8eec8f22839869cfe0bc3256bdf7648eee45e663458499e8d271d5cbe3b3057288862114644a1542c50858d75c242f93d8
6
+ metadata.gz: 8c082f14f3b10272bb767b5ac5a4f8b3d9f2c89ddfbdba13bd85b8c4589516b13745a21c4363f093aa103ff5ae11afd534bd4babe2be1d5a9b16b3be003ee538
7
+ data.tar.gz: 50e24617dbe4ad914040fdd2260643425b0a935523fc50ae239ae41f53e002aa127a2d2887915885a5203e2c39ab8380d03d1a16dab69514cdfd504d0e987bf8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1.1] - 2026-04-14
4
+ - Navigation: add `RubyCms.nav_group(...)` to define sidebar accordion groups in config (optional group page + child pages).
5
+
6
+ ## [0.2.1.0] - 2026-04-10
7
+
8
+ - Analytics rework and dashboard changes
9
+
3
10
  ## [0.2.0.8] - 2026-04-09
4
11
 
5
12
  - Analytics performance: migration adds `ahoy_events (name, time)`, `ahoy_events (visit_id, time)`, `ahoy_visits (started_at)`, `ahoy_visits (visitor_token)` indexes
data/README.md CHANGED
@@ -229,6 +229,26 @@ RubyCms.nav_register(
229
229
  )
230
230
  ```
231
231
 
232
+ ### nav_group (accordion)
233
+
234
+ Define a sidebar accordion group in the same config file as your pages:
235
+
236
+ ```ruby
237
+ RubyCms.nav_group(
238
+ key: :operations,
239
+ label: "Operations",
240
+ icon: :folder,
241
+ section: "main",
242
+ order: 20,
243
+ # Optional: the group can have its own page
244
+ path: ->(view) { view.main_app.admin_operations_path },
245
+ # Child pages are referenced by the keys you used in register_page/nav_register
246
+ children: %i[backups reports]
247
+ )
248
+ ```
249
+
250
+ Groups are hidden automatically when they have no visible children and no `path`.
251
+
232
252
  ### Path Options
233
253
 
234
254
  | Format | Example | Behavior |
@@ -117,7 +117,7 @@ module RubyCms
117
117
  nil
118
118
  end
119
119
 
120
- helper_method :format_chart_date, :format_chart_date_short
120
+ helper_method :format_chart_date, :format_chart_date_short, :format_chart_date_axis
121
121
 
122
122
  def format_chart_date(date_string)
123
123
  format_chart_date_by_granularity(date_string, long: true)
@@ -131,6 +131,16 @@ module RubyCms
131
131
  date_string.to_s
132
132
  end
133
133
 
134
+ # Compact x-axis tick for daily chart (month view: day number only to avoid crowded labels)
135
+ def format_chart_date_axis(date_string)
136
+ date = Date.parse(date_string.to_s)
137
+ return date.day.to_s if @period == "month"
138
+
139
+ format_chart_date_short(date_string)
140
+ rescue Date::Error
141
+ date_string.to_s
142
+ end
143
+
134
144
  def format_daily_date(date_string)
135
145
  date = Date.parse(date_string.to_s)
136
146
  if @period == "month"
@@ -26,9 +26,8 @@ module RubyCms
26
26
 
27
27
  def assign_dashboard_blocks
28
28
  visible = RubyCms.visible_dashboard_blocks(user: current_user_cms)
29
- @stats_blocks = visible
30
- .select {|b| b[:section] == :stats }
31
- .map {|b| prepare_dashboard_block(b) }
29
+ # Top :stats row (pages, users, permissions, errors) is not rendered on the dashboard layout.
30
+ @stats_blocks = []
32
31
  main = visible
33
32
  .select {|b| b[:section] == :main }
34
33
  .map {|b| prepare_dashboard_block(b) }
@@ -25,6 +25,14 @@ module RubyCms
25
25
  )
26
26
  end
27
27
 
28
+ def ruby_cms_nav_sidebar_rows(section: :main)
29
+ RubyCms.visible_nav_sidebar_rows(
30
+ section:,
31
+ view_context: self,
32
+ user: (current_user_cms if respond_to?(:current_user_cms))
33
+ )
34
+ end
35
+
28
36
  # Render an SVG fragment (typically <path ...>) safely.
29
37
  # Used for nav icons which may come from host app configuration.
30
38
  def ruby_cms_safe_svg_fragment(fragment)
@@ -58,7 +58,6 @@ export function registerRubyCmsControllers(application) {
58
58
  "ruby-cms--nav-order-sortable",
59
59
  NavOrderSortableController,
60
60
  );
61
-
62
61
  registeredApplications.add(application);
63
62
  }
64
63
 
@@ -1,6 +1,6 @@
1
1
  <%# Admin Sidebar Navigation %>
2
- <aside class="w-46 flex-shrink-0 bg-[#FAF9F5] flex flex-col overflow-hidden" aria-label="Admin" data-ruby-cms--mobile-menu-target="sidebar">
3
- <div class="px-5 py-5 flex-shrink-0 bg-[#FAF9F5]">
2
+ <aside class="flex h-full min-h-0 w-56 max-w-56 shrink-0 flex-col overflow-hidden bg-[#FAF9F5]" aria-label="Admin" data-ruby-cms--mobile-menu-target="sidebar">
3
+ <div class="pl-3 pr-4 py-5 flex-shrink-0 bg-[#FAF9F5]">
4
4
  <div class="flex items-center gap-3" data-ruby-cms--mobile-menu-target="sidebarContent">
5
5
  <% if respond_to?(:image_tag, true) %>
6
6
  <div class="w-9 h-9 flex-shrink-0 rounded-md bg-white border border-gray-200 overflow-hidden">
@@ -14,60 +14,178 @@
14
14
  </div>
15
15
  </div>
16
16
 
17
- <nav class="flex-1 px-3 py-6 overflow-y-auto" aria-label="Admin" data-ruby-cms--mobile-menu-target="sidebarContent">
18
- <%
19
- nav_main = "main"
20
- nav_bottom = "Settings"
21
- entries = ruby_cms_nav_entries
22
- by_section = entries.group_by { |e| s = e[:section].to_s.presence || nav_main; s == "custom" ? nav_bottom : s }
23
- main_items = by_section[nav_main] || []
24
- bottom_items = by_section[nav_bottom] || []
25
- %>
17
+ <nav class="flex-1 min-w-0 pl-2 pr-2.5 py-5 overflow-y-auto overflow-x-hidden" aria-label="Admin" data-ruby-cms--mobile-menu-target="sidebarContent">
18
+ <% main_rows = ruby_cms_nav_sidebar_rows(section: :main) %>
19
+ <% bottom_rows = ruby_cms_nav_sidebar_rows(section: :settings) %>
26
20
  <%# Main nav: top, scrollable %>
27
- <div class="flex-1 overflow-y-auto space-y-1 min-h-0">
28
- <% main_items.each do |e| %>
29
- <% begin %>
30
- <% path = e[:path].respond_to?(:call) ? e[:path].call(self) : e[:path] %>
31
- <% next if path.blank? %>
32
- <% path_str = path.respond_to?(:to_str) ? path.to_str : path.to_s %>
33
- <% active = (path_str == "/admin" || path_str == "/admin/") ? request.path.match?(%r{\A/admin/?\z}) : request.path.start_with?(path_str) %>
34
- <%= link_to path, class: "flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-md mb-0.5 no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
35
- <% if e[:icon].present? %>
36
- <svg class="w-5 h-5 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
37
- <%= ruby_cms_safe_svg_fragment(e[:icon]) %>
38
- </svg>
21
+ <div class="min-h-0 flex-1 space-y-1 overflow-y-auto overflow-x-hidden">
22
+ <% main_rows.each do |row| %>
23
+ <% if row[:type] == :link %>
24
+ <% e = row[:entry] %>
25
+ <% begin %>
26
+ <% path = e[:path].respond_to?(:call) ? e[:path].call(self) : e[:path] %>
27
+ <% next if path.blank? %>
28
+ <% path_str = path.respond_to?(:to_str) ? path.to_str : path.to_s %>
29
+ <% active = (path_str == "/admin" || path_str == "/admin/") ? request.path.match?(%r{\A/admin/?\z}) : request.path.start_with?(path_str) %>
30
+ <%= link_to path, class: "flex min-w-0 items-center gap-2 px-2 py-2 text-xs font-medium leading-5 rounded-md mb-0.5 no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
31
+ <% if e[:icon].present? %>
32
+ <svg class="w-4 h-4 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
33
+ <%= ruby_cms_safe_svg_fragment(e[:icon]) %>
34
+ </svg>
35
+ <% end %>
36
+ <span class="min-w-0 flex-1 truncate"><%= e[:label] %></span>
39
37
  <% end %>
40
- <span class="flex-1"><%= e[:label] %></span>
38
+ <% rescue => ex %>
39
+ <% Rails.logger.error "RubyCMS nav error for #{e[:key]}: #{ex.message}" if defined?(Rails.logger) %>
41
40
  <% end %>
42
- <% rescue => ex %>
43
- <% Rails.logger.error "RubyCMS nav error for #{e[:key]}: #{ex.message}" if defined?(Rails.logger) %>
41
+ <% else %>
42
+ <% g = row[:group] %>
43
+ <% children = row[:children] || [] %>
44
+ <% g_icon = g[:icon] %>
45
+ <% details_open = g.fetch(:default_open, true) %>
46
+ <details class="mb-0.5" <%= "open" if details_open %>>
47
+ <summary class="flex min-w-0 items-center justify-between gap-2 px-2 py-2 text-xs font-medium leading-5 rounded-md text-gray-600 cursor-pointer select-none hover:bg-blue-500 hover:text-white list-none [&::-webkit-details-marker]:hidden">
48
+ <span class="flex min-w-0 flex-1 items-center gap-2">
49
+ <% if g_icon.present? %>
50
+ <svg class="w-4 h-4 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
51
+ <%= ruby_cms_safe_svg_fragment(g_icon) %>
52
+ </svg>
53
+ <% end %>
54
+ <span class="truncate"><%= g[:label] %></span>
55
+ </span>
56
+ <svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
57
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
58
+ </svg>
59
+ </summary>
60
+ <div class="mt-0.5 space-y-0.5 ml-2 pl-2 py-1">
61
+ <% if g[:path].present? %>
62
+ <% begin %>
63
+ <% gpath = g[:path].respond_to?(:call) ? g[:path].call(self) : g[:path] %>
64
+ <% if gpath.present? %>
65
+ <% gpath_str = gpath.respond_to?(:to_str) ? gpath.to_str : gpath.to_s %>
66
+ <% active = (gpath_str == "/admin" || gpath_str == "/admin/") ? request.path.match?(%r{\A/admin/?\z}) : request.path.start_with?(gpath_str) %>
67
+ <%= link_to gpath, class: "flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-xs font-medium leading-5 rounded-md no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
68
+ <% if g_icon.present? %>
69
+ <svg class="w-3.5 h-3.5 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70
+ <%= ruby_cms_safe_svg_fragment(g_icon) %>
71
+ </svg>
72
+ <% end %>
73
+ <span class="min-w-0 flex-1 truncate"><%= g[:label] %></span>
74
+ <% end %>
75
+ <% end %>
76
+ <% rescue => ex %>
77
+ <% Rails.logger.error "RubyCMS nav group link #{g[:key]}: #{ex.message}" if defined?(Rails.logger) %>
78
+ <% end %>
79
+ <% end %>
80
+ <% children.each do |e| %>
81
+ <% begin %>
82
+ <% path = e[:path].respond_to?(:call) ? e[:path].call(self) : e[:path] %>
83
+ <% next if path.blank? %>
84
+ <% path_str = path.respond_to?(:to_str) ? path.to_str : path.to_s %>
85
+ <% active = (path_str == "/admin" || path_str == "/admin/") ? request.path.match?(%r{\A/admin/?\z}) : request.path.start_with?(path_str) %>
86
+ <%= link_to path, class: "flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-xs font-medium leading-5 rounded-md no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
87
+ <% if e[:icon].present? %>
88
+ <svg class="w-3.5 h-3.5 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
89
+ <%= ruby_cms_safe_svg_fragment(e[:icon]) %>
90
+ </svg>
91
+ <% end %>
92
+ <span class="min-w-0 flex-1 truncate"><%= e[:label] %></span>
93
+ <% end %>
94
+ <% rescue => ex %>
95
+ <% Rails.logger.error "RubyCMS nav group child #{e[:key]}: #{ex.message}" if defined?(Rails.logger) %>
96
+ <% end %>
97
+ <% end %>
98
+ </div>
99
+ </details>
44
100
  <% end %>
45
101
  <% end %>
46
102
  </div>
47
103
 
48
104
  <%# Spacer so bottom section sits above footer %>
49
105
  <div class="flex-shrink-0 pt-4 mt-4">
50
- <% if bottom_items.any? %>
51
- <div class="text-xs font-semibold uppercase tracking-wider text-gray-500 px-2 pb-2">
106
+ <% if bottom_rows.any? %>
107
+ <div class="px-1.5 pb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
52
108
  <%= t("ruby_cms.nav.settings", default: "Settings") %>
53
109
  </div>
54
110
  <div class="space-y-1">
55
- <% bottom_items.each do |e| %>
56
- <% begin %>
57
- <% path = e[:path].respond_to?(:call) ? e[:path].call(self) : e[:path] %>
58
- <% next if path.blank? %>
59
- <% path_str = path.respond_to?(:to_str) ? path.to_str : path.to_s %>
60
- <% active = request.path.start_with?(path_str) %>
61
- <%= link_to path, class: "flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-md mb-0.5 no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
62
- <% if e[:icon].present? %>
63
- <svg class="w-5 h-5 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
64
- <%= ruby_cms_safe_svg_fragment(e[:icon]) %>
65
- </svg>
111
+ <% bottom_rows.each do |row| %>
112
+ <% if row[:type] == :link %>
113
+ <% e = row[:entry] %>
114
+ <% begin %>
115
+ <% path = e[:path].respond_to?(:call) ? e[:path].call(self) : e[:path] %>
116
+ <% next if path.blank? %>
117
+ <% path_str = path.respond_to?(:to_str) ? path.to_str : path.to_s %>
118
+ <% active = request.path.start_with?(path_str) %>
119
+ <%= link_to path, class: "flex min-w-0 items-center gap-2 px-2 py-2 text-xs font-medium leading-5 rounded-md mb-0.5 no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
120
+ <% if e[:icon].present? %>
121
+ <svg class="w-4 h-4 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
122
+ <%= ruby_cms_safe_svg_fragment(e[:icon]) %>
123
+ </svg>
124
+ <% end %>
125
+ <span class="min-w-0 flex-1 truncate"><%= e[:label] %></span>
66
126
  <% end %>
67
- <span class="flex-1"><%= e[:label] %></span>
127
+ <% rescue => ex %>
128
+ <% Rails.logger.error "RubyCMS nav error for #{e[:key]}: #{ex.message}" if defined?(Rails.logger) %>
68
129
  <% end %>
69
- <% rescue => ex %>
70
- <% Rails.logger.error "RubyCMS nav error for #{e[:key]}: #{ex.message}" if defined?(Rails.logger) %>
130
+ <% else %>
131
+ <% g = row[:group] %>
132
+ <% children = row[:children] || [] %>
133
+ <% g_icon = g[:icon] %>
134
+ <% details_open = g.fetch(:default_open, true) %>
135
+ <details class="mb-0.5" <%= "open" if details_open %>>
136
+ <summary class="flex min-w-0 items-center justify-between gap-2 px-2 py-2 text-xs font-medium leading-5 rounded-md text-gray-600 cursor-pointer select-none hover:bg-blue-500 hover:text-white list-none [&::-webkit-details-marker]:hidden">
137
+ <span class="flex min-w-0 flex-1 items-center gap-2">
138
+ <% if g_icon.present? %>
139
+ <svg class="w-4 h-4 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
140
+ <%= ruby_cms_safe_svg_fragment(g_icon) %>
141
+ </svg>
142
+ <% end %>
143
+ <span class="truncate"><%= g[:label] %></span>
144
+ </span>
145
+ <svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
146
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
147
+ </svg>
148
+ </summary>
149
+ <div class="mt-0.5 space-y-0.5 ml-2 pl-2 py-1">
150
+ <% if g[:path].present? %>
151
+ <% begin %>
152
+ <% gpath = g[:path].respond_to?(:call) ? g[:path].call(self) : g[:path] %>
153
+ <% if gpath.present? %>
154
+ <% gpath_str = gpath.respond_to?(:to_str) ? gpath.to_str : gpath.to_s %>
155
+ <% active = request.path.start_with?(gpath_str) %>
156
+ <%= link_to gpath, class: "flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-xs font-medium leading-5 rounded-md no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
157
+ <% if g_icon.present? %>
158
+ <svg class="w-3.5 h-3.5 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
159
+ <%= ruby_cms_safe_svg_fragment(g_icon) %>
160
+ </svg>
161
+ <% end %>
162
+ <span class="min-w-0 flex-1 truncate"><%= g[:label] %></span>
163
+ <% end %>
164
+ <% end %>
165
+ <% rescue => ex %>
166
+ <% Rails.logger.error "RubyCMS nav group link #{g[:key]}: #{ex.message}" if defined?(Rails.logger) %>
167
+ <% end %>
168
+ <% end %>
169
+ <% children.each do |e| %>
170
+ <% begin %>
171
+ <% path = e[:path].respond_to?(:call) ? e[:path].call(self) : e[:path] %>
172
+ <% next if path.blank? %>
173
+ <% path_str = path.respond_to?(:to_str) ? path.to_str : path.to_s %>
174
+ <% active = request.path.start_with?(path_str) %>
175
+ <%= link_to path, class: "flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-xs font-medium leading-5 rounded-md no-underline transition-colors #{ active ? 'text-blue-600 font-semibold' : 'text-gray-600 hover:bg-blue-500 hover:text-white' }" do %>
176
+ <% if e[:icon].present? %>
177
+ <svg class="w-3.5 h-3.5 flex-shrink-0 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24">
178
+ <%= ruby_cms_safe_svg_fragment(e[:icon]) %>
179
+ </svg>
180
+ <% end %>
181
+ <span class="min-w-0 flex-1 truncate"><%= e[:label] %></span>
182
+ <% end %>
183
+ <% rescue => ex %>
184
+ <% Rails.logger.error "RubyCMS nav group child #{e[:key]}: #{ex.message}" if defined?(Rails.logger) %>
185
+ <% end %>
186
+ <% end %>
187
+ </div>
188
+ </details>
71
189
  <% end %>
72
190
  <% end %>
73
191
  </div>
@@ -75,16 +193,16 @@
75
193
  </div>
76
194
  </nav>
77
195
 
78
- <div class="px-3 py-3 flex-shrink-0 bg-[#FAF9F5]" data-ruby-cms--mobile-menu-target="sidebarContent">
196
+ <div class="min-w-0 shrink-0 bg-[#FAF9F5] py-2.5 pl-2 pr-2.5" data-ruby-cms--mobile-menu-target="sidebarContent">
79
197
  <div class="mb-2">
80
198
  <%= form_with url: ruby_cms_admin_locale_path, method: :patch, scope: nil, data: { turbo: false } do |form| %>
81
199
  <div class="relative">
82
- <div class="flex items-center gap-3 w-full px-3 py-2.5 text-sm font-medium text-gray-600 rounded-md no-underline transition-colors hover:bg-blue-500 hover:text-white">
83
- <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
200
+ <div class="flex w-full min-w-0 items-center gap-2 px-2 py-2 text-xs font-medium leading-5 text-gray-600 rounded-md no-underline transition-colors hover:bg-blue-500 hover:text-white">
201
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
84
202
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 21a9 9 0 100-18 9 9 0 000 18zM3.6 9h16.8M3.6 15h16.8M11 3c-2.2 2.2-3.5 5.2-3.5 9s1.3 6.8 3.5 9m2 0c2.2-2.2 3.5-5.2 3.5-9S15.2 5.2 13 3"/>
85
203
  </svg>
86
- <span class="flex-1 text-left"><%= ruby_cms_locale_display_name(I18n.locale) %></span>
87
- <svg class="w-4 h-4 flex-shrink-0 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
204
+ <span class="min-w-0 flex-1 truncate text-left"><%= ruby_cms_locale_display_name(I18n.locale) %></span>
205
+ <svg class="w-3.5 h-3.5 flex-shrink-0 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
88
206
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10l4-4 4 4M16 14l-4 4-4-4"/>
89
207
  </svg>
90
208
  </div>
@@ -102,20 +220,20 @@
102
220
  </div>
103
221
 
104
222
  <% if respond_to?(:main_app, true) && main_app.respond_to?(:root_path) %>
105
- <%= link_to main_app.root_path, target: "_blank", class: "flex items-center gap-3 w-full px-3 py-2.5 text-sm font-medium text-gray-600 rounded-md no-underline transition-colors hover:bg-blue-500 hover:text-white" do %>
106
- <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
223
+ <%= link_to main_app.root_path, target: "_blank", class: "flex w-full min-w-0 items-center gap-2 px-2 py-2 text-xs font-medium leading-5 text-gray-600 rounded-md no-underline transition-colors hover:bg-blue-500 hover:text-white" do %>
224
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
107
225
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
108
226
  </svg>
109
- <span class="flex-1 text-left"><%= t("ruby_cms.nav.view_site", default: "View site") %></span>
227
+ <span class="min-w-0 flex-1 truncate text-left"><%= t("ruby_cms.nav.view_site", default: "View site") %></span>
110
228
  <% end %>
111
229
  <% end %>
112
230
 
113
231
  <% if respond_to?(:main_app, true) && main_app.respond_to?(:session_path) %>
114
- <%= button_to main_app.session_path, method: :delete, class: "flex items-center gap-3 w-full px-3 py-2.5 text-sm font-medium text-gray-600 rounded-md bg-transparent border-0 cursor-pointer transition-colors hover:bg-blue-500 hover:text-white", data: { turbo_confirm: t("ruby_cms.nav.logout_confirm", default: "Are you sure you want to logout?") } do %>
115
- <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
232
+ <%= button_to main_app.session_path, method: :delete, class: "flex w-full min-w-0 items-center gap-2 px-2 py-2 text-xs font-medium leading-5 text-gray-600 rounded-md border-0 bg-transparent cursor-pointer transition-colors hover:bg-blue-500 hover:text-white", data: { turbo_confirm: t("ruby_cms.nav.logout_confirm", default: "Are you sure you want to logout?") } do %>
233
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
116
234
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
117
235
  </svg>
118
- <span class="flex-1 text-left"><%= t("ruby_cms.nav.logout", default: "Logout") %></span>
236
+ <span class="min-w-0 flex-1 truncate text-left"><%= t("ruby_cms.nav.logout", default: "Logout") %></span>
119
237
  <% end %>
120
238
  <% end %>
121
239
  </div>
@@ -0,0 +1,2 @@
1
+ <%# Tailwind safelist for RubyCMS utility classes used in gem views. %>
2
+ <div class="hidden bg-teal-100 text-teal-800 bg-rose-100 text-rose-800"></div>
@@ -37,23 +37,23 @@
37
37
  <% end %>
38
38
  </div>
39
39
 
40
- <%# ── Activity charts (3/4) + KPI summary card (1/4) ── %>
41
- <div class="grid gap-4 items-start" style="grid-template-columns: 3fr 1fr;">
40
+ <%# ── Activity charts (3/4) + KPI summary card (1/4), same row height ── %>
41
+ <div class="grid gap-4 items-stretch" style="grid-template-columns: 3fr 1fr;">
42
42
 
43
43
  <%# Left: activity charts stacked %>
44
- <div class="space-y-4 min-w-0">
44
+ <div class="flex min-h-0 min-w-0 flex-col gap-4">
45
45
  <%= render "ruby_cms/admin/analytics/partials/daily_activity_chart" %>
46
46
  <%= render "ruby_cms/admin/analytics/partials/hourly_activity_chart" %>
47
47
  </div>
48
48
 
49
- <%# Right: single KPI summary card %>
50
- <div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
51
- <div class="px-4 py-3 border-b border-border/60">
49
+ <%# Right: KPI summary stretches to match combined chart column height %>
50
+ <div class="flex h-full min-h-0 min-w-0 flex-col rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
51
+ <div class="shrink-0 px-4 py-3 border-b border-border/60">
52
52
  <p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Overview</p>
53
53
  </div>
54
54
 
55
55
  <%# Active users now %>
56
- <div class="px-4 py-3 bg-emerald-50/60 border-b border-emerald-100">
56
+ <div class="shrink-0 px-4 py-3 bg-emerald-50/60 border-b border-emerald-100">
57
57
  <div class="flex items-center justify-between">
58
58
  <div>
59
59
  <p class="text-xs font-medium text-emerald-700">Nu actief</p>
@@ -73,7 +73,7 @@
73
73
  </div>
74
74
  </div>
75
75
 
76
- <div class="divide-y divide-border/50">
76
+ <div class="min-h-0 flex-1 divide-y divide-border/50 overflow-y-auto">
77
77
 
78
78
  <%# Page Views %>
79
79
  <div class="px-4 py-3">