easy-admin-rails 0.1.11 → 0.1.12

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.
@@ -0,0 +1,206 @@
1
+ module EasyAdmin
2
+ module Versions
3
+ class PaginationComponent < BaseComponent
4
+ def initialize(pagy:, resource_class:, record:)
5
+ @pagy = pagy
6
+ @resource_class = resource_class
7
+ @record = record
8
+ end
9
+
10
+ def view_template
11
+ return unless @pagy.pages > 1
12
+
13
+ div(id: "versions-pagination", class: "mt-6 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6") do
14
+ # Mobile pagination info
15
+ div(class: "flex flex-1 justify-between sm:hidden") do
16
+ if @pagy.prev
17
+ link_to_previous_page
18
+ else
19
+ span(class: "relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500") do
20
+ "Previous"
21
+ end
22
+ end
23
+
24
+ if @pagy.next
25
+ link_to_next_page
26
+ else
27
+ span(class: "relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500") do
28
+ "Next"
29
+ end
30
+ end
31
+ end
32
+
33
+ # Desktop pagination
34
+ div(class: "hidden sm:flex sm:flex-1 sm:items-center sm:justify-between") do
35
+ # Results info
36
+ div do
37
+ p(class: "text-sm text-gray-700") do
38
+ plain "Showing "
39
+ span(class: "font-medium") { @pagy.from }
40
+ plain " to "
41
+ span(class: "font-medium") { @pagy.to }
42
+ plain " of "
43
+ span(class: "font-medium") { @pagy.count }
44
+ plain " versions"
45
+ end
46
+ end
47
+
48
+ # Page navigation
49
+ div do
50
+ nav(class: "isolate inline-flex -space-x-px rounded-md shadow-sm", "aria-label": "Pagination") do
51
+ # Previous page
52
+ if @pagy.prev
53
+ link_to_previous_page_desktop
54
+ else
55
+ span(class: "relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0") do
56
+ span(class: "sr-only") { "Previous" }
57
+ unsafe_raw <<~SVG
58
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
59
+ <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd"/>
60
+ </svg>
61
+ SVG
62
+ end
63
+ end
64
+
65
+ # Page numbers (show first, current range, and last)
66
+ render_page_numbers
67
+
68
+ # Next page
69
+ if @pagy.next
70
+ link_to_next_page_desktop
71
+ else
72
+ span(class: "relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0") do
73
+ span(class: "sr-only") { "Next" }
74
+ unsafe_raw <<~SVG
75
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
76
+ <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd"/>
77
+ </svg>
78
+ SVG
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def link_to_previous_page
90
+ a(
91
+ href: versions_url(page: @pagy.prev),
92
+ data: { turbo_frame: "versions-section" },
93
+ class: "relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
94
+ ) do
95
+ "Previous"
96
+ end
97
+ end
98
+
99
+ def link_to_next_page
100
+ a(
101
+ href: versions_url(page: @pagy.next),
102
+ data: { turbo_frame: "versions-section" },
103
+ class: "relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
104
+ ) do
105
+ "Next"
106
+ end
107
+ end
108
+
109
+ def link_to_previous_page_desktop
110
+ a(
111
+ href: versions_url(page: @pagy.prev),
112
+ data: { turbo_frame: "versions-section" },
113
+ class: "relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
114
+ ) do
115
+ span(class: "sr-only") { "Previous" }
116
+ unsafe_raw <<~SVG
117
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
118
+ <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd"/>
119
+ </svg>
120
+ SVG
121
+ end
122
+ end
123
+
124
+ def link_to_next_page_desktop
125
+ a(
126
+ href: versions_url(page: @pagy.next),
127
+ data: { turbo_frame: "versions-section" },
128
+ class: "relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
129
+ ) do
130
+ span(class: "sr-only") { "Next" }
131
+ unsafe_raw <<~SVG
132
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
133
+ <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd"/>
134
+ </svg>
135
+ SVG
136
+ end
137
+ end
138
+
139
+ def render_page_numbers
140
+ # Calculate range of pages to show
141
+ current = @pagy.page
142
+ total = @pagy.pages
143
+
144
+ # Show pages around current page
145
+ start_page = [current - 2, 1].max
146
+ end_page = [current + 2, total].min
147
+
148
+ # Always show page 1 if not in range
149
+ if start_page > 1
150
+ render_page_link(1)
151
+ if start_page > 2
152
+ span(class: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:outline-offset-0") do
153
+ "..."
154
+ end
155
+ end
156
+ end
157
+
158
+ # Show page range
159
+ (start_page..end_page).each do |page|
160
+ if page == current
161
+ render_current_page(page)
162
+ else
163
+ render_page_link(page)
164
+ end
165
+ end
166
+
167
+ # Always show last page if not in range
168
+ if end_page < total
169
+ if end_page < total - 1
170
+ span(class: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:outline-offset-0") do
171
+ "..."
172
+ end
173
+ end
174
+ render_page_link(total)
175
+ end
176
+ end
177
+
178
+ def render_current_page(page)
179
+ span(
180
+ "aria-current": "page",
181
+ class: "relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-semibold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
182
+ ) do
183
+ page.to_s
184
+ end
185
+ end
186
+
187
+ def render_page_link(page)
188
+ a(
189
+ href: versions_url(page: page),
190
+ data: { turbo_frame: "versions-section" },
191
+ class: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
192
+ ) do
193
+ page.to_s
194
+ end
195
+ end
196
+
197
+ def versions_url(page:)
198
+ easy_admin_url_helpers.resource_versions_path(
199
+ @resource_class.route_key,
200
+ @record.id,
201
+ page: page
202
+ )
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,412 @@
1
+ module EasyAdmin
2
+ module Versions
3
+ class TimelineComponent < BaseComponent
4
+ def initialize(versions:, turbo_frame_id: nil, resource_class: nil, record: nil)
5
+ @versions = versions
6
+ @turbo_frame_id = turbo_frame_id
7
+ @resource_class = resource_class
8
+ @record = record
9
+ end
10
+
11
+ def view_template
12
+ content = lambda do
13
+ div(class: "w-full") do
14
+ # Timeline container with explicit vertical layout
15
+ div(class: "relative w-full") do
16
+ # Timeline line - centered with icons (hidden on mobile)
17
+ div(class: "absolute left-10 top-0 bottom-0 w-0.5 bg-gray-300 hidden sm:block")
18
+
19
+ # Version items - force vertical stacking (newest first)
20
+ @versions.each_with_index do |version, index|
21
+ div(class: "relative w-full mb-6 sm:mb-8") do
22
+ render_timeline_item_content(version, index)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ if @turbo_frame_id
30
+ turbo_frame(id: @turbo_frame_id) do
31
+ content.call
32
+ end
33
+ else
34
+ content.call
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def render_timeline_item_content(version, index)
41
+ div(class: "flex items-start") do
42
+ # Timeline dot - matches admin panel styling (hidden on mobile)
43
+ div(class: "absolute left-8 w-3 h-3 #{event_bg_color(version.event)} rounded-full z-10 shadow-sm hidden sm:block") do
44
+ # Simple dot design to match admin panel
45
+ end
46
+
47
+ # Content card - responsive margin (no left margin on mobile)
48
+ div(class: "sm:ml-16 flex-1") do
49
+ div(class: "bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-200") do
50
+ render_item_header(version)
51
+ render_item_content(version)
52
+ render_item_actions(version)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def render_item_header(version)
59
+ # Match admin panel header style (like in BaseCardComponent) with mobile responsiveness
60
+ div(class: "px-4 sm:px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between") do
61
+ div(class: "flex items-center space-x-2 sm:space-x-3") do
62
+ # Event badge - using admin panel style
63
+ span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600") do
64
+ version.event.capitalize
65
+ end
66
+
67
+ # Time ago
68
+ span(class: "text-sm font-medium text-gray-900") do
69
+ time_ago_in_words(version.created_at) + " ago"
70
+ end
71
+ end
72
+
73
+ div(class: "flex items-center space-x-1 sm:space-x-2") do
74
+ # Exact timestamp - hide on mobile
75
+ span(class: "hidden sm:inline text-xs font-medium text-gray-500 uppercase tracking-wide") do
76
+ version.created_at.strftime("%m/%d %I:%M%p")
77
+ end
78
+
79
+ # User info - matching admin panel style
80
+ if version.whodunnit
81
+ span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800") do
82
+ version.whodunnit
83
+ end
84
+ else
85
+ span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600") do
86
+ "System"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ def render_item_content(version)
94
+ # Match admin panel body padding (like in BaseCardComponent) - responsive padding
95
+ div(class: "p-4 sm:p-6") do
96
+ if version.object.blank?
97
+ div(class: "text-sm text-gray-500") { "No change data available" }
98
+ return
99
+ end
100
+
101
+ begin
102
+ old_attrs = parse_version_object(version.object)
103
+ changes = extract_changes(version, old_attrs)
104
+
105
+ if changes.any?
106
+ div(class: "space-y-4") do
107
+ # Show first 3 changes with better styling
108
+ changes.first(3).each do |field, change|
109
+ render_field_change(field, change)
110
+ end
111
+
112
+ # Show count of remaining changes - using admin panel badge style
113
+ if changes.size > 3
114
+ div(class: "pt-4 border-t border-gray-100") do
115
+ span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600") do
116
+ "+#{changes.size - 3} more changes"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ else
122
+ div(class: "text-sm text-gray-500 flex items-center space-x-2") do
123
+ unsafe_raw '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
124
+ span { "No visible changes detected" }
125
+ end
126
+ end
127
+ rescue => e
128
+ div(class: "text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3 flex items-center space-x-2") do
129
+ unsafe_raw '<svg class="w-4 h-4 text-red-500 flex-shrink-0" 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"></path></svg>'
130
+ span { "Error parsing version data: #{e.message}" }
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ def render_field_change(field, change)
137
+ # Match admin panel card nested styling
138
+ div(class: "bg-gray-50 rounded-xl p-4 border border-gray-100") do
139
+ # Field name - using admin panel typography
140
+ div(class: "text-sm font-semibold text-gray-900 mb-3") do
141
+ span { field.humanize }
142
+ end
143
+
144
+ # Change display with arrow - responsive layout (horizontal on desktop, vertical on mobile)
145
+ div(class: "flex flex-col sm:flex-row sm:items-center space-y-3 sm:space-y-0 sm:space-x-4") do
146
+ if change.has_key?(:old)
147
+ div(class: "flex-1") do
148
+ div(class: "text-xs font-medium text-gray-500 uppercase tracking-wide mb-1") { "From" }
149
+ div(class: "text-sm text-gray-900 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm") do
150
+ format_value(change[:old])
151
+ end
152
+ end
153
+ end
154
+
155
+ # Arrow pointing from old to new - horizontal on desktop, vertical on mobile
156
+ if change.has_key?(:old) && change.has_key?(:new)
157
+ div(class: "flex-shrink-0 text-gray-400 flex justify-center sm:block") do
158
+ # Vertical arrow for mobile
159
+ unsafe_raw <<~SVG
160
+ <svg class="h-4 w-4 sm:hidden" viewBox="0 0 20 20" fill="currentColor">
161
+ <path fill-rule="evenodd" d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z" clip-rule="evenodd"/>
162
+ </svg>
163
+ SVG
164
+ # Horizontal arrow for desktop
165
+ unsafe_raw <<~SVG
166
+ <svg class="h-4 w-4 hidden sm:block" viewBox="0 0 20 20" fill="currentColor">
167
+ <path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd"/>
168
+ </svg>
169
+ SVG
170
+ end
171
+ end
172
+
173
+ if change.has_key?(:new)
174
+ div(class: "flex-1") do
175
+ div(class: "text-xs font-medium text-gray-500 uppercase tracking-wide mb-1") { "To" }
176
+ div(class: "text-sm text-gray-900 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm") do
177
+ format_value(change[:new])
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ def render_item_actions(version)
186
+ # Match admin panel footer style (like in BaseCardComponent)
187
+ div(class: "px-6 py-4 bg-gray-50/30 border-t border-gray-100 flex items-center justify-between") do
188
+ div(class: "flex items-center space-x-2") do
189
+ # Button style matching admin panel (similar to table header links)
190
+ a(
191
+ href: version_diff_url(version.id),
192
+ data: {
193
+ turbo_frame: "modal"
194
+ },
195
+ class: "group hover:text-gray-900 transition-colors duration-150 text-xs font-medium text-gray-500 uppercase tracking-wide"
196
+ ) do
197
+ span { "View Details" }
198
+ end
199
+
200
+ span(class: "text-gray-300") { "•" }
201
+
202
+ button(
203
+ class: "group hover:text-gray-900 transition-colors duration-150 text-xs font-medium text-gray-500 uppercase tracking-wide",
204
+ data: {
205
+ controller: "version-revert",
206
+ action: "click->version-revert#confirm",
207
+ version_revert_version_id_value: version.id,
208
+ version_revert_resource_id_value: @record.id,
209
+ version_revert_resource_name_value: @resource_class.route_key
210
+ }
211
+ ) do
212
+ span { "Revert" }
213
+ end
214
+ end
215
+
216
+ # Version ID - using admin panel monospace style
217
+ span(class: "text-xs font-medium text-gray-500 uppercase tracking-wide") do
218
+ "##{version.id}"
219
+ end
220
+ end
221
+ end
222
+
223
+ def event_color(event)
224
+ case event
225
+ when "create" then "border-green-500"
226
+ when "update" then "border-blue-500"
227
+ when "destroy" then "border-red-500"
228
+ else "border-gray-400"
229
+ end
230
+ end
231
+
232
+ def parse_version_object(object_yaml)
233
+ return {} if object_yaml.blank?
234
+
235
+ # Try safe loading first with common Rails classes
236
+ YAML.safe_load(
237
+ object_yaml,
238
+ permitted_classes: [Time, Date, DateTime, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Symbol],
239
+ aliases: true
240
+ )
241
+ rescue Psych::DisallowedClass, Psych::BadAlias
242
+ # Fallback: try unsafe loading for ActiveSupport objects
243
+ begin
244
+ YAML.unsafe_load(object_yaml)
245
+ rescue
246
+ # Last resort: try to extract basic info manually
247
+ extract_basic_attributes(object_yaml)
248
+ end
249
+ rescue => e
250
+ Rails.logger.warn "Failed to parse version object: #{e.message}"
251
+ {}
252
+ end
253
+
254
+ def extract_basic_attributes(yaml_string)
255
+ # Simple regex-based extraction for basic attributes
256
+ attributes = {}
257
+
258
+ yaml_string.scan(/^(\w+):\s*(.+)$/).each do |key, value|
259
+ # Skip complex objects
260
+ next if value.include?('!ruby/object')
261
+
262
+ # Clean up the value
263
+ cleaned_value = value.strip.gsub(/^["']|["']$/, '')
264
+ attributes[key] = cleaned_value unless cleaned_value.empty?
265
+ end
266
+
267
+ attributes
268
+ rescue
269
+ {}
270
+ end
271
+
272
+ def extract_changes(version, old_attrs)
273
+ return {} unless old_attrs.is_a?(Hash)
274
+
275
+ current_item = version.item
276
+ return {} unless current_item
277
+
278
+ changes = {}
279
+ current_attrs = current_item.attributes
280
+
281
+ # Compare old and new attributes
282
+ (old_attrs.keys + current_attrs.keys).uniq.each do |key|
283
+ next if skip_field?(key)
284
+
285
+ old_val = normalize_value_for_comparison(old_attrs[key])
286
+ new_val = normalize_value_for_comparison(current_attrs[key])
287
+
288
+ if old_val != new_val
289
+ changes[key] = { old: old_attrs[key], new: current_attrs[key] }
290
+ end
291
+ end
292
+
293
+ changes
294
+ end
295
+
296
+ def skip_field?(field)
297
+ %w[id updated_at].include?(field.to_s)
298
+ end
299
+
300
+ def normalize_value_for_comparison(value)
301
+ case value
302
+ when true, "true", "t", "1", 1
303
+ true
304
+ when false, "false", "f", "0", 0
305
+ false
306
+ when nil, ""
307
+ nil
308
+ else
309
+ value
310
+ end
311
+ end
312
+
313
+ def format_value(value)
314
+ case value
315
+ when Time, DateTime, ActiveSupport::TimeWithZone
316
+ value.strftime("%Y-%m-%d %H:%M")
317
+ when true
318
+ "Yes"
319
+ when false
320
+ "No"
321
+ when nil
322
+ "empty"
323
+ when String
324
+ # Try to parse as JSON for better formatting
325
+ if looks_like_json?(value)
326
+ format_json_preview(value)
327
+ else
328
+ value.to_s.truncate(30)
329
+ end
330
+ else
331
+ value.to_s.truncate(30)
332
+ end
333
+ end
334
+
335
+ private
336
+
337
+ def looks_like_json?(string)
338
+ return false if string.blank?
339
+
340
+ # Check for regular JSON
341
+ regular_json = string.strip.start_with?('{', '[') || string == '0' || string == 'null' || string =~ /^\d+$/
342
+
343
+ # Check for single-quoted JSON strings (like "{\"key\":\"value\"}")
344
+ quoted_json = string.strip.start_with?('"{') || string.strip.start_with?('"[')
345
+
346
+ result = regular_json || quoted_json
347
+ result
348
+ end
349
+
350
+ def format_json_preview(json_string)
351
+ return json_string if json_string.blank?
352
+
353
+ # Handle simple cases - including numeric strings that represent empty JSON
354
+ case json_string.strip
355
+ when '0', '1', '2', 'null', '""'
356
+ span(class: "text-gray-500 italic") { "empty" }
357
+ else
358
+ begin
359
+ # Try to handle quoted JSON strings
360
+ cleaned_string = json_string
361
+ if json_string.strip.start_with?('"{') || json_string.strip.start_with?('"[')
362
+ # Remove outer quotes and parse as JSON
363
+ cleaned_string = JSON.parse(json_string)
364
+ end
365
+
366
+ parsed = JSON.parse(cleaned_string)
367
+ if parsed.is_a?(Hash) && parsed.empty?
368
+ span(class: "text-gray-500 italic") { "empty object" }
369
+ elsif parsed.is_a?(Array) && parsed.empty?
370
+ span(class: "text-gray-500 italic") { "empty array" }
371
+ elsif parsed.is_a?(Hash)
372
+ # Show key count for objects
373
+ key_count = parsed.keys.size
374
+ span(class: "text-gray-700") { "#{key_count} #{'key'.pluralize(key_count)}" }
375
+ elsif parsed.is_a?(Array)
376
+ # Show item count for arrays
377
+ item_count = parsed.size
378
+ span(class: "text-gray-700") { "#{item_count} #{'item'.pluralize(item_count)}" }
379
+ else
380
+ # Simple value
381
+ parsed.to_s.truncate(30)
382
+ end
383
+ rescue JSON::ParserError
384
+ # Not valid JSON, truncate as string
385
+ json_string.truncate(30)
386
+ end
387
+ end
388
+ end
389
+
390
+ # Event colors matching admin panel's subtle approach
391
+ def event_bg_color(event)
392
+ case event
393
+ when "create" then "bg-blue-500"
394
+ when "update" then "bg-gray-400"
395
+ when "destroy" then "bg-red-500"
396
+ else "bg-gray-300"
397
+ end
398
+ end
399
+
400
+ def version_diff_url(version_id)
401
+ return '#' unless @resource_class && @record
402
+
403
+ easy_admin_url_helpers.resource_version_diff_path(
404
+ @resource_class.route_key,
405
+ @record.id,
406
+ version_id
407
+ )
408
+ end
409
+
410
+ end
411
+ end
412
+ end