admin_suite 0.1.0
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 +7 -0
- data/.gitignore +10 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +7 -0
- data/Rakefile +11 -0
- data/app/assets/admin_suite.css +444 -0
- data/app/assets/admin_suite_tailwind.css +8 -0
- data/app/assets/builds/admin_suite_tailwind.css +8 -0
- data/app/assets/rouge.css +218 -0
- data/app/assets/tailwind/admin_suite.css +22 -0
- data/app/controllers/admin_suite/application_controller.rb +118 -0
- data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
- data/app/controllers/admin_suite/docs_controller.rb +155 -0
- data/app/controllers/admin_suite/portals_controller.rb +22 -0
- data/app/controllers/admin_suite/resources_controller.rb +238 -0
- data/app/helpers/admin_suite/base_helper.rb +1199 -0
- data/app/helpers/admin_suite/icon_helper.rb +61 -0
- data/app/helpers/admin_suite/panels_helper.rb +52 -0
- data/app/helpers/admin_suite/resources_helper.rb +15 -0
- data/app/helpers/admin_suite/theme_helper.rb +99 -0
- data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
- data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
- data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
- data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
- data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
- data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
- data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
- data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
- data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
- data/app/views/admin_suite/dashboard/index.html.erb +21 -0
- data/app/views/admin_suite/docs/index.html.erb +86 -0
- data/app/views/admin_suite/panels/_cards.html.erb +107 -0
- data/app/views/admin_suite/panels/_chart.html.erb +47 -0
- data/app/views/admin_suite/panels/_health.html.erb +44 -0
- data/app/views/admin_suite/panels/_recent.html.erb +56 -0
- data/app/views/admin_suite/panels/_stat.html.erb +64 -0
- data/app/views/admin_suite/panels/_table.html.erb +36 -0
- data/app/views/admin_suite/portals/show.html.erb +75 -0
- data/app/views/admin_suite/resources/_form.html.erb +32 -0
- data/app/views/admin_suite/resources/edit.html.erb +24 -0
- data/app/views/admin_suite/resources/index.html.erb +315 -0
- data/app/views/admin_suite/resources/new.html.erb +22 -0
- data/app/views/admin_suite/resources/show.html.erb +184 -0
- data/app/views/admin_suite/shared/_flash.html.erb +30 -0
- data/app/views/admin_suite/shared/_form.html.erb +60 -0
- data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
- data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
- data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
- data/app/views/layouts/admin_suite/application.html.erb +79 -0
- data/lib/admin/base/action_executor.rb +155 -0
- data/lib/admin/base/action_handler.rb +31 -0
- data/lib/admin/base/filter_builder.rb +121 -0
- data/lib/admin/base/resource.rb +541 -0
- data/lib/admin_suite/configuration.rb +42 -0
- data/lib/admin_suite/engine.rb +101 -0
- data/lib/admin_suite/markdown_renderer.rb +115 -0
- data/lib/admin_suite/portal_definition.rb +64 -0
- data/lib/admin_suite/portal_registry.rb +32 -0
- data/lib/admin_suite/theme_palette.rb +36 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
- data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
- data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
- data/lib/admin_suite/version.rb +10 -0
- data/lib/admin_suite.rb +54 -0
- data/lib/generators/admin_suite/install/install_generator.rb +23 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
- data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
- data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
- data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
- data/lib/tasks/admin_suite_tailwind.rake +28 -0
- data/lib/tasks/admin_suite_test.rake +11 -0
- data/test/dummy/Gemfile +21 -0
- data/test/dummy/README.md +24 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/application_record.rb +2 -0
- data/test/dummy/app/views/layouts/application.html.erb +28 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/ci +6 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +35 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/ci.rb +19 -0
- data/test/dummy/config/database.yml +31 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +57 -0
- data/test/dummy/config/environments/production.rb +67 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +7 -0
- data/test/dummy/config/initializers/content_security_policy.rb +29 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +39 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config.ru +6 -0
- data/test/dummy/db/seeds.rb +9 -0
- data/test/dummy/log/test.log +441 -0
- data/test/dummy/public/400.html +135 -0
- data/test/dummy/public/404.html +135 -0
- data/test/dummy/public/406-unsupported-browser.html +135 -0
- data/test/dummy/public/422.html +135 -0
- data/test/dummy/public/500.html +135 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/public/robots.txt +1 -0
- data/test/dummy/test/test_helper.rb +15 -0
- data/test/dummy/tmp/local_secret.txt +1 -0
- data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
- data/test/integration/dashboard_test.rb +13 -0
- data/test/integration/docs_test.rb +46 -0
- data/test/integration/theme_test.rb +27 -0
- data/test/lib/markdown_renderer_test.rb +20 -0
- data/test/lib/theme_palette_test.rb +24 -0
- data/test/test_helper.rb +11 -0
- metadata +264 -0
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
# Helper methods for the Admin Suite engine UI.
|
|
5
|
+
#
|
|
6
|
+
# This is intentionally very close to the `/internal/developer` helper so we can
|
|
7
|
+
# keep both UIs side-by-side and compare behavior while migrating.
|
|
8
|
+
module BaseHelper
|
|
9
|
+
include Pagy::Frontend
|
|
10
|
+
include AdminSuite::IconHelper
|
|
11
|
+
include AdminSuite::PanelsHelper
|
|
12
|
+
include AdminSuite::ThemeHelper
|
|
13
|
+
include ::Internal::Developer::CustomRenderersHelper if defined?(::Internal::Developer::CustomRenderersHelper)
|
|
14
|
+
# ActiveStorage route helpers live on the host app (main_app), not the isolated engine.
|
|
15
|
+
def admin_suite_rails_blob_path(...)
|
|
16
|
+
if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_path)
|
|
17
|
+
main_app.rails_blob_path(...)
|
|
18
|
+
else
|
|
19
|
+
rails_blob_path(...)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def admin_suite_rails_blob_representation_path(...)
|
|
24
|
+
if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_representation_path)
|
|
25
|
+
main_app.rails_blob_representation_path(...)
|
|
26
|
+
else
|
|
27
|
+
rails_blob_representation_path(...)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Lookup the DSL field definition for a given attribute (if present).
|
|
32
|
+
#
|
|
33
|
+
# Used to render show values with type awareness (e.g. markdown/json/label).
|
|
34
|
+
def admin_suite_field_definition(field_name)
|
|
35
|
+
return nil unless respond_to?(:resource_config, true)
|
|
36
|
+
|
|
37
|
+
rc = resource_config
|
|
38
|
+
return nil unless rc
|
|
39
|
+
|
|
40
|
+
rc.form_config&.fields_list.to_a.find do |f|
|
|
41
|
+
f.respond_to?(:name) &&
|
|
42
|
+
f.respond_to?(:type) &&
|
|
43
|
+
f.name.to_sym == field_name.to_sym
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Prefer registry-driven implementations (with legacy fallbacks via `super`).
|
|
51
|
+
prepend AdminSuite::UI::ShowValueFormatter
|
|
52
|
+
prepend AdminSuite::UI::FormFieldRenderer
|
|
53
|
+
|
|
54
|
+
# Returns the color scheme for a portal
|
|
55
|
+
#
|
|
56
|
+
# @param portal_key [Symbol] Portal identifier
|
|
57
|
+
# @return [String]
|
|
58
|
+
def portal_color(portal_key)
|
|
59
|
+
portal_key = portal_key.to_sym
|
|
60
|
+
color = (navigation_items.dig(portal_key, :color) rescue nil)
|
|
61
|
+
return color.to_s if color.present?
|
|
62
|
+
|
|
63
|
+
case portal_key
|
|
64
|
+
when :ops then "amber"
|
|
65
|
+
when :ai then "cyan"
|
|
66
|
+
when :assistant then "violet"
|
|
67
|
+
when :email then "emerald"
|
|
68
|
+
else "slate"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns an icon for a portal.
|
|
73
|
+
#
|
|
74
|
+
# @param portal_key [Symbol] Portal identifier
|
|
75
|
+
# @return [ActiveSupport::SafeBuffer, String]
|
|
76
|
+
def portal_icon(portal_key, **opts)
|
|
77
|
+
portal_key = portal_key.to_sym
|
|
78
|
+
icon = (navigation_items.dig(portal_key, :icon) rescue nil)
|
|
79
|
+
icon ||= begin
|
|
80
|
+
{
|
|
81
|
+
ops: "settings",
|
|
82
|
+
ai: "sparkles",
|
|
83
|
+
assistant: "bot",
|
|
84
|
+
email: "mail"
|
|
85
|
+
}[portal_key]
|
|
86
|
+
end
|
|
87
|
+
icon = icon.presence || "layout-grid"
|
|
88
|
+
|
|
89
|
+
admin_suite_icon(icon, **opts)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Renders a column value from a record
|
|
93
|
+
#
|
|
94
|
+
# @param record [ActiveRecord::Base] The record
|
|
95
|
+
# @param column [Admin::Base::Resource::ColumnDefinition] Column definition
|
|
96
|
+
# @return [String]
|
|
97
|
+
def render_column_value(record, column)
|
|
98
|
+
if column.type == :toggle
|
|
99
|
+
field = (column.toggle_field || column.name).to_sym
|
|
100
|
+
render partial: "admin_suite/shared/toggle_cell",
|
|
101
|
+
locals: { record: record, field: field }
|
|
102
|
+
elsif column.type == :label
|
|
103
|
+
value = column.content.is_a?(Proc) ? column.content.call(record) : (record.public_send(column.name) rescue nil)
|
|
104
|
+
render_label_badge(value, color: column.label_color, size: column.label_size, record: record)
|
|
105
|
+
elsif column.content.is_a?(Proc)
|
|
106
|
+
column.content.call(record)
|
|
107
|
+
else
|
|
108
|
+
record.public_send(column.name) rescue "—"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Formats a value for display on show pages
|
|
113
|
+
#
|
|
114
|
+
# @param record [ActiveRecord::Base] The record
|
|
115
|
+
# @param field_name [Symbol, String] Field name
|
|
116
|
+
# @return [String] HTML safe formatted value
|
|
117
|
+
def format_show_value(record, field_name)
|
|
118
|
+
value = record.public_send(field_name) rescue nil
|
|
119
|
+
|
|
120
|
+
if value.is_a?(ActiveStorage::Attached::One)
|
|
121
|
+
return render_attachment_preview(value)
|
|
122
|
+
elsif value.is_a?(ActiveStorage::Attached::Many)
|
|
123
|
+
return render_attachments_preview(value)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
case value
|
|
127
|
+
when nil
|
|
128
|
+
content_tag(:span, "—", class: "text-slate-400")
|
|
129
|
+
when true
|
|
130
|
+
content_tag(:span, class: "inline-flex items-center gap-1") do
|
|
131
|
+
svg = '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'.html_safe
|
|
132
|
+
concat(svg)
|
|
133
|
+
concat(content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
|
|
134
|
+
end
|
|
135
|
+
when false
|
|
136
|
+
content_tag(:span, class: "inline-flex items-center gap-1") do
|
|
137
|
+
svg = '<svg class="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'.html_safe
|
|
138
|
+
concat(svg)
|
|
139
|
+
concat(content_tag(:span, "No", class: "text-slate-500"))
|
|
140
|
+
end
|
|
141
|
+
when Time, DateTime
|
|
142
|
+
content_tag(:span, class: "inline-flex items-center gap-2") do
|
|
143
|
+
concat(content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
|
|
144
|
+
concat(content_tag(:span, "(#{time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
|
|
145
|
+
end
|
|
146
|
+
when Date
|
|
147
|
+
value.strftime("%B %d, %Y")
|
|
148
|
+
when ActiveRecord::Base
|
|
149
|
+
link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
|
|
150
|
+
content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
|
|
151
|
+
when Hash
|
|
152
|
+
render_json_block(value)
|
|
153
|
+
when Array
|
|
154
|
+
if value.empty?
|
|
155
|
+
content_tag(:span, "Empty array", class: "text-slate-400 italic")
|
|
156
|
+
elsif value.first.is_a?(Hash)
|
|
157
|
+
render_json_block(value)
|
|
158
|
+
else
|
|
159
|
+
content_tag(:div, class: "flex flex-wrap gap-1") do
|
|
160
|
+
value.each do |item|
|
|
161
|
+
concat(content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
when Integer, Float, BigDecimal
|
|
166
|
+
content_tag(:span, number_with_delimiter(value), class: "font-mono")
|
|
167
|
+
else
|
|
168
|
+
value_str = value.to_s
|
|
169
|
+
|
|
170
|
+
if value_str.start_with?("{", "[") && value_str.length > 10
|
|
171
|
+
begin
|
|
172
|
+
parsed = JSON.parse(value_str)
|
|
173
|
+
render_json_block(parsed)
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
render_text_block(value_str)
|
|
176
|
+
end
|
|
177
|
+
elsif value_str.include?("\n") || value_str.length > 200
|
|
178
|
+
render_text_block(value_str, detect_language(field_name, value_str))
|
|
179
|
+
else
|
|
180
|
+
value_str
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def render_attachment_preview(attachment)
|
|
186
|
+
return content_tag(:span, "—", class: "text-slate-400") unless attachment.attached?
|
|
187
|
+
|
|
188
|
+
blob = attachment.blob
|
|
189
|
+
|
|
190
|
+
if blob.image?
|
|
191
|
+
variant = attachment.variant(resize_to_limit: [ 600, 400 ])
|
|
192
|
+
variant_url =
|
|
193
|
+
begin
|
|
194
|
+
admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
|
|
195
|
+
rescue StandardError
|
|
196
|
+
admin_suite_rails_blob_path(blob, disposition: :inline)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
content_tag(:div, class: "space-y-2") do
|
|
200
|
+
concat(content_tag(:div, class: "inline-block rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
|
|
201
|
+
image_tag(variant_url,
|
|
202
|
+
class: "max-w-full h-auto max-h-64 object-contain",
|
|
203
|
+
alt: blob.filename.to_s)
|
|
204
|
+
end)
|
|
205
|
+
concat(content_tag(:div, class: "flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400") do
|
|
206
|
+
concat(content_tag(:span, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300"))
|
|
207
|
+
concat(content_tag(:span, "•"))
|
|
208
|
+
concat(content_tag(:span, number_to_human_size(blob.byte_size)))
|
|
209
|
+
concat(content_tag(:span, "•"))
|
|
210
|
+
concat(link_to("View full size", admin_suite_rails_blob_path(blob, disposition: :inline), target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
|
|
211
|
+
end)
|
|
212
|
+
end
|
|
213
|
+
else
|
|
214
|
+
content_tag(:div, class: "flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700") do
|
|
215
|
+
concat(content_tag(:div, class: "flex-shrink-0 w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg flex items-center justify-center") do
|
|
216
|
+
'<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
|
|
217
|
+
end)
|
|
218
|
+
concat(content_tag(:div, class: "flex-1 min-w-0") do
|
|
219
|
+
concat(content_tag(:p, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300 truncate"))
|
|
220
|
+
concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-sm text-slate-500 dark:text-slate-400"))
|
|
221
|
+
end)
|
|
222
|
+
concat(link_to("Download", admin_suite_rails_blob_path(blob, disposition: :attachment),
|
|
223
|
+
class: "flex-shrink-0 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"))
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render_attachments_preview(attachments)
|
|
229
|
+
return content_tag(:span, "—", class: "text-slate-400") unless attachments.attached?
|
|
230
|
+
|
|
231
|
+
content_tag(:div, class: "grid grid-cols-2 md:grid-cols-3 gap-4") do
|
|
232
|
+
attachments.each do |attachment|
|
|
233
|
+
concat(render_attachment_preview(attachment))
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def render_json_block(data)
|
|
239
|
+
json_str = JSON.pretty_generate(data)
|
|
240
|
+
|
|
241
|
+
content_tag(:div, class: "relative group") do
|
|
242
|
+
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
|
|
243
|
+
concat(content_tag(:span, "JSON", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
|
|
244
|
+
concat(content_tag(:button,
|
|
245
|
+
'<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
|
|
246
|
+
type: "button",
|
|
247
|
+
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
248
|
+
data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": json_str },
|
|
249
|
+
title: "Copy to clipboard"))
|
|
250
|
+
end)
|
|
251
|
+
|
|
252
|
+
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto") do
|
|
253
|
+
content_tag(:code, class: "language-json") do
|
|
254
|
+
highlight_json(json_str)
|
|
255
|
+
end
|
|
256
|
+
end)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_text_block(text, language = nil)
|
|
261
|
+
content_tag(:div, class: "relative group") do
|
|
262
|
+
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
|
|
263
|
+
concat(content_tag(:span, language.to_s.upcase, class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider")) if language
|
|
264
|
+
concat(content_tag(:button,
|
|
265
|
+
'<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
|
|
266
|
+
type: "button",
|
|
267
|
+
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
268
|
+
data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": text },
|
|
269
|
+
title: "Copy to clipboard"))
|
|
270
|
+
end)
|
|
271
|
+
|
|
272
|
+
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto whitespace-pre-wrap") do
|
|
273
|
+
content_tag(:code, h(text), class: language ? "language-#{language}" : nil)
|
|
274
|
+
end)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def highlight_json(json_str)
|
|
279
|
+
highlighted = h(json_str)
|
|
280
|
+
.gsub(/("(?:[^"\\]|\\.)*")(\s*:)/) { "<span class=\"text-purple-400\">#{$1}</span>#{$2}" }
|
|
281
|
+
.gsub(/:\s*("(?:[^"\\]|\\.)*")/) { ":<span class=\"text-green-400\">#{$1}</span>" }
|
|
282
|
+
.gsub(/:\s*(true|false)/) { ":<span class=\"text-orange-400\">#{$1}</span>" }
|
|
283
|
+
.gsub(/:\s*(-?\d+(?:\.\d+)?)/) { ":<span class=\"text-cyan-400\">#{$1}</span>" }
|
|
284
|
+
.gsub(/:\s*(null)/) { ":<span class=\"text-red-400\">#{$1}</span>" }
|
|
285
|
+
|
|
286
|
+
highlighted.html_safe
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def detect_language(field_name, content)
|
|
290
|
+
field_str = field_name.to_s.downcase
|
|
291
|
+
|
|
292
|
+
return :markdown if field_str.include?("template") || field_str.include?("prompt")
|
|
293
|
+
return :ruby if field_str.include?("code") && content.include?("def ")
|
|
294
|
+
return :sql if field_str.include?("query") || field_str.include?("sql")
|
|
295
|
+
return :html if field_str.include?("html") || field_str.include?("body")
|
|
296
|
+
|
|
297
|
+
return :json if content.strip.start_with?("{", "[")
|
|
298
|
+
return :ruby if content.include?("def ") || content.include?("class ")
|
|
299
|
+
return :sql if content.upcase.include?("SELECT ") || content.upcase.include?("INSERT ")
|
|
300
|
+
return :html if content.include?("<html") || content.include?("<div")
|
|
301
|
+
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def render_custom_section(resource, render_type)
|
|
306
|
+
renderer = AdminSuite.config.custom_renderers[render_type.to_sym] rescue nil
|
|
307
|
+
return renderer.call(resource, self) if renderer
|
|
308
|
+
|
|
309
|
+
case render_type.to_sym
|
|
310
|
+
when :prompt_template_preview
|
|
311
|
+
render_prompt_template(resource)
|
|
312
|
+
when :json_preview
|
|
313
|
+
render_json_preview(resource)
|
|
314
|
+
when :code_preview
|
|
315
|
+
render_code_preview(resource)
|
|
316
|
+
when :messages_preview
|
|
317
|
+
render_messages_preview(resource)
|
|
318
|
+
when :tool_args_preview
|
|
319
|
+
render_tool_args_preview(resource)
|
|
320
|
+
when :turn_messages_preview
|
|
321
|
+
render_turn_messages_preview(resource)
|
|
322
|
+
else
|
|
323
|
+
content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# --- generic custom renderers (fallbacks) ---
|
|
328
|
+
def render_prompt_template(resource)
|
|
329
|
+
template = resource.respond_to?(:prompt_template) ? resource.prompt_template : nil
|
|
330
|
+
return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
|
|
331
|
+
|
|
332
|
+
highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
|
|
333
|
+
"<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
content_tag(:div, class: "relative group") do
|
|
337
|
+
concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
|
|
338
|
+
concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
|
|
339
|
+
concat(content_tag(:button,
|
|
340
|
+
'<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
|
|
341
|
+
type: "button",
|
|
342
|
+
class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
343
|
+
data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": template },
|
|
344
|
+
title: "Copy to clipboard"))
|
|
345
|
+
end)
|
|
346
|
+
|
|
347
|
+
concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-[600px] overflow-y-auto whitespace-pre-wrap leading-relaxed") do
|
|
348
|
+
highlighted_template.html_safe
|
|
349
|
+
end)
|
|
350
|
+
|
|
351
|
+
variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
|
|
352
|
+
if variables.any?
|
|
353
|
+
concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
|
|
354
|
+
concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
|
|
355
|
+
concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
|
|
356
|
+
variables.each do |var|
|
|
357
|
+
concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
|
|
358
|
+
end
|
|
359
|
+
end)
|
|
360
|
+
end)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def render_json_preview(resource)
|
|
366
|
+
data = resource.respond_to?(:data) ? resource.data : resource.attributes
|
|
367
|
+
render_json_block(data)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def render_code_preview(resource)
|
|
371
|
+
code = resource.respond_to?(:code) ? resource.code : resource.to_s
|
|
372
|
+
render_text_block(code, :ruby)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def render_messages_preview(resource)
|
|
376
|
+
messages = resource.respond_to?(:messages) ? resource.messages : []
|
|
377
|
+
if messages.respond_to?(:chronological)
|
|
378
|
+
messages = messages.chronological
|
|
379
|
+
end
|
|
380
|
+
messages = messages.limit(50) if messages.respond_to?(:limit)
|
|
381
|
+
messages = Array.wrap(messages)
|
|
382
|
+
|
|
383
|
+
return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
|
|
384
|
+
|
|
385
|
+
content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
|
|
386
|
+
messages.each_with_index do |msg, idx|
|
|
387
|
+
if msg.respond_to?(:role)
|
|
388
|
+
role = msg.role
|
|
389
|
+
content = msg.content
|
|
390
|
+
created_at = msg.respond_to?(:created_at) ? msg.created_at : nil
|
|
391
|
+
else
|
|
392
|
+
role = msg["role"] || msg[:role] || "unknown"
|
|
393
|
+
content = msg["content"] || msg[:content] || ""
|
|
394
|
+
created_at = msg["created_at"] || msg[:created_at]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
role_class = case role.to_s
|
|
398
|
+
when "user" then "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
|
|
399
|
+
when "assistant" then "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
|
|
400
|
+
when "tool" then "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
|
|
401
|
+
when "system" then "bg-slate-50 dark:bg-slate-700/50 border-slate-200 dark:border-slate-600"
|
|
402
|
+
else "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
role_icon = case role.to_s
|
|
406
|
+
when "user"
|
|
407
|
+
'<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe
|
|
408
|
+
when "assistant"
|
|
409
|
+
'<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
|
|
410
|
+
when "tool"
|
|
411
|
+
'<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>'.html_safe
|
|
412
|
+
else
|
|
413
|
+
'<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
|
|
417
|
+
concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
|
|
418
|
+
concat(content_tag(:div, class: "flex items-center gap-2") do
|
|
419
|
+
concat(role_icon)
|
|
420
|
+
concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
|
|
421
|
+
end)
|
|
422
|
+
concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
|
|
423
|
+
concat(content_tag(:span, created_at.strftime("%H:%M:%S"))) if created_at.respond_to?(:strftime)
|
|
424
|
+
concat(content_tag(:span, "##{idx + 1}"))
|
|
425
|
+
end)
|
|
426
|
+
end)
|
|
427
|
+
|
|
428
|
+
content_str = content.to_s
|
|
429
|
+
if role.to_s == "tool" && content_str.start_with?("{", "[")
|
|
430
|
+
begin
|
|
431
|
+
parsed = JSON.parse(content_str)
|
|
432
|
+
concat(render_json_block(parsed))
|
|
433
|
+
rescue JSON::ParserError
|
|
434
|
+
concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
|
|
435
|
+
end
|
|
436
|
+
else
|
|
437
|
+
concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
|
|
438
|
+
end
|
|
439
|
+
end)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def render_tool_args_preview(resource)
|
|
445
|
+
args = resource.respond_to?(:args) ? resource.args : (resource.respond_to?(:arguments) ? resource.arguments : {})
|
|
446
|
+
result = resource.respond_to?(:result) ? resource.result : nil
|
|
447
|
+
error = resource.respond_to?(:error) ? resource.error : nil
|
|
448
|
+
|
|
449
|
+
content_tag(:div, class: "space-y-6") do
|
|
450
|
+
concat(content_tag(:div) do
|
|
451
|
+
concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
|
|
452
|
+
if args.present? && args != {}
|
|
453
|
+
concat(render_json_block(args))
|
|
454
|
+
else
|
|
455
|
+
concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
|
|
456
|
+
end
|
|
457
|
+
end)
|
|
458
|
+
|
|
459
|
+
if result.present? && result != {}
|
|
460
|
+
concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
|
|
461
|
+
concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
|
|
462
|
+
concat(render_json_block(result))
|
|
463
|
+
end)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
if error.present?
|
|
467
|
+
concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
|
|
468
|
+
concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 dark:text-red-400 mb-2"))
|
|
469
|
+
concat(content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
|
|
470
|
+
content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono")
|
|
471
|
+
end)
|
|
472
|
+
end)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def render_turn_messages_preview(resource)
|
|
478
|
+
user_msg = resource.respond_to?(:user_message) ? resource.user_message : nil
|
|
479
|
+
asst_msg = resource.respond_to?(:assistant_message) ? resource.assistant_message : nil
|
|
480
|
+
|
|
481
|
+
content_tag(:div, class: "space-y-4") do
|
|
482
|
+
if user_msg
|
|
483
|
+
concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800") do
|
|
484
|
+
concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
|
|
485
|
+
concat('<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe)
|
|
486
|
+
concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
|
|
487
|
+
end)
|
|
488
|
+
concat(content_tag(:div, simple_format(h(user_msg.respond_to?(:content) ? user_msg.content.to_s : user_msg.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
|
|
489
|
+
end)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
if asst_msg
|
|
493
|
+
concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800") do
|
|
494
|
+
concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
|
|
495
|
+
concat('<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe)
|
|
496
|
+
concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
|
|
497
|
+
end)
|
|
498
|
+
concat(content_tag(:div, simple_format(h(asst_msg.respond_to?(:content) ? asst_msg.content.to_s : asst_msg.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
|
|
499
|
+
end)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm")) unless user_msg || asst_msg
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def auto_admin_suite_path_for(item)
|
|
507
|
+
return nil unless item.is_a?(ActiveRecord::Base)
|
|
508
|
+
|
|
509
|
+
ensure_admin_resources_loaded_for!(item.class)
|
|
510
|
+
|
|
511
|
+
resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
|
|
512
|
+
return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
|
|
513
|
+
|
|
514
|
+
resource_path(portal: resource.portal_name, resource_name: resource.resource_name_plural, id: item.to_param)
|
|
515
|
+
rescue StandardError
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def ensure_admin_resources_loaded_for!(model_class)
|
|
520
|
+
already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
|
|
521
|
+
return if already_loaded
|
|
522
|
+
|
|
523
|
+
Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
|
|
524
|
+
require file
|
|
525
|
+
end
|
|
526
|
+
rescue NameError
|
|
527
|
+
require "admin/base/resource"
|
|
528
|
+
retry
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# ---- show page sections / associations ----
|
|
532
|
+
#
|
|
533
|
+
# For parity, we keep the same section rendering and association displays used by
|
|
534
|
+
# `/internal/developer`. This is intentionally "UI heavy".
|
|
535
|
+
|
|
536
|
+
def render_show_section(resource, section, position = :main)
|
|
537
|
+
is_association = section.association.present? && !resource.public_send(section.association).is_a?(ActiveRecord::Base) rescue false
|
|
538
|
+
|
|
539
|
+
content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
|
|
540
|
+
header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
|
|
541
|
+
header_text_size = position == :sidebar ? "text-sm" : ""
|
|
542
|
+
header_border = is_association ? "" : "border-b border-slate-200 dark:border-slate-700"
|
|
543
|
+
|
|
544
|
+
concat(content_tag(:div, class: "#{header_padding} #{header_border} bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between") do
|
|
545
|
+
concat(content_tag(:h3, section.title, class: "font-medium text-slate-900 dark:text-white #{header_text_size}"))
|
|
546
|
+
|
|
547
|
+
if section.association.present?
|
|
548
|
+
assoc = resource.public_send(section.association) rescue nil
|
|
549
|
+
if assoc && !assoc.is_a?(ActiveRecord::Base)
|
|
550
|
+
count = assoc.count rescue 0
|
|
551
|
+
color_class = count > 0 ? "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400" : "bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-300"
|
|
552
|
+
concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end)
|
|
556
|
+
|
|
557
|
+
content_padding = position == :sidebar ? "p-4" : "p-6"
|
|
558
|
+
if is_association && position == :main
|
|
559
|
+
content_padding = section.paginate ? "pt-0 px-6 pb-0" : "pt-0 px-6 pb-6"
|
|
560
|
+
end
|
|
561
|
+
content_padding = "pt-0 p-4" if is_association && position == :sidebar
|
|
562
|
+
|
|
563
|
+
concat(content_tag(:div, class: content_padding) do
|
|
564
|
+
if section.render.present?
|
|
565
|
+
render_custom_section(resource, section.render)
|
|
566
|
+
elsif section.association.present?
|
|
567
|
+
render_association_section(resource, section)
|
|
568
|
+
elsif section.fields.any?
|
|
569
|
+
position == :sidebar ? render_sidebar_fields(resource, section.fields) : render_main_fields(resource, section.fields)
|
|
570
|
+
else
|
|
571
|
+
content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
|
|
572
|
+
end
|
|
573
|
+
end)
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def render_sidebar_fields(resource, fields)
|
|
578
|
+
content_tag(:div, class: "space-y-3") do
|
|
579
|
+
fields.each do |field_name|
|
|
580
|
+
value = resource.public_send(field_name) rescue nil
|
|
581
|
+
if value.is_a?(ActiveStorage::Attached::One) || value.is_a?(ActiveStorage::Attached::Many)
|
|
582
|
+
concat(render_sidebar_attachment(value))
|
|
583
|
+
else
|
|
584
|
+
concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
|
|
585
|
+
concat(content_tag(:span, field_name.to_s.humanize, class: "text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider flex-shrink-0"))
|
|
586
|
+
concat(content_tag(:span, class: "text-sm text-slate-900 dark:text-white text-right") { format_show_value(resource, field_name) })
|
|
587
|
+
end)
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def render_sidebar_attachment(attachment)
|
|
594
|
+
return content_tag(:div, class: "text-center py-4") { content_tag(:span, "No image", class: "text-slate-400 text-sm") } unless attachment.respond_to?(:attached?) && attachment.attached?
|
|
595
|
+
|
|
596
|
+
single = attachment.is_a?(ActiveStorage::Attached::Many) ? attachment.first : attachment
|
|
597
|
+
blob = single.blob
|
|
598
|
+
if blob.image?
|
|
599
|
+
variant = single.variant(resize_to_limit: [ 400, 300 ])
|
|
600
|
+
variant_url =
|
|
601
|
+
begin
|
|
602
|
+
admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
|
|
603
|
+
rescue StandardError
|
|
604
|
+
admin_suite_rails_blob_path(blob, disposition: :inline)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
content_tag(:div, class: "space-y-2") do
|
|
608
|
+
concat(content_tag(:div, class: "rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
|
|
609
|
+
image_tag(variant_url, class: "w-full h-auto object-cover", alt: blob.filename.to_s)
|
|
610
|
+
end)
|
|
611
|
+
concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-500 dark:text-slate-400") do
|
|
612
|
+
concat(content_tag(:span, number_to_human_size(blob.byte_size)))
|
|
613
|
+
concat(link_to("View full", admin_suite_rails_blob_path(blob, disposition: :inline), target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
|
|
614
|
+
end)
|
|
615
|
+
end
|
|
616
|
+
else
|
|
617
|
+
content_tag(:div, class: "flex items-center gap-2 p-2 bg-slate-50 dark:bg-slate-800 rounded-lg") do
|
|
618
|
+
concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded flex items-center justify-center") do
|
|
619
|
+
'<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
|
|
620
|
+
end)
|
|
621
|
+
concat(content_tag(:div, class: "flex-1 min-w-0") do
|
|
622
|
+
concat(content_tag(:p, blob.filename.to_s.truncate(20), class: "text-xs font-medium text-slate-700 dark:text-slate-300 truncate"))
|
|
623
|
+
concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-xs text-slate-500"))
|
|
624
|
+
end)
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def render_main_fields(resource, fields)
|
|
630
|
+
content_tag(:dl, class: "space-y-6") do
|
|
631
|
+
fields.each do |field_name|
|
|
632
|
+
concat(content_tag(:div) do
|
|
633
|
+
concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
|
|
634
|
+
concat(content_tag(:dd, class: "text-sm text-slate-900 dark:text-white") { format_show_value(resource, field_name) })
|
|
635
|
+
end)
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# ---- association rendering ----
|
|
641
|
+
def render_association_section(resource, section)
|
|
642
|
+
associated = resource.public_send(section.association) rescue nil
|
|
643
|
+
return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
|
|
644
|
+
|
|
645
|
+
is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
|
|
646
|
+
return render_association_card_single(associated, section) if is_single
|
|
647
|
+
|
|
648
|
+
items = associated
|
|
649
|
+
pagy = nil
|
|
650
|
+
|
|
651
|
+
if section.paginate
|
|
652
|
+
per_page = (section.per_page || section.limit || 20).to_i
|
|
653
|
+
per_page = 1 if per_page < 1
|
|
654
|
+
page_param = association_page_param(section)
|
|
655
|
+
page = params[page_param].presence || 1
|
|
656
|
+
total_count = associated.respond_to?(:count) ? associated.count : associated.to_a.size
|
|
657
|
+
pagy = Pagy.new(count: total_count, page: page, limit: per_page, page_param: page_param)
|
|
658
|
+
items = associated.respond_to?(:offset) ? associated.offset(pagy.offset).limit(per_page) : Array.wrap(associated)[pagy.offset, per_page] || []
|
|
659
|
+
elsif section.limit
|
|
660
|
+
items = associated.respond_to?(:limit) ? associated.limit(section.limit) : Array.wrap(associated).first(section.limit)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
items = Array.wrap(items)
|
|
664
|
+
return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if items.empty?
|
|
665
|
+
|
|
666
|
+
content_tag(:div) do
|
|
667
|
+
case section.display
|
|
668
|
+
when :table
|
|
669
|
+
concat(render_association_table(items, section))
|
|
670
|
+
when :cards
|
|
671
|
+
concat(render_association_cards(items, section))
|
|
672
|
+
else
|
|
673
|
+
concat(render_association_list(items, section))
|
|
674
|
+
end
|
|
675
|
+
concat(render_association_pagination(pagy)) if pagy
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def association_page_param(section) = "#{section.association}_page"
|
|
680
|
+
|
|
681
|
+
def render_association_pagination(pagy)
|
|
682
|
+
content_tag(:div, class: "-mx-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 px-6 py-3") do
|
|
683
|
+
content_tag(:nav, class: "flex items-center justify-between", "aria-label" => "Pagination") do
|
|
684
|
+
concat(pagy_prev_link(pagy))
|
|
685
|
+
concat(pagy_page_links(pagy))
|
|
686
|
+
concat(pagy_next_link(pagy))
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def pagy_prev_link(pagy)
|
|
692
|
+
if pagy.prev
|
|
693
|
+
link_to("Prev", pagy_url_for(pagy, pagy.prev),
|
|
694
|
+
class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
|
|
695
|
+
else
|
|
696
|
+
content_tag(:span, "Prev",
|
|
697
|
+
class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def pagy_next_link(pagy)
|
|
702
|
+
if pagy.next
|
|
703
|
+
link_to("Next", pagy_url_for(pagy, pagy.next),
|
|
704
|
+
class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
|
|
705
|
+
else
|
|
706
|
+
content_tag(:span, "Next",
|
|
707
|
+
class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def pagy_page_links(pagy)
|
|
712
|
+
content_tag(:div, class: "flex items-center gap-1") do
|
|
713
|
+
pagy.series.each { |item| concat(render_pagy_series_item(pagy, item)) }
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def render_pagy_series_item(pagy, item)
|
|
718
|
+
case item
|
|
719
|
+
when Integer
|
|
720
|
+
link_to(item, pagy_url_for(pagy, item),
|
|
721
|
+
class: "px-2.5 py-1 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
|
|
722
|
+
when String
|
|
723
|
+
content_tag(:span, item, class: "px-2.5 py-1 text-sm font-semibold text-white bg-indigo-600 border border-indigo-600 rounded")
|
|
724
|
+
when :gap
|
|
725
|
+
content_tag(:span, "…", class: "px-2 text-sm text-slate-400 dark:text-slate-500")
|
|
726
|
+
else
|
|
727
|
+
""
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def render_association_card_single(item, section)
|
|
732
|
+
link_path = build_association_link(item, section)
|
|
733
|
+
|
|
734
|
+
card_content = capture do
|
|
735
|
+
concat(content_tag(:div, class: "flex items-center justify-between gap-3") do
|
|
736
|
+
concat(content_tag(:div, class: "min-w-0 flex-1") do
|
|
737
|
+
title = item_display_title(item)
|
|
738
|
+
title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
|
|
739
|
+
concat(content_tag(:div, title, class: title_class))
|
|
740
|
+
|
|
741
|
+
subtitle = []
|
|
742
|
+
subtitle << item.status.to_s.humanize if item.respond_to?(:status) && item.status.present?
|
|
743
|
+
subtitle << item.email_address if item.respond_to?(:email_address) && item.email_address.present?
|
|
744
|
+
subtitle << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
|
|
745
|
+
concat(content_tag(:div, subtitle.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5")) if subtitle.any?
|
|
746
|
+
end)
|
|
747
|
+
|
|
748
|
+
if link_path
|
|
749
|
+
concat('<svg class="w-5 h-5 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
|
|
750
|
+
end
|
|
751
|
+
end)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
link_path ? link_to(card_content, link_path, class: "flex items-center -m-4 p-4 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/10 transition-colors group") : content_tag(:div, card_content, class: "flex items-center")
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def render_association_list(items, section)
|
|
758
|
+
content_tag(:div, class: "divide-y divide-slate-200 dark:divide-slate-700 -mx-6 -mt-2 -mb-6") do
|
|
759
|
+
items.each do |item|
|
|
760
|
+
link_path = build_association_link(item, section)
|
|
761
|
+
wrapper = if link_path
|
|
762
|
+
->(content) { link_to(link_path, class: "block px-6 py-4 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 transition-colors group") { content } }
|
|
763
|
+
else
|
|
764
|
+
->(content) { content_tag(:div, content, class: "px-6 py-4") }
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
concat(wrapper.call(capture do
|
|
768
|
+
concat(content_tag(:div, class: "flex items-start justify-between gap-4") do
|
|
769
|
+
concat(content_tag(:div, class: "min-w-0 flex-1") do
|
|
770
|
+
concat(content_tag(:div, class: "flex items-center gap-2") do
|
|
771
|
+
title = item_display_title(item)
|
|
772
|
+
title_class = link_path ? "text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "text-slate-900 dark:text-white"
|
|
773
|
+
concat(content_tag(:span, title.truncate(60), class: "font-medium #{title_class} truncate"))
|
|
774
|
+
concat(render_status_badge(item.status, size: :sm)) if item.respond_to?(:status) && item.status.present?
|
|
775
|
+
end)
|
|
776
|
+
end)
|
|
777
|
+
|
|
778
|
+
concat(content_tag(:div, class: "flex items-center gap-3 flex-shrink-0 text-xs text-slate-400") do
|
|
779
|
+
concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago")) if item.respond_to?(:created_at) && item.created_at
|
|
780
|
+
if link_path
|
|
781
|
+
concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
|
|
782
|
+
end
|
|
783
|
+
end)
|
|
784
|
+
end)
|
|
785
|
+
end))
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# Minimal association table support (matches internal portal table UX enough for now).
|
|
791
|
+
def render_association_table(items, section)
|
|
792
|
+
columns = section.columns.presence || detect_table_columns(items.first)
|
|
793
|
+
|
|
794
|
+
content_tag(:div, class: "overflow-x-auto -mx-6 -mt-1") do
|
|
795
|
+
content_tag(:table, class: "min-w-full divide-y divide-slate-200 dark:divide-slate-700") do
|
|
796
|
+
concat(content_tag(:thead, class: "bg-slate-50/50 dark:bg-slate-900/30") do
|
|
797
|
+
content_tag(:tr) do
|
|
798
|
+
Array.wrap(columns).each do |col|
|
|
799
|
+
header = col.to_s.gsub(/_id$/, "").humanize
|
|
800
|
+
concat(content_tag(:th, header, class: "px-4 py-2.5 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider first:pl-6"))
|
|
801
|
+
end
|
|
802
|
+
concat(content_tag(:th, "", class: "px-4 py-2.5 w-16"))
|
|
803
|
+
end
|
|
804
|
+
end)
|
|
805
|
+
|
|
806
|
+
concat(content_tag(:tbody, class: "divide-y divide-slate-200 dark:divide-slate-700") do
|
|
807
|
+
items.each do |item|
|
|
808
|
+
link_path = build_association_link(item, section)
|
|
809
|
+
concat(content_tag(:tr, class: link_path ? "hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 cursor-pointer group" : "") do
|
|
810
|
+
Array.wrap(columns).each_with_index do |col, idx|
|
|
811
|
+
value = item.public_send(col) rescue nil
|
|
812
|
+
text = format_table_cell(value)
|
|
813
|
+
concat(content_tag(:td, text, class: (idx == 0 ? "px-4 py-3 text-sm first:pl-6" : "px-4 py-3 text-sm")))
|
|
814
|
+
end
|
|
815
|
+
concat(content_tag(:td, class: "px-4 py-3 text-right pr-6") do
|
|
816
|
+
link_path ? link_to("View", link_path, class: "inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 text-sm font-medium") : ""
|
|
817
|
+
end)
|
|
818
|
+
end)
|
|
819
|
+
end
|
|
820
|
+
end)
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def render_association_cards(items, section)
|
|
826
|
+
content_tag(:div, class: "grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1") do
|
|
827
|
+
items.each do |item|
|
|
828
|
+
link_path = build_association_link(item, section)
|
|
829
|
+
card_class = "border border-slate-200 dark:border-slate-700 rounded-lg p-4 transition-all"
|
|
830
|
+
card_class += link_path ? " hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md group cursor-pointer" : " hover:bg-slate-50 dark:hover:bg-slate-900/30"
|
|
831
|
+
|
|
832
|
+
card_content = capture do
|
|
833
|
+
concat(content_tag(:div, class: "flex items-start justify-between gap-2 mb-2") do
|
|
834
|
+
title = item_display_title(item)
|
|
835
|
+
title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
|
|
836
|
+
concat(content_tag(:span, title.truncate(35), class: title_class))
|
|
837
|
+
concat(render_status_badge(item.status, size: :sm)) if item.respond_to?(:status) && item.status.present?
|
|
838
|
+
end)
|
|
839
|
+
concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700/50") do
|
|
840
|
+
concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago")) if item.respond_to?(:created_at) && item.created_at
|
|
841
|
+
concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe) if link_path
|
|
842
|
+
end)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
concat(link_path ? link_to(card_content, link_path, class: card_class) : content_tag(:div, card_content, class: card_class))
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def detect_table_columns(item)
|
|
851
|
+
return [ :id, :name, :created_at ] unless item
|
|
852
|
+
priority = [ :name, :title, :status ]
|
|
853
|
+
attrs = item.attributes.keys.map(&:to_sym)
|
|
854
|
+
selected = priority.select { |c| attrs.include?(c) }
|
|
855
|
+
selected << :created_at if selected.size < 5 && attrs.include?(:created_at)
|
|
856
|
+
selected.take(5)
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def format_table_cell(value)
|
|
860
|
+
case value
|
|
861
|
+
when nil then "—"
|
|
862
|
+
when true, false then value ? "Yes" : "No"
|
|
863
|
+
when Time, DateTime then value.strftime("%b %d, %H:%M")
|
|
864
|
+
when Date then value.strftime("%b %d, %Y")
|
|
865
|
+
when ActiveRecord::Base then item_display_title(value)
|
|
866
|
+
else value.to_s.truncate(50)
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def item_display_title(item)
|
|
871
|
+
return item.name if item.respond_to?(:name) && item.name.present?
|
|
872
|
+
return item.title if item.respond_to?(:title) && item.title.present?
|
|
873
|
+
return item.display_title if item.respond_to?(:display_title) && item.display_title.present?
|
|
874
|
+
return item.content.to_s.truncate(50) if item.respond_to?(:content)
|
|
875
|
+
|
|
876
|
+
"##{item.id}"
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def build_association_link(item, section)
|
|
880
|
+
if section.link_to.present?
|
|
881
|
+
begin
|
|
882
|
+
return send(section.link_to, item)
|
|
883
|
+
rescue NoMethodError
|
|
884
|
+
# fall through to auto-link
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
auto_admin_suite_path_for(item)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def render_status_badge(status, size: :md)
|
|
892
|
+
return content_tag(:span, "—", class: "text-slate-400") if status.blank?
|
|
893
|
+
|
|
894
|
+
status_str = status.to_s.downcase
|
|
895
|
+
colors = case status_str
|
|
896
|
+
when "active", "open", "success", "approved", "completed", "enabled"
|
|
897
|
+
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
|
898
|
+
when "pending", "proposed", "queued", "waiting"
|
|
899
|
+
"bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
|
|
900
|
+
when "running", "processing", "in_progress"
|
|
901
|
+
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
|
|
902
|
+
when "error", "failed", "rejected", "cancelled"
|
|
903
|
+
"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
|
|
904
|
+
else
|
|
905
|
+
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
padding = size == :sm ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
|
|
909
|
+
content_tag(:span, status_str.titleize, class: "inline-flex items-center #{padding} rounded-full font-medium #{colors}")
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def render_label_badge(value, color: nil, size: :md, record: nil)
|
|
913
|
+
return content_tag(:span, "—", class: "text-slate-400") if value.blank?
|
|
914
|
+
|
|
915
|
+
label_color = resolve_label_option(color, record).presence || :slate
|
|
916
|
+
label_size = resolve_label_option(size, record).presence || :md
|
|
917
|
+
colors = label_badge_colors(label_color)
|
|
918
|
+
padding = label_size.to_s == "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
|
|
919
|
+
content_tag(:span, value.to_s, class: "inline-flex items-center #{padding} rounded-md font-medium #{colors}")
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def resolve_label_option(option, record)
|
|
923
|
+
return option.call(record) if option.is_a?(Proc)
|
|
924
|
+
option
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def label_badge_colors(color)
|
|
928
|
+
case color.to_s.downcase
|
|
929
|
+
when "green"
|
|
930
|
+
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
|
931
|
+
when "amber", "yellow", "orange"
|
|
932
|
+
"bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
|
|
933
|
+
when "blue"
|
|
934
|
+
"bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
|
|
935
|
+
when "red"
|
|
936
|
+
"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
|
|
937
|
+
when "indigo"
|
|
938
|
+
"bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400"
|
|
939
|
+
when "purple"
|
|
940
|
+
"bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400"
|
|
941
|
+
when "violet"
|
|
942
|
+
"bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400"
|
|
943
|
+
when "emerald"
|
|
944
|
+
"bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
|
|
945
|
+
when "cyan"
|
|
946
|
+
"bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400"
|
|
947
|
+
else
|
|
948
|
+
"bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# ---- form fields ----
|
|
953
|
+
def render_form_field(f, field, resource)
|
|
954
|
+
return if field.if_condition.present? && !field.if_condition.call(resource)
|
|
955
|
+
return if field.unless_condition.present? && field.unless_condition.call(resource)
|
|
956
|
+
|
|
957
|
+
capture do
|
|
958
|
+
concat(content_tag(:div, class: "form-group") do
|
|
959
|
+
concat(f.label(field.name, class: "form-label") do
|
|
960
|
+
concat(field.label)
|
|
961
|
+
concat(content_tag(:span, " *", class: "text-red-500")) if field.required
|
|
962
|
+
end)
|
|
963
|
+
|
|
964
|
+
field_class = "form-input w-full"
|
|
965
|
+
field_class += " border-red-500" if resource.errors[field.name].any?
|
|
966
|
+
|
|
967
|
+
field_html = case field.type
|
|
968
|
+
when :textarea then f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
|
|
969
|
+
when :url then f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
970
|
+
when :email then f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
971
|
+
when :number then f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
972
|
+
when :toggle then render_toggle_field(f, field, resource)
|
|
973
|
+
when :label
|
|
974
|
+
label_value = resource.public_send(field.name) rescue nil
|
|
975
|
+
render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
|
|
976
|
+
when :select
|
|
977
|
+
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
|
|
978
|
+
f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
|
|
979
|
+
when :searchable_select then render_searchable_select(f, field, resource)
|
|
980
|
+
when :multi_select, :tags then render_multi_select(f, field, resource)
|
|
981
|
+
when :image, :attachment then render_file_upload(f, field, resource)
|
|
982
|
+
when :trix, :rich_text then f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
|
|
983
|
+
when :markdown
|
|
984
|
+
f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
|
|
985
|
+
when :file then f.file_field(field.name, class: "form-input-file", accept: field.accept)
|
|
986
|
+
when :datetime then f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
|
|
987
|
+
when :date then f.date_field(field.name, class: field_class, readonly: field.readonly)
|
|
988
|
+
when :time then f.time_field(field.name, class: field_class, readonly: field.readonly)
|
|
989
|
+
when :json
|
|
990
|
+
render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
|
|
991
|
+
when :code then render_code_editor(f, field, resource)
|
|
992
|
+
else
|
|
993
|
+
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
concat(field_html)
|
|
997
|
+
|
|
998
|
+
concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
|
|
999
|
+
concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400")) if resource.errors[field.name].any?
|
|
1000
|
+
end)
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def render_toggle_field(_f, field, resource)
|
|
1005
|
+
checked = !!resource.public_send(field.name)
|
|
1006
|
+
param_key = resource.class.model_name.param_key
|
|
1007
|
+
|
|
1008
|
+
content_tag(:div,
|
|
1009
|
+
class: "inline-flex items-center gap-3",
|
|
1010
|
+
data: {
|
|
1011
|
+
controller: "admin-suite--toggle-switch",
|
|
1012
|
+
"admin-suite--toggle-switch-active-class-value": "is-on",
|
|
1013
|
+
"admin-suite--toggle-switch-inactive-classes-value": ""
|
|
1014
|
+
}) do
|
|
1015
|
+
concat(content_tag(:button, type: "button",
|
|
1016
|
+
class: "admin-suite-toggle-track #{checked ? "is-on" : ""}",
|
|
1017
|
+
role: "switch",
|
|
1018
|
+
"aria-checked" => checked.to_s,
|
|
1019
|
+
data: { action: "click->admin-suite--toggle-switch#toggle", "admin-suite--toggle-switch-target": "button" },
|
|
1020
|
+
disabled: field.readonly) do
|
|
1021
|
+
content_tag(:span, "", class: "admin-suite-toggle-thumb", data: { "admin-suite--toggle-switch-target": "thumb" })
|
|
1022
|
+
end)
|
|
1023
|
+
|
|
1024
|
+
concat(hidden_field_tag("#{param_key}[#{field.name}]", checked ? "1" : "0", id: "#{param_key}_#{field.name}", data: { "admin-suite--toggle-switch-target": "input" }))
|
|
1025
|
+
concat(content_tag(:span, checked ? "Enabled" : "Disabled", class: "text-sm font-medium text-slate-700", data: { "admin-suite--toggle-switch-target": "label" }))
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
def render_searchable_select(_f, field, resource)
|
|
1030
|
+
param_key = resource.class.model_name.param_key
|
|
1031
|
+
current_value = resource.public_send(field.name)
|
|
1032
|
+
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
|
|
1033
|
+
|
|
1034
|
+
options_json = if collection.is_a?(Array)
|
|
1035
|
+
collection.map { |opt| opt.is_a?(Array) ? { value: opt[1], label: opt[0] } : { value: opt, label: opt.to_s.humanize } }.to_json
|
|
1036
|
+
else
|
|
1037
|
+
"[]"
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
current_label = if current_value.present? && collection.is_a?(Array)
|
|
1041
|
+
match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
|
|
1042
|
+
match.is_a?(Array) ? match[0] : match.to_s
|
|
1043
|
+
else
|
|
1044
|
+
current_value
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
content_tag(:div,
|
|
1048
|
+
data: {
|
|
1049
|
+
controller: "admin-suite--searchable-select",
|
|
1050
|
+
"admin-suite--searchable-select-options-value": options_json,
|
|
1051
|
+
"admin-suite--searchable-select-creatable-value": field.create_url.present?,
|
|
1052
|
+
"admin-suite--searchable-select-search-url-value": collection.is_a?(String) ? collection : ""
|
|
1053
|
+
},
|
|
1054
|
+
class: "relative") do
|
|
1055
|
+
concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value, data: { "admin-suite--searchable-select-target": "input" }))
|
|
1056
|
+
concat(text_field_tag(nil, current_label,
|
|
1057
|
+
class: "form-input w-full",
|
|
1058
|
+
placeholder: field.placeholder || "Search...",
|
|
1059
|
+
autocomplete: "off",
|
|
1060
|
+
data: {
|
|
1061
|
+
"admin-suite--searchable-select-target": "search",
|
|
1062
|
+
action: "input->admin-suite--searchable-select#search focus->admin-suite--searchable-select#open keydown->admin-suite--searchable-select#keydown"
|
|
1063
|
+
}))
|
|
1064
|
+
concat(content_tag(:div, "",
|
|
1065
|
+
class: "absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
|
|
1066
|
+
data: { "admin-suite--searchable-select-target": "dropdown" }))
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
def render_multi_select(_f, field, resource)
|
|
1071
|
+
param_key = resource.class.model_name.param_key
|
|
1072
|
+
current_values =
|
|
1073
|
+
if resource.respond_to?("#{field.name}_list")
|
|
1074
|
+
resource.public_send("#{field.name}_list")
|
|
1075
|
+
elsif resource.respond_to?(field.name)
|
|
1076
|
+
Array.wrap(resource.public_send(field.name))
|
|
1077
|
+
else
|
|
1078
|
+
[]
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
options =
|
|
1082
|
+
if field.collection.is_a?(Proc)
|
|
1083
|
+
field.collection.call
|
|
1084
|
+
elsif field.collection.is_a?(Array)
|
|
1085
|
+
field.collection
|
|
1086
|
+
else
|
|
1087
|
+
[]
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
field_name = field.type == :tags ? "tag_list" : field.name
|
|
1091
|
+
full_field_name = "#{param_key}[#{field_name}][]"
|
|
1092
|
+
|
|
1093
|
+
content_tag(:div,
|
|
1094
|
+
data: {
|
|
1095
|
+
controller: "admin-suite--tag-select",
|
|
1096
|
+
"admin-suite--tag-select-creatable-value": field.create_url.present? || field.type == :tags,
|
|
1097
|
+
"admin-suite--tag-select-field-name-value": full_field_name
|
|
1098
|
+
},
|
|
1099
|
+
class: "space-y-2") do
|
|
1100
|
+
concat(hidden_field_tag(full_field_name, "", id: nil, data: { "admin-suite--tag-select-target": "placeholder" }))
|
|
1101
|
+
|
|
1102
|
+
concat(content_tag(:div,
|
|
1103
|
+
class: "flex flex-wrap gap-2 min-h-[2.5rem] p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg",
|
|
1104
|
+
data: { "admin-suite--tag-select-target": "tags" }) do
|
|
1105
|
+
current_values.each do |val|
|
|
1106
|
+
concat(content_tag(:span,
|
|
1107
|
+
class: "inline-flex items-center gap-1 px-2 py-1 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded text-sm") do
|
|
1108
|
+
concat(val.to_s)
|
|
1109
|
+
concat(hidden_field_tag(full_field_name, val, id: nil))
|
|
1110
|
+
concat(button_tag("×", type: "button", class: "text-indigo-500 hover:text-indigo-700 font-bold", data: { action: "admin-suite--tag-select#remove" }))
|
|
1111
|
+
end)
|
|
1112
|
+
end
|
|
1113
|
+
concat(text_field_tag(nil, "",
|
|
1114
|
+
class: "flex-1 min-w-[120px] border-none focus:outline-none focus:ring-0 bg-transparent text-sm",
|
|
1115
|
+
placeholder: field.placeholder || "Add tag...",
|
|
1116
|
+
autocomplete: "off",
|
|
1117
|
+
data: { "admin-suite--tag-select-target": "input", action: "keydown->admin-suite--tag-select#keydown input->admin-suite--tag-select#search" }))
|
|
1118
|
+
end)
|
|
1119
|
+
|
|
1120
|
+
if options.any?
|
|
1121
|
+
concat(content_tag(:div,
|
|
1122
|
+
class: "hidden border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 shadow-lg max-h-48 overflow-y-auto",
|
|
1123
|
+
data: { "admin-suite--tag-select-target": "dropdown" }) do
|
|
1124
|
+
options.each do |opt|
|
|
1125
|
+
label, value = opt.is_a?(Array) ? [ opt[0], opt[1] ] : [ opt, opt ]
|
|
1126
|
+
concat(content_tag(:button, label,
|
|
1127
|
+
type: "button",
|
|
1128
|
+
class: "block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700",
|
|
1129
|
+
data: { action: "admin-suite--tag-select#select", value: value }))
|
|
1130
|
+
end
|
|
1131
|
+
end)
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def render_file_upload(f, field, resource)
|
|
1137
|
+
attachment = resource.respond_to?(field.name) ? resource.public_send(field.name) : nil
|
|
1138
|
+
has_attachment = attachment.respond_to?(:attached?) && attachment.attached?
|
|
1139
|
+
is_image = field.type == :image || (field.accept.present? && field.accept.include?("image"))
|
|
1140
|
+
existing_url =
|
|
1141
|
+
if has_attachment && is_image
|
|
1142
|
+
variant = attachment.variant(resize_to_limit: [ 300, 300 ])
|
|
1143
|
+
begin
|
|
1144
|
+
admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
|
|
1145
|
+
rescue StandardError
|
|
1146
|
+
admin_suite_rails_blob_path(attachment.blob, disposition: :inline)
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
content_tag(:div,
|
|
1151
|
+
data: {
|
|
1152
|
+
controller: "admin-suite--file-upload",
|
|
1153
|
+
"admin-suite--file-upload-accept-value": field.accept || (is_image ? "image/*" : "*/*"),
|
|
1154
|
+
"admin-suite--file-upload-preview-value": field.type == :image,
|
|
1155
|
+
"admin-suite--file-upload-existing-url-value": existing_url
|
|
1156
|
+
},
|
|
1157
|
+
class: "space-y-3") do
|
|
1158
|
+
if has_attachment && is_image
|
|
1159
|
+
concat(content_tag(:div, class: "relative inline-block") do
|
|
1160
|
+
concat(image_tag(existing_url, class: "max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover", data: { "admin-suite--file-upload-target": "imagePreview" }))
|
|
1161
|
+
concat(button_tag("×", type: "button",
|
|
1162
|
+
class: "absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center text-sm",
|
|
1163
|
+
data: { "admin-suite--file-upload-target": "removeButton", action: "admin-suite--file-upload#remove" }))
|
|
1164
|
+
end)
|
|
1165
|
+
else
|
|
1166
|
+
concat(image_tag("", class: "hidden max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover", data: { "admin-suite--file-upload-target": "imagePreview" }))
|
|
1167
|
+
concat(content_tag(:div, "", class: "hidden", data: { "admin-suite--file-upload-target": "filename" }))
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
concat(content_tag(:div,
|
|
1171
|
+
class: "relative border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors",
|
|
1172
|
+
data: { "admin-suite--file-upload-target": "dropzone" }) do
|
|
1173
|
+
concat(f.file_field(field.name,
|
|
1174
|
+
class: "sr-only",
|
|
1175
|
+
id: "#{field.name}_input",
|
|
1176
|
+
accept: field.accept || (is_image ? "image/*" : nil),
|
|
1177
|
+
data: { "admin-suite--file-upload-target": "input", action: "change->admin-suite--file-upload#preview" }))
|
|
1178
|
+
|
|
1179
|
+
concat(content_tag(:label, for: "#{field.name}_input",
|
|
1180
|
+
class: "flex flex-col items-center justify-center w-full py-6 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-900/50 rounded-lg transition-colors") do
|
|
1181
|
+
concat('<svg class="w-8 h-8 text-slate-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>'.html_safe)
|
|
1182
|
+
concat(content_tag(:span, "Click to upload or drag and drop", class: "text-sm text-slate-500 dark:text-slate-400"))
|
|
1183
|
+
concat(content_tag(:span, "PNG, JPG, WebP up to 10MB", class: "text-xs text-slate-400 mt-1")) if is_image
|
|
1184
|
+
end)
|
|
1185
|
+
end)
|
|
1186
|
+
end
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def render_code_editor(f, field, _resource)
|
|
1190
|
+
content_tag(:div, class: "relative", data: { controller: "admin-suite--code-editor" }) do
|
|
1191
|
+
f.text_area(field.name,
|
|
1192
|
+
class: "w-full font-mono text-sm bg-slate-900 text-slate-100 p-4 rounded-lg border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500",
|
|
1193
|
+
rows: field.rows || 12,
|
|
1194
|
+
placeholder: field.placeholder,
|
|
1195
|
+
data: { "admin-suite--code-editor-target": "textarea" })
|
|
1196
|
+
end
|
|
1197
|
+
end
|
|
1198
|
+
end
|
|
1199
|
+
end
|