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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redcarpet"
4
+ require "rouge"
5
+ require "rouge/plugins/redcarpet"
6
+
7
+ module AdminSuite
8
+ # MarkdownRenderer converts markdown text into safe HTML with syntax highlighting.
9
+ #
10
+ # Uses Redcarpet for markdown parsing, Rouge for syntax highlighting,
11
+ # and extracts a table of contents from headings.
12
+ class MarkdownRenderer
13
+ attr_reader :markdown
14
+
15
+ def initialize(markdown)
16
+ @markdown = markdown.to_s
17
+ end
18
+
19
+ # @return [Hash{Symbol=>Object}]
20
+ def render
21
+ result = self.class.render_with_toc(markdown)
22
+
23
+ {
24
+ html: result[:html],
25
+ toc: result[:toc],
26
+ reading_time_minutes: reading_time_minutes
27
+ }
28
+ end
29
+
30
+ # @param text [String]
31
+ # @return [ActiveSupport::SafeBuffer]
32
+ def self.render(text)
33
+ renderer = HtmlRenderer.new
34
+ md = Redcarpet::Markdown.new(renderer, markdown_extensions)
35
+ md.render(text.to_s).html_safe
36
+ end
37
+
38
+ # @param text [String]
39
+ # @return [Hash{Symbol=>Object}]
40
+ def self.render_with_toc(text)
41
+ renderer = HtmlRenderer.new
42
+ md = Redcarpet::Markdown.new(renderer, markdown_extensions)
43
+ html = md.render(text.to_s).html_safe
44
+ { html: html, toc: renderer.toc_items }
45
+ end
46
+
47
+ private
48
+
49
+ def self.markdown_extensions
50
+ {
51
+ autolink: true,
52
+ tables: true,
53
+ fenced_code_blocks: true,
54
+ strikethrough: true,
55
+ highlight: true,
56
+ superscript: true,
57
+ underline: true,
58
+ no_intra_emphasis: true,
59
+ space_after_headers: true,
60
+ lax_spacing: true
61
+ }
62
+ end
63
+
64
+ # Rough reading time estimate assuming 200 wpm.
65
+ # @return [Integer]
66
+ def reading_time_minutes
67
+ words = markdown.scan(/\b[\p{L}\p{N}']+\b/).size
68
+ [ (words / 200.0).ceil, 1 ].max
69
+ end
70
+
71
+ class HtmlRenderer < Redcarpet::Render::HTML
72
+ include Rouge::Plugins::Redcarpet
73
+
74
+ attr_reader :toc_items
75
+
76
+ def initialize(extensions = {})
77
+ super(extensions.merge(
78
+ hard_wrap: true,
79
+ link_attributes: { target: "_blank", rel: "noopener noreferrer" },
80
+ with_toc_data: true
81
+ ))
82
+ @toc_items = []
83
+ @heading_ids = Hash.new(0)
84
+ end
85
+
86
+ def block_code(code, language)
87
+ language ||= "text"
88
+ lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
89
+ formatter = Rouge::Formatters::HTML.new
90
+ highlighted = formatter.format(lexer.lex(code))
91
+
92
+ lang_label = language != "text" ? %(<span class="code-lang">#{language}</span>) : ""
93
+ %(<div class="code-block">#{lang_label}<pre class="highlight #{language}"><code>#{highlighted}</code></pre></div>)
94
+ end
95
+
96
+ def header(text, header_level)
97
+ base_slug = text.to_s.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
98
+ base_slug = "section" if base_slug.blank?
99
+
100
+ @heading_ids[base_slug] += 1
101
+ slug = @heading_ids[base_slug] > 1 ? "#{base_slug}-#{@heading_ids[base_slug]}" : base_slug
102
+
103
+ if header_level >= 2 && header_level <= 4
104
+ @toc_items << { level: header_level, id: slug, text: text }
105
+ end
106
+
107
+ %(<h#{header_level} id="#{slug}">#{text}</h#{header_level}>\n)
108
+ end
109
+
110
+ def table(header, body)
111
+ %(<table class="admin-suite-doc-table"><thead>#{header}</thead><tbody>#{body}</tbody></table>\n)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "admin_suite/ui/dashboard_definition"
4
+
5
+ module AdminSuite
6
+ class PortalDefinition
7
+ attr_reader :key
8
+
9
+ def initialize(key)
10
+ @key = key.to_sym
11
+ @label = nil
12
+ @icon = nil
13
+ @color = nil
14
+ @order = nil
15
+ @description = nil
16
+ @dashboard = nil
17
+ end
18
+
19
+ def label(value = nil)
20
+ @label = value if value.present?
21
+ @label
22
+ end
23
+
24
+ def icon(value = nil)
25
+ @icon = value if value.present?
26
+ @icon
27
+ end
28
+
29
+ def color(value = nil)
30
+ @color = value if value.present?
31
+ @color
32
+ end
33
+
34
+ def order(value = nil)
35
+ @order = value unless value.nil?
36
+ @order
37
+ end
38
+
39
+ def description(value = nil)
40
+ @description = value if value.present?
41
+ @description
42
+ end
43
+
44
+ def dashboard(&block)
45
+ @dashboard ||= UI::DashboardDefinition.new
46
+ UI::DashboardDSL.new(@dashboard).instance_eval(&block) if block_given?
47
+ @dashboard
48
+ end
49
+
50
+ def dashboard_definition
51
+ @dashboard
52
+ end
53
+
54
+ def to_nav_meta
55
+ {
56
+ label: @label,
57
+ icon: @icon,
58
+ color: @color,
59
+ order: @order,
60
+ description: @description
61
+ }.compact
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Stores portal definitions registered via `AdminSuite.portal`.
5
+ module PortalRegistry
6
+ class << self
7
+ # @return [Hash{Symbol=>AdminSuite::PortalDefinition}]
8
+ def all
9
+ @all ||= {}
10
+ end
11
+
12
+ # @param definition [AdminSuite::PortalDefinition]
13
+ # @return [AdminSuite::PortalDefinition]
14
+ def register(definition)
15
+ all[definition.key] = definition
16
+ end
17
+
18
+ # @param key [Symbol, String]
19
+ # @return [AdminSuite::PortalDefinition, nil]
20
+ def fetch(key)
21
+ all[key.to_sym]
22
+ end
23
+
24
+ # Clears the registry (useful for development reloads).
25
+ #
26
+ # @return [void]
27
+ def reset!
28
+ @all = {}
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module ThemePalette
5
+ # Minimal Tailwind-like palette values (hex) for theming.
6
+ # We only include the shades AdminSuite uses.
7
+ COLORS = {
8
+ "slate" => { 100 => "#f1f5f9", 200 => "#e2e8f0", 500 => "#64748b", 600 => "#475569", 700 => "#334155", 800 => "#1e293b", 900 => "#0f172a" },
9
+ "indigo" => { 100 => "#e0e7ff", 200 => "#c7d2fe", 500 => "#6366f1", 600 => "#4f46e5", 700 => "#4338ca", 800 => "#3730a3", 900 => "#312e81" },
10
+ "purple" => { 100 => "#f3e8ff", 200 => "#e9d5ff", 500 => "#a855f7", 600 => "#9333ea", 700 => "#7e22ce", 800 => "#6b21a8", 900 => "#581c87" },
11
+ "violet" => { 100 => "#ede9fe", 200 => "#ddd6fe", 500 => "#8b5cf6", 600 => "#7c3aed", 700 => "#6d28d9", 800 => "#5b21b6", 900 => "#4c1d95" },
12
+ "amber" => { 100 => "#fef3c7", 200 => "#fde68a", 500 => "#f59e0b", 600 => "#d97706", 700 => "#b45309", 800 => "#92400e", 900 => "#78350f" },
13
+ "emerald" => { 100 => "#d1fae5", 200 => "#a7f3d0", 500 => "#10b981", 600 => "#059669", 700 => "#047857", 800 => "#065f46", 900 => "#064e3b" },
14
+ "cyan" => { 100 => "#cffafe", 200 => "#a5f3fc", 500 => "#06b6d4", 600 => "#0891b2", 700 => "#0e7490", 800 => "#155e75", 900 => "#164e63" },
15
+ "blue" => { 100 => "#dbeafe", 200 => "#bfdbfe", 500 => "#3b82f6", 600 => "#2563eb", 700 => "#1d4ed8", 800 => "#1e40af", 900 => "#1e3a8a" },
16
+ "green" => { 100 => "#dcfce7", 200 => "#bbf7d0", 500 => "#22c55e", 600 => "#16a34a", 700 => "#15803d", 800 => "#166534", 900 => "#14532d" },
17
+ "red" => { 100 => "#fee2e2", 200 => "#fecaca", 500 => "#ef4444", 600 => "#dc2626", 700 => "#b91c1c", 800 => "#991b1b", 900 => "#7f1d1d" }
18
+ }.freeze
19
+
20
+ def self.resolve(color_name, shade, fallback: nil)
21
+ return fallback if color_name.blank?
22
+
23
+ name = color_name.to_s.delete_prefix(":")
24
+ COLORS.dig(name, shade) || fallback
25
+ end
26
+
27
+ def self.normalize_color(value, default_name:)
28
+ return default_name.to_s if value.blank?
29
+ value.to_s.delete_prefix(":")
30
+ end
31
+
32
+ def self.hex?(value)
33
+ value.is_a?(String) && value.match?(/\A#(?:[0-9a-fA-F]{3}){1,2}\z/)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module UI
5
+ PanelDefinition = Struct.new(:type, :title, :options, keyword_init: true)
6
+ RowDefinition = Struct.new(:panels, keyword_init: true)
7
+
8
+ class DashboardDefinition
9
+ attr_reader :rows
10
+
11
+ def initialize
12
+ @rows = []
13
+ end
14
+ end
15
+
16
+ # DSL used inside `portal.dashboard do ... end`.
17
+ class DashboardDSL
18
+ def initialize(definition)
19
+ @definition = definition
20
+ end
21
+
22
+ def row(&block)
23
+ row = RowDefinition.new(panels: [])
24
+ RowDSL.new(row).instance_eval(&block) if block_given?
25
+ @definition.rows << row
26
+ row
27
+ end
28
+ end
29
+
30
+ # DSL used inside `row do ... end`.
31
+ class RowDSL
32
+ def initialize(row)
33
+ @row = row
34
+ end
35
+
36
+ def panel(type, title = nil, span: nil, **options, &block)
37
+ options[:span] = span if span
38
+ options[:block] = block if block_given?
39
+ @row.panels << PanelDefinition.new(type: type.to_sym, title: title, options: options)
40
+ end
41
+
42
+ def stat_panel(title, value = nil, span: nil, **options, &block)
43
+ value_proc = value.is_a?(Proc) ? value : (block_given? ? block : nil)
44
+ panel(:stat, title, span: span, **options.merge(value: value_proc || value))
45
+ end
46
+
47
+ def health_panel(title, status: nil, metrics: nil, span: nil, **options, &block)
48
+ panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
49
+ end
50
+
51
+ def chart_panel(title, data: nil, span: nil, **options, &block)
52
+ data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
53
+ panel(:chart, title, span: span, **options.merge(data: data_proc || data))
54
+ end
55
+
56
+ def cards_panel(title, resources: nil, span: nil, **options, &block)
57
+ panel(:cards, title, span: span, **options.merge(resources: resources, block: block))
58
+ end
59
+
60
+ def recent_panel(title, scope: nil, link: nil, span: nil, **options, &block)
61
+ panel(:recent, title, span: span, **options.merge(scope: scope, link: link, block: block))
62
+ end
63
+
64
+ def table_panel(title, rows: nil, columns: nil, span: nil, **options, &block)
65
+ panel(:table, title, span: span, **options.merge(rows: rows, columns: columns, block: block))
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module UI
5
+ module FieldRendererRegistry
6
+ class << self
7
+ def handlers
8
+ @handlers ||= {}
9
+ end
10
+
11
+ def register(type, &block)
12
+ handlers[type.to_sym] = block
13
+ end
14
+
15
+ def render(type, view:, f:, field:, resource:, field_class:)
16
+ handler = handlers[type.to_sym]
17
+ return nil unless handler
18
+
19
+ handler.call(view, f, field, resource, field_class)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ # ---- default field renderers ----
27
+ AdminSuite::UI::FieldRendererRegistry.register(:textarea) do |_view, f, field, resource, field_class|
28
+ f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
29
+ end
30
+
31
+ AdminSuite::UI::FieldRendererRegistry.register(:url) do |_view, f, field, resource, field_class|
32
+ f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
33
+ end
34
+
35
+ AdminSuite::UI::FieldRendererRegistry.register(:email) do |_view, f, field, resource, field_class|
36
+ f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
37
+ end
38
+
39
+ AdminSuite::UI::FieldRendererRegistry.register(:number) do |_view, f, field, resource, field_class|
40
+ f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
41
+ end
42
+
43
+ AdminSuite::UI::FieldRendererRegistry.register(:toggle) do |view, f, field, resource, _field_class|
44
+ view.render_toggle_field(f, field, resource)
45
+ end
46
+
47
+ AdminSuite::UI::FieldRendererRegistry.register(:label) do |view, _f, field, resource, _field_class|
48
+ label_value = resource.public_send(field.name) rescue nil
49
+ view.render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
50
+ end
51
+
52
+ AdminSuite::UI::FieldRendererRegistry.register(:select) do |_view, f, field, resource, field_class|
53
+ collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
54
+ f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
55
+ end
56
+
57
+ AdminSuite::UI::FieldRendererRegistry.register(:searchable_select) do |view, f, field, resource, _field_class|
58
+ view.render_searchable_select(f, field, resource)
59
+ end
60
+
61
+ AdminSuite::UI::FieldRendererRegistry.register(:multi_select) do |view, f, field, resource, _field_class|
62
+ view.render_multi_select(f, field, resource)
63
+ end
64
+
65
+ AdminSuite::UI::FieldRendererRegistry.register(:tags) do |view, f, field, resource, _field_class|
66
+ view.render_multi_select(f, field, resource)
67
+ end
68
+
69
+ AdminSuite::UI::FieldRendererRegistry.register(:image) do |view, f, field, resource, _field_class|
70
+ view.render_file_upload(f, field, resource)
71
+ end
72
+
73
+ AdminSuite::UI::FieldRendererRegistry.register(:attachment) do |view, f, field, resource, _field_class|
74
+ view.render_file_upload(f, field, resource)
75
+ end
76
+
77
+ AdminSuite::UI::FieldRendererRegistry.register(:trix) do |_view, f, field, resource, _field_class|
78
+ f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
79
+ end
80
+
81
+ AdminSuite::UI::FieldRendererRegistry.register(:rich_text) do |_view, f, field, resource, _field_class|
82
+ f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
83
+ end
84
+
85
+ AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |_view, f, field, resource, field_class|
86
+ f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
87
+ end
88
+
89
+ AdminSuite::UI::FieldRendererRegistry.register(:file) do |_view, f, field, resource, _field_class|
90
+ f.file_field(field.name, class: "form-input-file", accept: field.accept)
91
+ end
92
+
93
+ AdminSuite::UI::FieldRendererRegistry.register(:datetime) do |_view, f, field, resource, field_class|
94
+ f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
95
+ end
96
+
97
+ AdminSuite::UI::FieldRendererRegistry.register(:date) do |_view, f, field, resource, field_class|
98
+ f.date_field(field.name, class: field_class, readonly: field.readonly)
99
+ end
100
+
101
+ AdminSuite::UI::FieldRendererRegistry.register(:time) do |_view, f, field, resource, field_class|
102
+ f.time_field(field.name, class: field_class, readonly: field.readonly)
103
+ end
104
+
105
+ AdminSuite::UI::FieldRendererRegistry.register(:json) do |view, f, field, resource, _field_class|
106
+ view.render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
107
+ end
108
+
109
+ AdminSuite::UI::FieldRendererRegistry.register(:code) do |view, f, field, resource, _field_class|
110
+ view.render_code_editor(f, field, resource)
111
+ end
112
+
113
+ AdminSuite::UI::FieldRendererRegistry.register(:text) do |_view, f, field, resource, field_class|
114
+ f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
115
+ end
116
+
117
+ AdminSuite::UI::FieldRendererRegistry.register(:string) do |_view, f, field, resource, field_class|
118
+ f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
119
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "admin_suite/ui/field_renderer_registry"
4
+
5
+ module AdminSuite
6
+ module UI
7
+ # Overrides `render_form_field` to use a registry of field renderers,
8
+ # while leaving the legacy implementation available via `super`.
9
+ module FormFieldRenderer
10
+ def render_form_field(f, field, resource)
11
+ return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
12
+
13
+ return if field.if_condition.present? && !field.if_condition.call(resource)
14
+ return if field.unless_condition.present? && field.unless_condition.call(resource)
15
+
16
+ capture do
17
+ concat(content_tag(:div, class: "form-group") do
18
+ concat(f.label(field.name, class: "form-label") do
19
+ concat(field.label)
20
+ concat(content_tag(:span, " *", class: "text-red-500")) if field.required
21
+ end)
22
+
23
+ field_class = "form-input w-full"
24
+ field_class += " border-red-500" if resource.errors[field.name].any?
25
+
26
+ field_html =
27
+ AdminSuite::UI::FieldRendererRegistry.render(
28
+ field.type || :text,
29
+ view: self,
30
+ f: f,
31
+ field: field,
32
+ resource: resource,
33
+ field_class: field_class
34
+ )
35
+
36
+ # If the registry doesn't know how to render, fall back to legacy behavior.
37
+ return super if field_html.nil?
38
+
39
+ concat(field_html)
40
+
41
+ concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
42
+ 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?
43
+ end)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module UI
5
+ module ShowFormatterRegistry
6
+ class << self
7
+ def class_handlers
8
+ @class_handlers ||= {}
9
+ end
10
+
11
+ def default_handler
12
+ @default_handler
13
+ end
14
+
15
+ def register_class(klass, &block)
16
+ class_handlers[klass] = block
17
+ end
18
+
19
+ def register_default(&block)
20
+ @default_handler = block
21
+ end
22
+
23
+ def format(value, view:, record:, field_name:)
24
+ handler = class_handlers.find { |klass, _| value.is_a?(klass) }&.last
25
+ handler ||= default_handler
26
+ return nil unless handler
27
+
28
+ handler.call(value, view, record, field_name)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # ---- default show formatters ----
36
+ AdminSuite::UI::ShowFormatterRegistry.register_class(NilClass) do |_value, view, _record, _field|
37
+ view.content_tag(:span, "—", class: "text-slate-400")
38
+ end
39
+
40
+ AdminSuite::UI::ShowFormatterRegistry.register_class(TrueClass) do |_value, view, _record, _field|
41
+ view.content_tag(:span, class: "inline-flex items-center gap-1") do
42
+ view.concat(view.admin_suite_icon("check-circle-2", class: "w-4 h-4 text-green-500"))
43
+ view.concat(view.content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
44
+ end
45
+ end
46
+
47
+ AdminSuite::UI::ShowFormatterRegistry.register_class(FalseClass) do |_value, view, _record, _field|
48
+ view.content_tag(:span, class: "inline-flex items-center gap-1") do
49
+ view.concat(view.admin_suite_icon("x-circle", class: "w-4 h-4 text-slate-400"))
50
+ view.concat(view.content_tag(:span, "No", class: "text-slate-500"))
51
+ end
52
+ end
53
+
54
+ AdminSuite::UI::ShowFormatterRegistry.register_class(Time) do |value, view, _record, _field|
55
+ view.content_tag(:span, class: "inline-flex items-center gap-2") do
56
+ view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
57
+ view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
58
+ end
59
+ end
60
+
61
+ AdminSuite::UI::ShowFormatterRegistry.register_class(DateTime) do |value, view, _record, _field|
62
+ view.content_tag(:span, class: "inline-flex items-center gap-2") do
63
+ view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
64
+ view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
65
+ end
66
+ end
67
+
68
+ AdminSuite::UI::ShowFormatterRegistry.register_class(Date) do |value, _view, _record, _field|
69
+ value.strftime("%B %d, %Y")
70
+ end
71
+
72
+ if defined?(ActiveRecord::Base)
73
+ AdminSuite::UI::ShowFormatterRegistry.register_class(ActiveRecord::Base) do |value, view, _record, _field|
74
+ link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
75
+ view.content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
76
+ end
77
+ end
78
+
79
+ AdminSuite::UI::ShowFormatterRegistry.register_class(Hash) do |value, view, _record, _field|
80
+ view.render_json_block(value)
81
+ end
82
+
83
+ AdminSuite::UI::ShowFormatterRegistry.register_class(Array) do |value, view, _record, _field|
84
+ if value.empty?
85
+ view.content_tag(:span, "Empty array", class: "text-slate-400 italic")
86
+ elsif value.first.is_a?(Hash)
87
+ view.render_json_block(value)
88
+ else
89
+ view.content_tag(:div, class: "flex flex-wrap gap-1") do
90
+ value.each do |item|
91
+ view.concat(view.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"))
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ AdminSuite::UI::ShowFormatterRegistry.register_class(Integer) do |value, view, _record, _field|
98
+ view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
99
+ end
100
+
101
+ AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _record, _field|
102
+ view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
103
+ end
104
+
105
+ AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
106
+ value_str = value.to_s
107
+
108
+ if value_str.start_with?("{", "[") && value_str.length > 10
109
+ begin
110
+ parsed = JSON.parse(value_str)
111
+ view.render_json_block(parsed)
112
+ rescue JSON::ParserError
113
+ view.render_text_block(value_str)
114
+ end
115
+ elsif value_str.include?("\n") || value_str.length > 200
116
+ view.render_text_block(value_str, view.detect_language(field_name, value_str))
117
+ else
118
+ value_str
119
+ end
120
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "admin_suite/ui/show_formatter_registry"
4
+
5
+ module AdminSuite
6
+ module UI
7
+ # Overrides `format_show_value` to use a registry of show value formatters,
8
+ # while leaving the legacy implementation available via `super`.
9
+ module ShowValueFormatter
10
+ def format_show_value(record, field_name)
11
+ value = record.public_send(field_name) rescue nil
12
+
13
+ if (field_def = admin_suite_field_definition(field_name))
14
+ case field_def.type
15
+ when :markdown
16
+ rendered =
17
+ if defined?(::MarkdownRenderer)
18
+ ::MarkdownRenderer.render(value.to_s)
19
+ else
20
+ simple_format(value.to_s)
21
+ end
22
+ return content_tag(:div, rendered, class: "prose dark:prose-invert max-w-none")
23
+ when :json
24
+ begin
25
+ parsed =
26
+ if value.is_a?(Hash) || value.is_a?(Array)
27
+ value
28
+ elsif value.present?
29
+ JSON.parse(value.to_s)
30
+ end
31
+ return render_json_block(parsed) if parsed
32
+ rescue JSON::ParserError
33
+ # fall through
34
+ end
35
+ when :label
36
+ return render_label_badge(value, color: field_def.label_color, size: field_def.label_size, record: record)
37
+ end
38
+ end
39
+
40
+ # If the field isn't in the form config, fall back to index column config
41
+ # so show pages can still render labels consistently.
42
+ if respond_to?(:resource_config, true) && (rc = resource_config) && rc.index_config&.columns_list
43
+ col = rc.index_config.columns_list.find { |c| c.name.to_sym == field_name.to_sym }
44
+ if col&.type == :label
45
+ label_value = col.content.is_a?(Proc) ? col.content.call(record) : value
46
+ return render_label_badge(label_value, color: col.label_color, size: col.label_size, record: record)
47
+ end
48
+ end
49
+
50
+ if value.is_a?(ActiveStorage::Attached::One)
51
+ return render_attachment_preview(value)
52
+ elsif value.is_a?(ActiveStorage::Attached::Many)
53
+ return render_attachments_preview(value)
54
+ end
55
+
56
+ formatted =
57
+ AdminSuite::UI::ShowFormatterRegistry.format(
58
+ value,
59
+ view: self,
60
+ record: record,
61
+ field_name: field_name
62
+ )
63
+
64
+ return formatted unless formatted.nil?
65
+
66
+ super
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Version
5
+ VERSION = "0.1.0"
6
+ end
7
+
8
+ # Backward-compatible constant.
9
+ VERSION = Version::VERSION
10
+ end