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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +20 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +11 -1
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +2 -3
- data/app/helpers/ruby_cms/application_helper.rb +8 -0
- data/app/javascript/controllers/ruby_cms/index.js +0 -1
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +171 -53
- data/app/views/ruby_cms/_tailwind_safelist.html.erb +2 -0
- data/app/views/ruby_cms/admin/analytics/index.html.erb +8 -8
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +306 -51
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +296 -48
- data/app/views/ruby_cms/admin/dashboard/blocks/_analytics_overview.html.erb +63 -36
- data/app/views/ruby_cms/admin/dashboard/blocks/_quick_actions.html.erb +2 -2
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +3 -12
- data/lib/generators/ruby_cms/install_generator.rb +83 -4
- data/lib/ruby_cms/engine/navigation_registration.rb +17 -16
- data/lib/ruby_cms/version.rb +1 -1
- data/lib/ruby_cms.rb +83 -0
- metadata +2 -1
|
@@ -1,65 +1,313 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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="
|
|
34
|
-
<div class="
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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, "&").replace(/</g, "<").replace(/"/g, """);
|
|
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"
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 "
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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="
|
|
34
|
-
<
|
|
35
|
-
<% top.to_a.first(4).
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
</
|
|
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-
|
|
46
|
-
<div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-
|
|
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"
|
|
50
|
-
<p class="mt-1 text-xs text-gray-500"
|
|
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-
|
|
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-
|
|
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-
|
|
6
|
+
<div class="space-y-8 pb-8">
|
|
7
7
|
|
|
8
|
-
<%#
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|