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.
@@ -1,65 +1,313 @@
1
- <div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
2
- <div class="flex items-center gap-3 px-4 py-3 border-b border-border/40">
3
- <div class="rounded-md bg-emerald-50 p-1.5 flex-shrink-0">
4
- <svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
1
+ <%
2
+ chart_id = "rcms-hourly-#{SecureRandom.hex(4)}"
3
+ hourly_arr = @hourly_activity.is_a?(Hash) ? @hourly_activity.to_a : (@hourly_activity || [])
4
+ has_data = hourly_arr.present? && hourly_arr.any?
5
+
6
+ grouped = hourly_arr.each_slice(3).map do |group|
7
+ hours = group.map(&:first)
8
+ total = group.sum { |_, c| c.to_i }
9
+ [hours, total]
10
+ end
11
+
12
+ max_value = grouped.map { |_, c| c.to_i }.max.to_f
13
+ max_value = 1.0 unless max_value.positive? && max_value.finite?
14
+ chart_h = 180
15
+ bar_area_h = chart_h - 20
16
+
17
+ peak = grouped.max_by { |_, c| c }
18
+ peak_label = peak ? "#{peak[0].first}:00–#{peak[0].last}:59" : nil
19
+ peak_count = peak ? peak[1] : 0
20
+ n_slots = [grouped.size.to_f, 1.0].max
21
+ total_slot_views = grouped.sum { |_, c| c.to_i }
22
+ line_pts_hourly = grouped.each_with_index.map { |(hours, count), i|
23
+ vx = ((i + 0.5) / n_slots) * 100.0
24
+ vy = 100.0 - (max_value.positive? ? (count.to_f / max_value * 100.0) : 0.0)
25
+ "#{vx.round(4)},#{vy.round(4)}"
26
+ }.join(" ")
27
+ plot_scroll_min_w = [grouped.size * 32 + 56, 280].max
28
+ %>
29
+
30
+ <div class="min-w-0 rounded-xl border border-border/60 bg-white shadow-sm">
31
+
32
+ <%# ── Header ── %>
33
+ <div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-border/40">
34
+ <div class="flex items-center gap-3">
35
+ <div class="rounded-md bg-sky-50 p-1.5 flex-shrink-0">
36
+ <svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
37
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
38
+ </svg>
39
+ </div>
40
+ <div>
41
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly Activity") %></p>
42
+ <p class="text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour of day") %></p>
43
+ </div>
5
44
  </div>
6
- <div>
7
- <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly Activity") %></p>
8
- <p class="text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour of day") %></p>
45
+
46
+ <div class="flex items-center gap-2 sm:gap-3 flex-shrink-0 flex-wrap justify-end">
47
+ <% if has_data && peak_label %>
48
+ <div class="hidden sm:flex items-center gap-1.5 rounded-full bg-sky-50 border border-sky-100 px-2.5 py-1">
49
+ <svg class="w-3 h-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
51
+ </svg>
52
+ <span class="text-xs font-medium text-sky-800">Peak <%= peak_label %></span>
53
+ <span class="text-xs font-semibold tabular-nums text-blue-900"><%= number_with_delimiter(peak_count) %></span>
54
+ </div>
55
+ <% end %>
56
+ <% if has_data %>
57
+ <div class="rcms-chart-mode-toggle inline-flex rounded-lg border border-border/60 bg-muted/30 p-0.5 text-[11px] font-medium"
58
+ role="group"
59
+ data-rcms-chart="<%= chart_id %>"
60
+ aria-label="<%= t('ruby_cms.admin.analytics.chart_display', default: 'Chart display') %>">
61
+ <button type="button"
62
+ class="rcms-mode-btn px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground"
63
+ data-mode="bars"
64
+ aria-pressed="true">
65
+ <%= t("ruby_cms.admin.analytics.chart_bars", default: "Bars") %>
66
+ </button>
67
+ <button type="button"
68
+ class="rcms-mode-btn px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground"
69
+ data-mode="line"
70
+ aria-pressed="false">
71
+ <%= t("ruby_cms.admin.analytics.chart_line", default: "Line") %>
72
+ </button>
73
+ </div>
74
+ <% end %>
9
75
  </div>
10
76
  </div>
11
77
 
12
- <div class="px-4 py-4">
13
- <% if @hourly_activity.present? && @hourly_activity.any? %>
14
- <%
15
- hourly_arr = @hourly_activity.is_a?(Hash) ? @hourly_activity.to_a : @hourly_activity
16
- grouped = hourly_arr.each_slice(3).map do |group|
17
- hours = group.map(&:first)
18
- total = group.sum { |_, c| c.to_i }
19
- [hours, total]
20
- end
21
- max_value = grouped.map { |_, c| c.to_i }.max.to_f
22
- max_value = 1.0 unless max_value.positive? && max_value.finite?
23
- chart_h = 160
24
- %>
25
-
26
- <div class="grid grid-cols-[2.75rem_1fr] gap-2">
27
- <div class="flex flex-col justify-between text-xs text-muted-foreground tabular-nums" style="height:<%= chart_h %>px">
28
- <% [100, 75, 50, 25, 0].each do |pct| %>
29
- <div class="text-right"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
30
- <% end %>
78
+ <%# ── Chart body ── %>
79
+ <div class="min-w-0 px-4 py-4">
80
+ <% if has_data %>
81
+ <div id="<%= chart_id %>" class="relative min-w-0 select-none">
82
+
83
+ <%# Hover info card %>
84
+ <div id="<%= chart_id %>-tip"
85
+ role="tooltip"
86
+ class="pointer-events-none absolute z-[100] hidden min-w-[10rem] max-w-[16rem]">
31
87
  </div>
32
88
 
33
- <div class="relative">
34
- <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
35
- <% 5.times do %><div class="h-px bg-border/30"></div><% end %>
89
+ <div class="w-full min-w-0 overflow-x-auto overflow-y-visible overscroll-x-contain">
90
+ <div class="min-w-0" style="width:max(100%,<%= plot_scroll_min_w %>px)">
91
+ <div class="grid min-w-0 gap-2" style="grid-template-columns:2.75rem minmax(0,1fr);">
92
+
93
+ <%# Y-axis %>
94
+ <div class="flex flex-col justify-between text-right text-xs text-muted-foreground tabular-nums" style="height:<%= chart_h %>px; padding-bottom:20px;">
95
+ <% [100, 75, 50, 25, 0].each do |pct| %>
96
+ <div class="leading-none"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
97
+ <% end %>
36
98
  </div>
37
99
 
38
- <div class="flex items-end gap-1.5" style="height:<%= chart_h %>px">
39
- <% grouped.each do |hours, count| %>
40
- <% ratio = count.to_f / max_value %>
41
- <% ratio = 0.0 unless ratio.finite? %>
42
- <% h = if count.to_i.positive?
43
- [[(ratio * chart_h).round, 2].max, chart_h].min
44
- else
45
- 0
46
- end %>
47
- <div class="flex flex-col items-center gap-1.5 flex-1 min-w-0 group">
48
- <div class="w-full flex items-end justify-center" title="<%= hours.size > 1 ? "#{hours.first}:00–#{hours.last}:00" : "#{hours.first}:00" %> — <%= number_with_delimiter(count) %> views">
49
- <div
50
- class="w-full rounded-t transition-colors group-hover:opacity-90"
51
- style="height:<%= h %>px; max-width:12px; background-color:rgb(16 185 129 / 0.75);"
52
- ></div>
100
+ <%# Plot area %>
101
+ <div class="relative min-w-0" style="height:<%= chart_h %>px;">
102
+
103
+ <%# Grid lines %>
104
+ <div class="absolute inset-0 flex flex-col justify-between pointer-events-none" style="padding-bottom:20px;">
105
+ <% 5.times do %><div style="border-top:1px solid hsl(var(--border,214 32% 91%) / 0.3);"></div><% end %>
106
+ </div>
107
+
108
+ <%# Line chart %>
109
+ <div class="rcms-line-layer absolute left-0 right-0 pointer-events-none z-[1] hidden"
110
+ style="height:<%= bar_area_h %>px; top:0;"
111
+ aria-hidden="true">
112
+ <svg class="w-full h-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
113
+ <polyline class="rcms-line-hourly" fill="none" stroke="rgb(37 99 235)" stroke-width="1.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" points="<%= line_pts_hourly %>"/>
114
+ </svg>
115
+ </div>
116
+
117
+ <%# Bars %>
118
+ <div class="rcms-bars-layer flex items-end gap-1 relative z-[2]" style="height:<%= chart_h %>px;">
119
+ <% grouped.each do |hours, count| %>
120
+ <%
121
+ ratio = count.to_f / max_value
122
+ ratio = 0.0 unless ratio.finite?
123
+ h = count.to_i.positive? ? [[(ratio * bar_area_h).round, 2].max, bar_area_h].min : 0
124
+ range = hours.size > 1 ? "#{hours.first}:00 – #{hours.last}:59" : "#{hours.first}:00"
125
+ short = hours.size > 1 ? "#{hours.first}–#{hours.last}h" : "#{hours.first}h"
126
+ is_peak = peak && hours == peak[0]
127
+ pct_of_day = total_slot_views.positive? ? (100.0 * count.to_i / total_slot_views) : 0.0
128
+ %>
129
+ <div class="rcms-bar-group flex flex-col items-center flex-1 min-w-0 cursor-default"
130
+ data-label="<%= range %>"
131
+ data-views="<%= number_with_delimiter(count) %>"
132
+ data-pct-of-day="<%= sprintf('%.1f', pct_of_day) %>"
133
+ data-is-peak="<%= is_peak ? '1' : '0' %>"
134
+ data-max-scale="<%= number_with_delimiter(max_value.round) %>"
135
+ data-total-views="<%= number_with_delimiter(total_slot_views) %>">
136
+ <div class="w-full flex items-end justify-center rcms-bar-stack" style="height:<%= bar_area_h %>px;">
137
+ <div class="rcms-bar w-full rounded-t-sm"
138
+ style="height:<%= h %>px; max-width:14px; background:rgb(37 99 235 / <%= is_peak ? '1' : '0.82' %>); transition:opacity .12s,filter .12s;"></div>
139
+ </div>
140
+ <div class="text-muted-foreground truncate w-full text-center leading-none"
141
+ style="font-size:10px; height:20px; padding-top:5px;"><%= short %></div>
53
142
  </div>
54
- <div class="text-[10px] text-muted-foreground truncate w-full text-center"><%= hours.size > 1 ? "#{hours.first}–#{hours.last}h" : "#{hours.first}h" %></div>
55
- </div>
56
- <% end %>
143
+ <% end %>
144
+ </div>
145
+
146
+ </div>
147
+ </div>
57
148
  </div>
58
149
  </div>
59
150
  </div>
151
+
152
+ <script>
153
+ (function() {
154
+ var BLUE = "rgb(37 99 235)";
155
+ var MODE_KEY = "ruby_cms.analytics.hourlyChartMode";
156
+
157
+ function escapeHtml(s) {
158
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
159
+ }
160
+
161
+ function init() {
162
+ var chart = document.getElementById("<%= chart_id %>");
163
+ if (!chart || chart.__rcmsInit) return;
164
+ chart.__rcmsInit = true;
165
+
166
+ var tip = document.getElementById("<%= chart_id %>-tip");
167
+ var groups = chart.querySelectorAll(".rcms-bar-group");
168
+ var allBars = chart.querySelectorAll(".rcms-bar");
169
+ var lineLayer = chart.querySelector(".rcms-line-layer");
170
+ var barsLayer = chart.querySelector(".rcms-bars-layer");
171
+ var linePolys = lineLayer ? lineLayer.querySelectorAll("polyline") : [];
172
+ var toggles = document.querySelectorAll('.rcms-chart-mode-toggle[data-rcms-chart="<%= chart_id %>"] .rcms-mode-btn');
173
+
174
+ function setMode(mode) {
175
+ var isLine = mode === "line";
176
+ if (lineLayer) lineLayer.classList.toggle("hidden", !isLine);
177
+ if (barsLayer) {
178
+ barsLayer.querySelectorAll(".rcms-bar-stack").forEach(function(el) {
179
+ el.style.visibility = isLine ? "hidden" : "visible";
180
+ });
181
+ }
182
+ linePolys.forEach(function(p) {
183
+ p.style.opacity = isLine ? "1" : "0";
184
+ });
185
+ toggles.forEach(function(btn) {
186
+ var active = btn.getAttribute("data-mode") === mode;
187
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
188
+ btn.classList.toggle("bg-background", active);
189
+ btn.classList.toggle("text-foreground", active);
190
+ btn.classList.toggle("shadow-sm", active);
191
+ });
192
+ try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
193
+ }
194
+
195
+ try {
196
+ var saved = localStorage.getItem(MODE_KEY);
197
+ if (saved === "line" || saved === "bars") setMode(saved);
198
+ else setMode("bars");
199
+ } catch (e) {
200
+ setMode("bars");
201
+ }
202
+
203
+ toggles.forEach(function(btn) {
204
+ btn.addEventListener("click", function() {
205
+ setMode(btn.getAttribute("data-mode") || "bars");
206
+ });
207
+ });
208
+
209
+ groups.forEach(function(group) {
210
+ group.addEventListener("mouseenter", function() {
211
+ var isLine = lineLayer && !lineLayer.classList.contains("hidden");
212
+ if (!isLine) {
213
+ allBars.forEach(function(b) { b.style.opacity = "0.35"; b.style.filter = ""; });
214
+ group.querySelectorAll(".rcms-bar").forEach(function(b) {
215
+ b.style.opacity = "1";
216
+ b.style.filter = "brightness(1.08)";
217
+ });
218
+ } else {
219
+ linePolys.forEach(function(p) { p.style.opacity = "0.35"; });
220
+ }
221
+
222
+ var label = group.dataset.label || "";
223
+ var views = group.dataset.views || "0";
224
+ var pct = group.dataset.pctOfDay || "0";
225
+ var isPk = group.dataset.isPeak === "1";
226
+ var scale = group.dataset.maxScale || "";
227
+ var total = group.dataset.totalViews || "";
228
+ var modeLabel = isLine ? "Line chart" : "Bar chart";
229
+
230
+ tip.innerHTML =
231
+ '<div class="overflow-hidden rounded-xl border border-slate-200/90 bg-white text-xs text-slate-800 shadow-[0_10px_38px_-12px_rgba(15,23,42,0.28)]">' +
232
+ '<div class="border-b border-slate-100 bg-gradient-to-br from-sky-50 via-white to-white px-3 py-2">' +
233
+ '<p class="text-[13px] font-semibold leading-snug tracking-tight text-slate-900">' + escapeHtml(label) + "</p>" +
234
+ '<p class="mt-1 text-[10px] leading-relaxed text-slate-500">' +
235
+ "Scale 0–" + escapeHtml(scale) + " · " + modeLabel + " · Day total " + escapeHtml(total) + " views" +
236
+ "</p>" +
237
+ "</div>" +
238
+ '<div class="bg-white px-3 py-2.5">' +
239
+ '<div class="flex gap-2.5">' +
240
+ '<span class="mt-0.5 h-2 w-2 shrink-0 rounded-full shadow-sm ring-2 ring-white" style="background:' + BLUE + ';box-shadow:0 0 0 1px rgba(15,23,42,0.06)"></span>' +
241
+ '<div class="min-w-0 flex-1">' +
242
+ '<p class="text-[10px] font-semibold uppercase tracking-wider text-slate-400">This block</p>' +
243
+ '<p class="mt-0.5 text-sm font-semibold tabular-nums text-slate-900">' + escapeHtml(views) +
244
+ ' <span class="text-[11px] font-medium text-slate-400">(' + escapeHtml(pct) + "% of day)</span></p>" +
245
+ "</div>" +
246
+ "</div>" +
247
+ "</div>" +
248
+ (isPk
249
+ ? '<div class="border-t border-sky-100/80 bg-gradient-to-r from-sky-50/90 to-blue-50/50 px-3 py-1.5 text-center text-[10px] font-semibold text-sky-900">Peak interval for this period</div>'
250
+ : "") +
251
+ "</div>";
252
+
253
+ tip.classList.remove("hidden");
254
+ requestAnimationFrame(function() {
255
+ position(group, tip, chart);
256
+ });
257
+ });
258
+
259
+ group.addEventListener("mouseleave", function() {
260
+ allBars.forEach(function(b) { b.style.opacity = ""; b.style.filter = ""; });
261
+ var lineOn = lineLayer && !lineLayer.classList.contains("hidden");
262
+ linePolys.forEach(function(p) {
263
+ p.style.opacity = lineOn ? "1" : "0";
264
+ });
265
+ tip.classList.add("hidden");
266
+ });
267
+ });
268
+ }
269
+
270
+ function position(group, tip, container) {
271
+ var cr = container.getBoundingClientRect();
272
+ var gr = group.getBoundingClientRect();
273
+ var gap = 6;
274
+ var pad = 4;
275
+ var tw = tip.offsetWidth || 140;
276
+ var th = tip.offsetHeight || 64;
277
+ var cx = gr.left - cr.left + gr.width / 2;
278
+ var top = gr.top - cr.top - th - gap;
279
+
280
+ if (top < pad) {
281
+ top = gr.bottom - cr.top + gap;
282
+ }
283
+ var maxTop = container.clientHeight - th - pad;
284
+ if (top > maxTop) {
285
+ top = Math.max(pad, maxTop);
286
+ }
287
+
288
+ var left = Math.round(cx - tw / 2);
289
+ left = Math.max(pad, Math.min(left, container.clientWidth - tw - pad));
290
+
291
+ tip.style.position = "absolute";
292
+ tip.style.left = left + "px";
293
+ tip.style.top = Math.round(top) + "px";
294
+ }
295
+
296
+ if (document.readyState === "loading") {
297
+ document.addEventListener("DOMContentLoaded", init);
298
+ } else {
299
+ init();
300
+ }
301
+ document.addEventListener("turbo:load", init);
302
+ document.addEventListener("turbo:render", init);
303
+ })();
304
+ </script>
305
+
60
306
  <% else %>
61
307
  <div class="flex flex-col items-center justify-center h-44 gap-2">
62
- <svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
308
+ <svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
310
+ </svg>
63
311
  <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_hourly_data", default: "No hourly activity data") %></p>
64
312
  </div>
65
313
  <% end %>
@@ -1,53 +1,80 @@
1
- <div class="flex h-full flex-col rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="flex items-center justify-between gap-3 border-b border-gray-100 px-5 py-3">
3
- <div>
4
- <p class="text-sm font-semibold text-gray-900">Analytics</p>
5
- <p class="text-xs text-gray-500">Last 7 days.</p>
1
+ <%# Analytics same outer shell as sibling cards; softer interior (no box-in-box borders) %>
2
+ <div class="flex h-full min-w-0 flex-col overflow-hidden rounded-lg border border-gray-200/80 bg-white shadow-sm">
3
+ <%# Header — aligned with Quick actions / Recent errors %>
4
+ <div class="flex items-center justify-between gap-2 border-b border-gray-100 px-5 py-3">
5
+ <div class="flex min-w-0 items-center gap-3">
6
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sky-50 text-sky-600">
7
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
8
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
9
+ </svg>
10
+ </div>
11
+ <div class="min-w-0">
12
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.dashboard.analytics_title", default: "Analytics") %></p>
13
+ <p class="text-xs text-gray-500"><%= t("ruby_cms.admin.dashboard.analytics_subtitle", default: "Last 7 days") %></p>
14
+ </div>
6
15
  </div>
7
- <%= link_to "View all", ruby_cms_admin_analytics_path, class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
16
+ <%= link_to t("ruby_cms.admin.dashboard.analytics_view_all", default: "View all"),
17
+ ruby_cms_admin_analytics_path,
18
+ class: "shrink-0 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900" %>
8
19
  </div>
9
20
 
10
21
  <% if @dashboard_analytics_stats.present? %>
11
- <div class="grid grid-cols-2 gap-x-4 gap-y-3 px-5 py-4">
12
- <div>
13
- <p class="text-xs font-medium text-gray-500">Page views</p>
14
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:total_page_views].to_i) %></p>
15
- </div>
16
- <div>
17
- <p class="text-xs font-medium text-gray-500">Unique visitors</p>
18
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:unique_visitors].to_i) %></p>
19
- </div>
20
- <div>
21
- <p class="text-xs font-medium text-gray-500">Sessions</p>
22
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:total_sessions].to_i) %></p>
23
- </div>
24
- <div>
25
- <p class="text-xs font-medium text-gray-500">Avg / day</p>
26
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:avg_daily_views].to_i) %></p>
27
- </div>
22
+ <%# KPIs no inner ring/border (rings can render as a harsh outline); same padding rhythm as sibling cards %>
23
+ <div class="px-5 py-4">
24
+ <div class="grid grid-cols-2 gap-x-4 gap-y-5">
25
+ <%
26
+ kpi = [
27
+ { key: :total_page_views, label: t("ruby_cms.admin.analytics.total_page_views", default: "Page views"), icon: "eye", cls: "text-sky-600" },
28
+ { key: :unique_visitors, label: t("ruby_cms.admin.analytics.unique_visitors", default: "Unique visitors"), icon: "users", cls: "text-blue-600" },
29
+ { key: :total_sessions, label: t("ruby_cms.admin.analytics.total_sessions", default: "Sessions"), icon: "sessions", cls: "text-indigo-600" },
30
+ { key: :avg_daily_views, label: t("ruby_cms.admin.dashboard.avg_per_day", default: "Avg / day"), icon: "calendar", cls: "text-violet-600" }
31
+ ]
32
+ %>
33
+ <% kpi.each do |item| %>
34
+ <div class="min-w-0 pl-1">
35
+ <div class="flex items-center gap-2 text-gray-500">
36
+ <% case item[:icon]
37
+ when "eye" %>
38
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
39
+ <% when "users" %>
40
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
41
+ <% when "sessions" %>
42
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
43
+ <% else %>
44
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
45
+ <% end %>
46
+ <span class="text-[11px] font-medium leading-tight text-gray-600"><%= item[:label] %></span>
47
+ </div>
48
+ <p class="mt-1.5 text-xl font-semibold tabular-nums tracking-tight text-gray-900"><%= number_with_delimiter(@dashboard_analytics_stats[item[:key]].to_i) %></p>
49
+ </div>
50
+ <% end %>
51
+ </div>
28
52
  </div>
29
53
 
30
54
  <% top = @dashboard_analytics_stats[:popular_pages] %>
31
55
  <% if top.present? %>
32
- <div class="flex-1 border-t border-gray-100">
33
- <p class="px-5 pt-3 text-xs text-gray-500">Popular pages</p>
34
- <div class="divide-y divide-gray-100">
35
- <% top.to_a.first(4).each do |page_name, count| %>
36
- <div class="flex items-center justify-between gap-2 px-5 py-2">
37
- <span class="min-w-0 truncate text-sm font-medium text-gray-900" title="<%= page_name %>"><%= page_name.presence || "(empty)" %></span>
38
- <span class="shrink-0 text-sm tabular-nums text-gray-500"><%= number_with_delimiter(count.to_i) %></span>
39
- </div>
56
+ <div class="flex-1 border-t border-gray-100 px-5 pb-4 pt-3">
57
+ <p class="mb-2 text-xs font-medium text-gray-500"><%= t("ruby_cms.admin.dashboard.popular_pages", default: "Popular pages") %></p>
58
+ <ul class="space-y-0.5">
59
+ <% top.to_a.first(4).each_with_index do |(page_name, count), i| %>
60
+ <li class="group flex items-center justify-between gap-2 rounded-lg py-2 pl-2 pr-1 transition-colors hover:bg-gray-50">
61
+ <div class="flex min-w-0 items-baseline gap-2">
62
+ <span class="w-4 shrink-0 text-right text-[11px] font-medium tabular-nums text-gray-400"><%= i + 1 %>.</span>
63
+ <span class="truncate text-sm text-gray-900" title="<%= page_name %>"><%= page_name.presence || "(empty)" %></span>
64
+ </div>
65
+ <span class="shrink-0 text-sm font-medium tabular-nums text-gray-500 group-hover:text-gray-700"><%= number_with_delimiter(count.to_i) %></span>
66
+ </li>
40
67
  <% end %>
41
- </div>
68
+ </ul>
42
69
  </div>
43
70
  <% end %>
44
71
  <% else %>
45
- <div class="flex flex-1 flex-col items-center justify-center px-5 py-8 text-center">
46
- <div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-200">
72
+ <div class="flex flex-1 flex-col items-center justify-center px-5 py-10 text-center">
73
+ <div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-500">
47
74
  <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
48
75
  </div>
49
- <p class="text-sm font-medium text-gray-900">Analytics unavailable</p>
50
- <p class="mt-1 text-xs text-gray-500">Could not load data.</p>
76
+ <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.dashboard.analytics_unavailable", default: "Analytics unavailable") %></p>
77
+ <p class="mt-1 text-xs text-gray-500"><%= t("ruby_cms.admin.dashboard.analytics_unavailable_hint", default: "Could not load data.") %></p>
51
78
  </div>
52
79
  <% end %>
53
80
  </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,20 +3,11 @@
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
- <%# Stats compact, all four on one row from md upward %>
9
- <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
10
- <% @stats_blocks.each do |block| %>
11
- <div class="flex flex-col">
12
- <%= render_dashboard_block(block) %>
13
- </div>
14
- <% end %>
15
- </div>
16
-
17
- <%# Built-in row: quick actions | recent errors | analytics — always equal-width columns %>
8
+ <%# Main row: three equal columns quick actions | recent errors | analytics %>
18
9
  <% if @primary_main_blocks.any? %>
19
- <div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-3 my-4">
10
+ <div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-3">
20
11
  <% @primary_main_blocks.each do |block| %>
21
12
  <div class="flex min-w-0 flex-col">
22
13
  <%= render_dashboard_block(block) %>
@@ -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