ruby_cms 0.2.1.0 → 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: 6d3c084a1a0a48bcb56bfe935ed7005b29c967617575e4ee284adbdfe5d020b8
4
- data.tar.gz: 8d2b6780c2195cb72fb571e9c532e1b20092132ba96699fc918e407c820a8b59
3
+ metadata.gz: 7267ef45796f4fa3970c9737eaa93ae97acf466e312d471e111a30a2c8161a2d
4
+ data.tar.gz: 1a38b722589679fd1ea1884580f8e05d597676aa3d4b8cf92611c68a5303d705
5
5
  SHA512:
6
- metadata.gz: 57a352ac8f7998cf1ae2287fd7f4c7264d90d000254a4be81f85b5e3c21ec35589cbc255f7ea54985f30498ad486d8ccdbd7b5eb73ff73fb133b43bef53d00cd
7
- data.tar.gz: 3fa14b8361bcadd7558965276034cd40461dc49c8ac5831fbf97d21aa0b56bfa03d2363b12395f363b54389f94a9bc8de05cb4f074f36bc0c0bcf301698d12e0
6
+ metadata.gz: 8c082f14f3b10272bb767b5ac5a4f8b3d9f2c89ddfbdba13bd85b8c4589516b13745a21c4363f093aa103ff5ae11afd534bd4babe2be1d5a9b16b3be003ee538
7
+ data.tar.gz: 50e24617dbe4ad914040fdd2260643425b0a935523fc50ae239ae41f53e002aa127a2d2887915885a5203e2c39ab8380d03d1a16dab69514cdfd504d0e987bf8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
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
+
3
6
  ## [0.2.1.0] - 2026-04-10
4
7
 
5
8
  - Analytics rework and dashboard changes
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 |
@@ -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)
@@ -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>
@@ -9,7 +9,7 @@
9
9
  <div class="flex flex-col divide-y divide-gray-100">
10
10
  <%= link_to new_ruby_cms_admin_content_block_path, class: "group flex items-center justify-between gap-2 px-5 py-3 hover:bg-gray-50 transition-colors" do %>
11
11
  <div class="flex min-w-0 items-center gap-2.5">
12
- <div class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-100 mr-2">
12
+ <div class="mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-teal-100 text-teal-800">
13
13
  <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
14
14
  </div>
15
15
  <div class="min-w-0">
@@ -35,7 +35,7 @@
35
35
 
36
36
  <%= link_to ruby_cms_admin_visitor_errors_path, class: "group flex items-center justify-between gap-2 px-5 py-3 hover:bg-gray-50 transition-colors" do %>
37
37
  <div class="flex min-w-0 items-center gap-2.5">
38
- <div class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-100 mr-2">
38
+ <div class="mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-rose-100 text-rose-800">
39
39
  <svg class="h-3.5 w-3.5" 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>
40
40
  </div>
41
41
  <div class="min-w-0">
@@ -3,7 +3,7 @@
3
3
  subtitle: "Welcome back! Here's an overview of your CMS.",
4
4
  content_card: false
5
5
  ) do %>
6
- <div class="space-y-5">
6
+ <div class="space-y-8 pb-8">
7
7
 
8
8
  <%# Main row: three equal columns — quick actions | recent errors | analytics %>
9
9
  <% if @primary_main_blocks.any? %>
@@ -492,8 +492,7 @@ module RubyCms
492
492
  end
493
493
 
494
494
  def configure_tailwind(tailwind_css)
495
- remove_ruby_cms_tailwind_source(tailwind_css)
496
- remove_ruby_cms_tailwind_content_paths
495
+ ensure_ruby_cms_tailwind_safelist_partial
497
496
  run "bin/rails tailwindcss:build" if File.exist?(tailwind_css)
498
497
  # Importmap pins are provided by the engine via `ruby_cms/config/importmap.rb`.
499
498
  add_importmap_pins
@@ -869,9 +868,89 @@ module RubyCms
869
868
  end.join("\n")
870
869
  end
871
870
 
871
+ # Keep Tailwind generation fast without scanning the gem path:
872
+ # generate a host-app safelist partial from RubyCMS classes.
873
+ def ensure_ruby_cms_tailwind_safelist_partial
874
+ classes = ruby_cms_tailwind_classes
875
+ return if classes.empty?
876
+
877
+ safelist_path = Rails.root.join("app/views/ruby_cms/_tailwind_safelist.html.erb")
878
+ FileUtils.mkdir_p(safelist_path.dirname)
879
+ File.write(safelist_path, build_tailwind_safelist_partial(classes))
880
+ say "✓ Task tailwind/safelist: Generated app/views/ruby_cms/_tailwind_safelist.html.erb.",
881
+ :green
882
+ rescue StandardError => e
883
+ say "⚠ Task tailwind/safelist: Could not generate safelist partial: #{e.message}.", :yellow
884
+ end
885
+
886
+ def ruby_cms_tailwind_classes
887
+ patterns = [
888
+ RubyCms::Engine.root.join("app/views/**/*.erb"),
889
+ RubyCms::Engine.root.join("app/components/**/*.rb"),
890
+ RubyCms::Engine.root.join("app/helpers/**/*.rb"),
891
+ RubyCms::Engine.root.join("app/javascript/**/*.js")
892
+ ]
893
+
894
+ tokens = Set.new
895
+ patterns.each do |pattern|
896
+ Dir.glob(pattern.to_s).each do |path|
897
+ extract_tailwind_tokens_from(File.read(path), tokens)
898
+ end
899
+ end
900
+
901
+ tokens.to_a.sort
902
+ end
903
+
904
+ def extract_tailwind_tokens_from(content, tokens)
905
+ class_strings(content).each do |raw|
906
+ add_tailwind_tokens(raw, tokens)
907
+
908
+ # Also include classes from interpolated branches:
909
+ # "#{active ? 'text-blue-600' : 'text-gray-600'}"
910
+ raw.scan(/['"]([^'"]+)['"]/).flatten.each do |inner|
911
+ add_tailwind_tokens(inner, tokens)
912
+ end
913
+ end
914
+ end
915
+
916
+ def class_strings(content)
917
+ regexes = [
918
+ /class\s*=\s*"([^"]+)"/m,
919
+ /class\s*=\s*'([^']+)'/m,
920
+ /class:\s*"([^"]+)"/m,
921
+ /class:\s*'([^']+)'/m
922
+ ]
923
+
924
+ regexes.flat_map { |regex| content.scan(regex).flatten.compact }
925
+ end
926
+
927
+ def add_tailwind_tokens(value, tokens)
928
+ value
929
+ .gsub(/\#\{[^}]*\}/, " ")
930
+ .split(/\s+/)
931
+ .map(&:strip)
932
+ .reject(&:empty?)
933
+ .each do |token|
934
+ next unless token.match?(/\A[-!:\[\]\/.%a-zA-Z0-9_]+\z/)
935
+ next unless token.match?(/[A-Za-z]/)
936
+ next if token.start_with?("http", "/", "#")
937
+
938
+ tokens << token
939
+ end
940
+ end
941
+
942
+ def build_tailwind_safelist_partial(classes)
943
+ grouped_classes = classes.each_slice(20).map { |group| group.join(" ") }.join("\n ")
944
+
945
+ <<~ERB
946
+ <%# RubyCMS Tailwind safelist (auto-generated by ruby_cms:install). %>
947
+ <%# Keep this file so Tailwind picks up classes used inside the RubyCMS gem. %>
948
+ <div class="hidden #{grouped_classes}"></div>
949
+ ERB
950
+ end
951
+
872
952
  # RubyCMS admin styles are precompiled to app/assets/stylesheets/ruby_cms/admin.css.
873
- # Remove RubyCMS-specific Tailwind source globs to keep host-app builds fast.
874
- # Not a generator task.
953
+ # Legacy cleanup helpers kept for backward compatibility.
875
954
  def remove_ruby_cms_tailwind_source(tailwind_css_path)
876
955
  return unless valid_tailwind_source_path?(tailwind_css_path)
877
956
 
@@ -39,15 +39,6 @@ module RubyCms
39
39
  permission: :manage_analytics,
40
40
  order: 1
41
41
  )
42
- RubyCms.register_page(
43
- key: :permissions,
44
- label: "Permissions",
45
- path: lambda(&:ruby_cms_admin_permissions_path),
46
- icon: :shield_check,
47
- section: :settings,
48
- permission: :manage_permissions,
49
- order: 2
50
- )
51
42
  RubyCms.register_page(
52
43
  key: :visitor_errors,
53
44
  label: "Visitor errors",
@@ -55,7 +46,16 @@ module RubyCms
55
46
  icon: :exclamation_triangle,
56
47
  section: :settings,
57
48
  permission: :manage_visitor_errors,
58
- order: 3
49
+ order: 2
50
+ )
51
+ RubyCms.register_page(
52
+ key: :permissions,
53
+ label: "Permissions",
54
+ path: lambda(&:ruby_cms_admin_permissions_path),
55
+ icon: :shield_check,
56
+ section: :settings,
57
+ permission: :manage_permissions,
58
+ order: 10
59
59
  )
60
60
  RubyCms.register_page(
61
61
  key: :users,
@@ -64,7 +64,7 @@ module RubyCms
64
64
  icon: :user_group,
65
65
  section: :settings,
66
66
  permission: :manage_permissions,
67
- order: 4
67
+ order: 20
68
68
  )
69
69
  RubyCms.register_page(
70
70
  key: :commands,
@@ -73,16 +73,17 @@ module RubyCms
73
73
  icon: :wrench,
74
74
  section: :settings,
75
75
  permission: :manage_admin,
76
- order: 5
76
+ order: 30
77
77
  )
78
- RubyCms.register_page(
78
+ RubyCms.nav_group(
79
79
  key: :settings,
80
80
  label: "Settings",
81
81
  path: lambda(&:ruby_cms_admin_settings_path),
82
82
  icon: :cog_6_tooth,
83
- section: :settings,
84
- permission: :manage_admin,
85
- order: 6
83
+ section: RubyCms::NAV_SECTION_BOTTOM,
84
+ children: %i[permissions users commands],
85
+ default_open: false,
86
+ order: 3
86
87
  )
87
88
  end
88
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyCms
4
- VERSION = "0.2.1.0"
4
+ VERSION = "0.2.1.1"
5
5
  end
data/lib/ruby_cms.rb CHANGED
@@ -75,6 +75,7 @@ module RubyCms
75
75
  resolved_path = path.kind_of?(Symbol) ? ->(v) { v.main_app.send(path) } : path
76
76
  resolved_icon = icon.nil? ? nil : RubyCms::Icons.resolve(icon)
77
77
  entry = {
78
+ type: :link,
78
79
  key: normalized_key,
79
80
  label: label.to_s,
80
81
  path: resolved_path,
@@ -94,6 +95,49 @@ module RubyCms
94
95
  entry
95
96
  end
96
97
 
98
+ # Register a navigation group (accordion) in the sidebar.
99
+ #
100
+ # A group can optionally have its own page (`path:`) and can contain child pages
101
+ # (registered via `register_page` / `nav_register`) referenced by their keys.
102
+ #
103
+ # Notes:
104
+ # - `children:` is an array of nav keys (Symbol/String) that will be rendered under the group.
105
+ # - Children are filtered by the same visibility rules as regular nav links.
106
+ # - A group is hidden if it has no visible children and no `path`.
107
+ def self.nav_group(
108
+ key:, label:, children:,
109
+ path: nil, icon: nil, section: nil, order: nil,
110
+ permission: nil, default_visible: true, default_open: true, **options
111
+ )
112
+ normalized_key = key.to_sym
113
+ normalized_section = section.presence || NAV_SECTION_MAIN
114
+ resolved_path = path.nil? ? nil : (path.kind_of?(Symbol) ? ->(v) { v.main_app.send(path) } : path)
115
+ resolved_icon = icon.nil? ? nil : RubyCms::Icons.resolve(icon)
116
+ normalized_children = Array(children).map(&:to_s).map(&:strip).reject(&:blank?).map(&:to_sym)
117
+
118
+ entry = {
119
+ type: :group,
120
+ key: normalized_key,
121
+ label: label.to_s,
122
+ path: resolved_path,
123
+ icon: resolved_icon,
124
+ section: normalized_section,
125
+ order: order,
126
+ permission: permission&.to_s,
127
+ default_visible: default_visible ? true : false,
128
+ default_open: default_open ? true : false,
129
+ children: normalized_children,
130
+ if: options[:if]
131
+ }
132
+
133
+ self.nav_registry = nav_registry.reject {|e| e[:key] == normalized_key }
134
+ self.nav_registry += [entry]
135
+
136
+ register_navigation_setting!(entry)
137
+
138
+ entry
139
+ end
140
+
97
141
  VALID_PAGE_SECTIONS = %i[main settings].freeze
98
142
 
99
143
  # Unified API to register an admin page: nav item + permission key in one call.
@@ -133,6 +177,45 @@ module RubyCms
133
177
  []
134
178
  end
135
179
 
180
+ # Sidebar rows for a given section. Includes groups (accordions) and top-level links.
181
+ # Returns an array of:
182
+ # - { type: :link, entry: <nav entry hash> }
183
+ # - { type: :group, group: <group entry hash>, children: [<nav entry hash>...] }
184
+ def self.visible_nav_sidebar_rows(section:, view_context: nil, user: nil)
185
+ visible = visible_nav_registry(view_context:, user:)
186
+ links = visible.select {|e| e[:type].to_s == "link" }
187
+ groups = visible.select {|e| e[:type].to_s == "group" }
188
+
189
+ links_by_key = links.index_by {|e| e[:key].to_sym }
190
+ nested_keys = groups.flat_map {|g| Array(g[:children]) }.map(&:to_sym).to_set
191
+
192
+ section_key = section.to_s
193
+ section_name = (section_key == "settings" ? NAV_SECTION_BOTTOM : NAV_SECTION_MAIN)
194
+
195
+ # groups first; children order uses their own order/label.
196
+ group_rows = groups
197
+ .select {|g| g[:section].to_s == section_name }
198
+ .sort_by {|g| nav_sort_tuple(g) }
199
+ .filter_map do |g|
200
+ child_entries = Array(g[:children]).map {|k| links_by_key[k.to_sym] }.compact
201
+ child_entries = child_entries.sort_by {|c| [c[:order] || 1000, c[:label].to_s] }
202
+ next if child_entries.empty? && g[:path].blank?
203
+ { type: :group, group: g, children: child_entries }
204
+ end
205
+
206
+ link_rows = links
207
+ .select {|e| e[:section].to_s == section_name }
208
+ .reject {|e| nested_keys.include?(e[:key].to_sym) }
209
+ .sort_by {|e| nav_sort_tuple(e) }
210
+ .map {|e| { type: :link, entry: e } }
211
+
212
+ merged = (group_rows.map {|r| [r, nav_sort_tuple(r[:group])] } +
213
+ link_rows.map {|r| [r, nav_sort_tuple(r[:entry])] })
214
+ .sort_by {|(_, tuple)| tuple }
215
+ .map(&:first)
216
+ merged
217
+ end
218
+
136
219
  module Nav
137
220
  def self.register(key:, label:, path:, icon: nil, section: nil, order: nil, permission: nil, default_visible: true, **)
138
221
  RubyCms.nav_register(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1.0
4
+ version: 0.2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Codebyjob
@@ -162,6 +162,7 @@ files:
162
162
  - app/views/layouts/ruby_cms/_admin_sidebar.html.erb
163
163
  - app/views/layouts/ruby_cms/admin.html.erb
164
164
  - app/views/layouts/ruby_cms/minimal.html.erb
165
+ - app/views/ruby_cms/_tailwind_safelist.html.erb
165
166
  - app/views/ruby_cms/admin/analytics/index.html.erb
166
167
  - app/views/ruby_cms/admin/analytics/page_details.html.erb
167
168
  - app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb