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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/Gemfile +13 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +7 -0
  6. data/Rakefile +11 -0
  7. data/app/assets/admin_suite.css +444 -0
  8. data/app/assets/admin_suite_tailwind.css +8 -0
  9. data/app/assets/builds/admin_suite_tailwind.css +8 -0
  10. data/app/assets/rouge.css +218 -0
  11. data/app/assets/tailwind/admin_suite.css +22 -0
  12. data/app/controllers/admin_suite/application_controller.rb +118 -0
  13. data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
  14. data/app/controllers/admin_suite/docs_controller.rb +155 -0
  15. data/app/controllers/admin_suite/portals_controller.rb +22 -0
  16. data/app/controllers/admin_suite/resources_controller.rb +238 -0
  17. data/app/helpers/admin_suite/base_helper.rb +1199 -0
  18. data/app/helpers/admin_suite/icon_helper.rb +61 -0
  19. data/app/helpers/admin_suite/panels_helper.rb +52 -0
  20. data/app/helpers/admin_suite/resources_helper.rb +15 -0
  21. data/app/helpers/admin_suite/theme_helper.rb +99 -0
  22. data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
  23. data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
  24. data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
  25. data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
  26. data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
  27. data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
  28. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
  29. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
  30. data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
  31. data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
  32. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
  33. data/app/views/admin_suite/dashboard/index.html.erb +21 -0
  34. data/app/views/admin_suite/docs/index.html.erb +86 -0
  35. data/app/views/admin_suite/panels/_cards.html.erb +107 -0
  36. data/app/views/admin_suite/panels/_chart.html.erb +47 -0
  37. data/app/views/admin_suite/panels/_health.html.erb +44 -0
  38. data/app/views/admin_suite/panels/_recent.html.erb +56 -0
  39. data/app/views/admin_suite/panels/_stat.html.erb +64 -0
  40. data/app/views/admin_suite/panels/_table.html.erb +36 -0
  41. data/app/views/admin_suite/portals/show.html.erb +75 -0
  42. data/app/views/admin_suite/resources/_form.html.erb +32 -0
  43. data/app/views/admin_suite/resources/edit.html.erb +24 -0
  44. data/app/views/admin_suite/resources/index.html.erb +315 -0
  45. data/app/views/admin_suite/resources/new.html.erb +22 -0
  46. data/app/views/admin_suite/resources/show.html.erb +184 -0
  47. data/app/views/admin_suite/shared/_flash.html.erb +30 -0
  48. data/app/views/admin_suite/shared/_form.html.erb +60 -0
  49. data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
  50. data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
  51. data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
  52. data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
  53. data/app/views/layouts/admin_suite/application.html.erb +79 -0
  54. data/lib/admin/base/action_executor.rb +155 -0
  55. data/lib/admin/base/action_handler.rb +31 -0
  56. data/lib/admin/base/filter_builder.rb +121 -0
  57. data/lib/admin/base/resource.rb +541 -0
  58. data/lib/admin_suite/configuration.rb +42 -0
  59. data/lib/admin_suite/engine.rb +101 -0
  60. data/lib/admin_suite/markdown_renderer.rb +115 -0
  61. data/lib/admin_suite/portal_definition.rb +64 -0
  62. data/lib/admin_suite/portal_registry.rb +32 -0
  63. data/lib/admin_suite/theme_palette.rb +36 -0
  64. data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
  65. data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
  66. data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
  67. data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
  68. data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
  69. data/lib/admin_suite/version.rb +10 -0
  70. data/lib/admin_suite.rb +54 -0
  71. data/lib/generators/admin_suite/install/install_generator.rb +23 -0
  72. data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
  73. data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
  74. data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
  75. data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
  76. data/lib/tasks/admin_suite_tailwind.rake +28 -0
  77. data/lib/tasks/admin_suite_test.rake +11 -0
  78. data/test/dummy/Gemfile +21 -0
  79. data/test/dummy/README.md +24 -0
  80. data/test/dummy/Rakefile +6 -0
  81. data/test/dummy/app/assets/stylesheets/application.css +10 -0
  82. data/test/dummy/app/controllers/application_controller.rb +4 -0
  83. data/test/dummy/app/helpers/application_helper.rb +2 -0
  84. data/test/dummy/app/models/application_record.rb +2 -0
  85. data/test/dummy/app/views/layouts/application.html.erb +28 -0
  86. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  87. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  88. data/test/dummy/bin/ci +6 -0
  89. data/test/dummy/bin/dev +2 -0
  90. data/test/dummy/bin/rails +4 -0
  91. data/test/dummy/bin/rake +4 -0
  92. data/test/dummy/bin/setup +35 -0
  93. data/test/dummy/config/application.rb +43 -0
  94. data/test/dummy/config/boot.rb +3 -0
  95. data/test/dummy/config/ci.rb +19 -0
  96. data/test/dummy/config/database.yml +31 -0
  97. data/test/dummy/config/environment.rb +5 -0
  98. data/test/dummy/config/environments/development.rb +57 -0
  99. data/test/dummy/config/environments/production.rb +67 -0
  100. data/test/dummy/config/environments/test.rb +42 -0
  101. data/test/dummy/config/initializers/assets.rb +7 -0
  102. data/test/dummy/config/initializers/content_security_policy.rb +29 -0
  103. data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  104. data/test/dummy/config/initializers/inflections.rb +16 -0
  105. data/test/dummy/config/locales/en.yml +31 -0
  106. data/test/dummy/config/puma.rb +39 -0
  107. data/test/dummy/config/routes.rb +16 -0
  108. data/test/dummy/config.ru +6 -0
  109. data/test/dummy/db/seeds.rb +9 -0
  110. data/test/dummy/log/test.log +441 -0
  111. data/test/dummy/public/400.html +135 -0
  112. data/test/dummy/public/404.html +135 -0
  113. data/test/dummy/public/406-unsupported-browser.html +135 -0
  114. data/test/dummy/public/422.html +135 -0
  115. data/test/dummy/public/500.html +135 -0
  116. data/test/dummy/public/icon.png +0 -0
  117. data/test/dummy/public/icon.svg +3 -0
  118. data/test/dummy/public/robots.txt +1 -0
  119. data/test/dummy/test/test_helper.rb +15 -0
  120. data/test/dummy/tmp/local_secret.txt +1 -0
  121. data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
  122. data/test/integration/dashboard_test.rb +13 -0
  123. data/test/integration/docs_test.rb +46 -0
  124. data/test/integration/theme_test.rb +27 -0
  125. data/test/lib/markdown_renderer_test.rb +20 -0
  126. data/test/lib/theme_palette_test.rb +24 -0
  127. data/test/test_helper.rb +11 -0
  128. 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