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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module IconHelper
5
+ # Renders an icon for AdminSuite using the configured renderer.
6
+ #
7
+ # Default behavior uses lucide-rails (LucideRails::IconProvider) if available.
8
+ # Back-compat: if `name` looks like raw SVG markup, it is returned as HTML safe.
9
+ #
10
+ # @param name [String, Symbol] icon name (e.g. "settings") OR raw svg string
11
+ # @param opts [Hash] passed to the underlying renderer (e.g. class:, stroke_width:)
12
+ # @return [ActiveSupport::SafeBuffer, String]
13
+ def admin_suite_icon(name, **opts)
14
+ return "".html_safe if name.blank?
15
+
16
+ raw = name.to_s
17
+ if raw.lstrip.start_with?("<svg")
18
+ return raw.html_safe
19
+ end
20
+
21
+ renderer = AdminSuite.config.icon_renderer
22
+ return renderer.call(raw, self, **opts) if renderer.respond_to?(:call)
23
+
24
+ # lucide-rails provides stripped SVG paths via IconProvider; we wrap them.
25
+ if defined?(::LucideRails::IconProvider)
26
+ default_class = "w-4 h-4"
27
+ css_class = [ default_class, opts[:class] ].compact.join(" ")
28
+ stroke_width = opts.fetch(:stroke_width, 2)
29
+ title = opts[:title]
30
+
31
+ begin
32
+ inner = ::LucideRails::IconProvider.icon(raw)
33
+ rescue ArgumentError
34
+ inner = nil
35
+ end
36
+
37
+ if inner.present?
38
+ return content_tag(
39
+ :svg,
40
+ (title.present? ? content_tag(:title, title) + inner.html_safe : inner.html_safe),
41
+ class: css_class,
42
+ xmlns: "http://www.w3.org/2000/svg",
43
+ width: "24",
44
+ height: "24",
45
+ viewBox: "0 0 24 24",
46
+ fill: "none",
47
+ stroke: "currentColor",
48
+ "stroke-width" => stroke_width,
49
+ "stroke-linecap" => "round",
50
+ "stroke-linejoin" => "round",
51
+ "aria-hidden" => "true",
52
+ focusable: "false"
53
+ )
54
+ end
55
+ end
56
+
57
+ # Safety fallback if lucide-rails isn't available in the host app for any reason.
58
+ content_tag(:span, "", class: opts[:class] || "inline-block w-4 h-4", title: raw)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module PanelsHelper
5
+ # Renders a portal dashboard rows grid.
6
+ #
7
+ # @param rows [Array<AdminSuite::UI::RowDefinition>]
8
+ def render_dashboard_rows(rows)
9
+ return "" if rows.blank?
10
+
11
+ content_tag(:div, class: "space-y-6") do
12
+ rows.each do |row|
13
+ concat(content_tag(:div, class: "grid grid-cols-1 lg:grid-cols-12 gap-6") do
14
+ Array(row.panels).each do |panel|
15
+ concat(render_panel(panel))
16
+ end
17
+ end)
18
+ end
19
+ end
20
+ end
21
+
22
+ # Renders a single panel by selecting a partial.
23
+ #
24
+ # Host apps can override by setting `AdminSuite.config.partials[:panel_<type>]`.
25
+ #
26
+ # @param panel [AdminSuite::UI::PanelDefinition]
27
+ def render_panel(panel)
28
+ type = panel.type.to_sym
29
+ override = AdminSuite.config.partials[:"panel_#{type}"] rescue nil
30
+ partial = override.presence || "admin_suite/panels/#{type}"
31
+ span = (panel.options[:span] || 12).to_i
32
+ span = 12 if span < 1
33
+ span = 12 if span > 12
34
+
35
+ # Avoid dynamic Tailwind class generation (e.g. `lg:col-span-#{span}`),
36
+ # which would otherwise require a safelist.
37
+ content_tag(:div, style: "grid-column: span #{span} / span #{span};") do
38
+ render partial:, locals: { panel: panel }
39
+ end
40
+ end
41
+
42
+ # Evaluates a panel option, calling Procs if needed.
43
+ #
44
+ # @param value [Object, Proc]
45
+ # @return [Object]
46
+ def panel_eval(value)
47
+ return value.call if value.is_a?(Proc) && value.arity == 0
48
+ return value.call(self) if value.is_a?(Proc) && value.arity == 1
49
+ value
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module ResourcesHelper
5
+ # Intentionally empty.
6
+ #
7
+ # `AdminSuite::ApplicationController` installs `AdminSuite::BaseHelper` for all
8
+ # engine views. That helper provides rich rendering for:
9
+ # - index column types (e.g. `:toggle`, `:label`)
10
+ # - show formatters (markdown/json/code/attachments)
11
+ #
12
+ # Defining `render_column_value` / `format_show_value` here would override
13
+ # those implementations and silently break functionality.
14
+ end
15
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module ThemeHelper
5
+ def admin_suite_theme
6
+ (AdminSuite.config.theme || {}).symbolize_keys
7
+ rescue StandardError
8
+ {}
9
+ end
10
+
11
+ def theme_primary
12
+ admin_suite_theme[:primary]
13
+ end
14
+
15
+ def theme_secondary
16
+ admin_suite_theme[:secondary]
17
+ end
18
+
19
+ # Returns a <style> tag that scopes theme variables to AdminSuite.
20
+ #
21
+ # This is the core of engine-build mode theming: UI classes stay static
22
+ # (no `bg-#{...}`), and color changes are driven by CSS variables.
23
+ def admin_suite_theme_style_tag
24
+ theme = admin_suite_theme
25
+
26
+ primary = theme[:primary]
27
+ secondary = theme[:secondary]
28
+
29
+ primary_name =
30
+ if AdminSuite::ThemePalette.hex?(primary)
31
+ nil
32
+ else
33
+ AdminSuite::ThemePalette.normalize_color(primary, default_name: :indigo)
34
+ end
35
+
36
+ secondary_name =
37
+ if AdminSuite::ThemePalette.hex?(secondary)
38
+ nil
39
+ else
40
+ AdminSuite::ThemePalette.normalize_color(secondary, default_name: :purple)
41
+ end
42
+
43
+ # Primary variables
44
+ primary_600 = AdminSuite::ThemePalette.hex?(primary) ? primary : AdminSuite::ThemePalette.resolve(primary_name, 600, fallback: "#4f46e5")
45
+ primary_700 = AdminSuite::ThemePalette.hex?(primary) ? primary : AdminSuite::ThemePalette.resolve(primary_name, 700, fallback: "#4338ca")
46
+
47
+ # Sidebar gradient variables (dark shades)
48
+ sidebar_from = AdminSuite::ThemePalette.resolve(primary_name || "indigo", 900, fallback: "#312e81")
49
+ sidebar_via = AdminSuite::ThemePalette.resolve(primary_name || "indigo", 800, fallback: "#3730a3")
50
+ sidebar_to =
51
+ if AdminSuite::ThemePalette.hex?(secondary)
52
+ secondary
53
+ else
54
+ AdminSuite::ThemePalette.resolve(secondary_name || "purple", 900, fallback: "#581c87")
55
+ end
56
+
57
+ css = <<~CSS
58
+ body.admin-suite {
59
+ --admin-suite-primary: #{primary_600};
60
+ --admin-suite-primary-hover: #{primary_700};
61
+ --admin-suite-sidebar-from: #{sidebar_from};
62
+ --admin-suite-sidebar-via: #{sidebar_via};
63
+ --admin-suite-sidebar-to: #{sidebar_to};
64
+ }
65
+ CSS
66
+
67
+ content_tag(:style, css.html_safe)
68
+ end
69
+
70
+ def theme_link_class
71
+ "admin-suite-link"
72
+ end
73
+
74
+ def theme_link_hover_text_class
75
+ "admin-suite-link-hover"
76
+ end
77
+
78
+ def theme_btn_primary_class
79
+ "admin-suite-btn-primary"
80
+ end
81
+
82
+ def theme_btn_primary_small_class
83
+ "admin-suite-btn-primary admin-suite-btn-primary--sm"
84
+ end
85
+
86
+ def theme_badge_primary_class
87
+ "admin-suite-badge-primary"
88
+ end
89
+
90
+ def theme_focus_ring_class
91
+ "admin-suite-focus-ring"
92
+ end
93
+
94
+ def theme_sidebar_gradient_class
95
+ # Deprecated: gradient is now CSS-variable driven (see `admin_suite_theme_style_tag`).
96
+ ""
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Utility controller for common click/change actions (Admin Suite).
4
+ export default class extends Controller {
5
+ static values = {
6
+ modalId: String,
7
+ url: String,
8
+ inputId: String,
9
+ fallbackUrl: String,
10
+ }
11
+
12
+ openModal(event) {
13
+ event.preventDefault()
14
+ const modal = document.getElementById(this.modalIdValue)
15
+ if (modal) {
16
+ modal.dispatchEvent(new CustomEvent("modal:show"))
17
+ } else if (this.hasFallbackUrlValue) {
18
+ window.location.href = this.fallbackUrlValue
19
+ }
20
+ }
21
+
22
+ closeModal(event) {
23
+ event.preventDefault()
24
+ const modal = document.getElementById(this.modalIdValue)
25
+ if (modal) {
26
+ modal.dispatchEvent(new CustomEvent("modal:hide"))
27
+ }
28
+ }
29
+
30
+ hideModal(event) {
31
+ event.preventDefault()
32
+ const modal = document.getElementById(this.modalIdValue)
33
+ if (modal) {
34
+ modal.classList.add("hidden")
35
+ }
36
+ }
37
+
38
+ navigate(event) {
39
+ const clickedInteractive = event.target.closest(
40
+ "a, button, input, select, textarea, [data-action]",
41
+ )
42
+ if (clickedInteractive && clickedInteractive !== this.element) {
43
+ return
44
+ }
45
+ if (this.hasUrlValue) {
46
+ window.location.href = this.urlValue
47
+ }
48
+ }
49
+
50
+ redirectToValue(event) {
51
+ const value = event.target.value
52
+ if (value) {
53
+ window.location.href = value
54
+ }
55
+ }
56
+
57
+ clearAndSubmit(event) {
58
+ event.preventDefault()
59
+ const input = document.getElementById(this.inputIdValue)
60
+ if (input) {
61
+ input.value = ""
62
+ const form = input.closest("form")
63
+ if (form) {
64
+ form.requestSubmit()
65
+ }
66
+ }
67
+ }
68
+
69
+ stopPropagation(event) {
70
+ event.stopPropagation()
71
+ }
72
+ }
73
+
@@ -0,0 +1,57 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Clipboard controller (Admin Suite) for copying text to clipboard.
4
+ export default class extends Controller {
5
+ static values = {
6
+ text: String,
7
+ }
8
+
9
+ async copy(event) {
10
+ event.preventDefault()
11
+
12
+ try {
13
+ await navigator.clipboard.writeText(this.textValue)
14
+ this.showFeedback("Copied!")
15
+ } catch (_err) {
16
+ this.fallbackCopy(this.textValue)
17
+ }
18
+ }
19
+
20
+ fallbackCopy(text) {
21
+ const textarea = document.createElement("textarea")
22
+ textarea.value = text
23
+ textarea.style.position = "fixed"
24
+ textarea.style.opacity = "0"
25
+ document.body.appendChild(textarea)
26
+ textarea.select()
27
+
28
+ try {
29
+ document.execCommand("copy")
30
+ this.showFeedback("Copied!")
31
+ } catch (_err) {
32
+ this.showFeedback("Failed to copy")
33
+ }
34
+
35
+ document.body.removeChild(textarea)
36
+ }
37
+
38
+ showFeedback(message) {
39
+ const tooltip = document.createElement("div")
40
+ tooltip.textContent = message
41
+ tooltip.className =
42
+ "fixed z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded shadow-lg pointer-events-none transition-opacity duration-200"
43
+
44
+ const rect = this.element.getBoundingClientRect()
45
+ tooltip.style.top = `${rect.top - 30}px`
46
+ tooltip.style.left = `${rect.left + rect.width / 2}px`
47
+ tooltip.style.transform = "translateX(-50%)"
48
+
49
+ document.body.appendChild(tooltip)
50
+
51
+ setTimeout(() => {
52
+ tooltip.style.opacity = "0"
53
+ setTimeout(() => tooltip.remove(), 200)
54
+ }, 1000)
55
+ }
56
+ }
57
+
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Code Editor Controller (Admin Suite)
5
+ *
6
+ * Lightweight fallback: keeps a monospace textarea but adds
7
+ * - tab indentation support
8
+ * - auto-resize (optional)
9
+ *
10
+ * If a host app wants a real editor (CodeMirror/Monaco), it can override by
11
+ * replacing this controller via importmap pinning.
12
+ */
13
+ export default class extends Controller {
14
+ static targets = ["textarea"]
15
+
16
+ connect() {
17
+ if (!this.hasTextareaTarget) return
18
+
19
+ this.onKeydown = this.onKeydown.bind(this)
20
+ this.textareaTarget.addEventListener("keydown", this.onKeydown)
21
+ }
22
+
23
+ disconnect() {
24
+ if (!this.hasTextareaTarget) return
25
+ this.textareaTarget.removeEventListener("keydown", this.onKeydown)
26
+ }
27
+
28
+ onKeydown(event) {
29
+ if (event.key !== "Tab") return
30
+
31
+ event.preventDefault()
32
+ const el = this.textareaTarget
33
+ const start = el.selectionStart
34
+ const end = el.selectionEnd
35
+ const value = el.value
36
+
37
+ // Insert two spaces on tab.
38
+ el.value = value.substring(0, start) + " " + value.substring(end)
39
+ el.selectionStart = el.selectionEnd = start + 2
40
+
41
+ // Keep Rails form dirty tracking happy.
42
+ el.dispatchEvent(new Event("input", { bubbles: true }))
43
+ }
44
+ }
45
+
@@ -0,0 +1,238 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * File Upload Controller (Admin Suite)
5
+ */
6
+ export default class extends Controller {
7
+ static targets = ["input", "filename", "dropzone", "imagePreview", "progress", "removeButton"]
8
+
9
+ static values = {
10
+ accept: { type: String, default: "" },
11
+ maxSize: { type: Number, default: 10485760 },
12
+ preview: { type: Boolean, default: false },
13
+ multiple: { type: Boolean, default: false },
14
+ existingUrl: String,
15
+ }
16
+
17
+ connect() {
18
+ this.setupDropZone()
19
+
20
+ if (this.existingUrlValue && this.hasImagePreviewTarget) {
21
+ this.showExistingPreview()
22
+ }
23
+ }
24
+
25
+ disconnect() {
26
+ if (this.dropZoneElement) {
27
+ this.dropZoneElement.removeEventListener("dragover", this.handleDragOver)
28
+ this.dropZoneElement.removeEventListener("dragleave", this.handleDragLeave)
29
+ this.dropZoneElement.removeEventListener("drop", this.handleDrop)
30
+ }
31
+ }
32
+
33
+ setupDropZone() {
34
+ this.dropZoneElement = this.hasDropzoneTarget ? this.dropzoneTarget : this.element
35
+
36
+ this.handleDragOver = this.onDragOver.bind(this)
37
+ this.handleDragLeave = this.onDragLeave.bind(this)
38
+ this.handleDrop = this.onDrop.bind(this)
39
+
40
+ this.dropZoneElement.addEventListener("dragover", this.handleDragOver)
41
+ this.dropZoneElement.addEventListener("dragleave", this.handleDragLeave)
42
+ this.dropZoneElement.addEventListener("drop", this.handleDrop)
43
+ }
44
+
45
+ preview(event) {
46
+ const files = event.target.files
47
+ if (files.length > 0) {
48
+ this.processFiles(files)
49
+ }
50
+ }
51
+
52
+ onDragOver(event) {
53
+ event.preventDefault()
54
+ event.stopPropagation()
55
+ this.dropZoneElement.classList.add("border-amber-500", "bg-amber-50", "dark:bg-amber-900/10")
56
+ }
57
+
58
+ onDragLeave(event) {
59
+ event.preventDefault()
60
+ event.stopPropagation()
61
+ this.dropZoneElement.classList.remove("border-amber-500", "bg-amber-50", "dark:bg-amber-900/10")
62
+ }
63
+
64
+ onDrop(event) {
65
+ event.preventDefault()
66
+ event.stopPropagation()
67
+ this.dropZoneElement.classList.remove("border-amber-500", "bg-amber-50", "dark:bg-amber-900/10")
68
+
69
+ const files = event.dataTransfer.files
70
+ if (files.length > 0) {
71
+ this.processFiles(files)
72
+ }
73
+ }
74
+
75
+ processFiles(files) {
76
+ const file = files[0]
77
+
78
+ if (!this.validateType(file)) {
79
+ this.showError(`Invalid file type. Allowed: ${this.acceptValue || "all files"}`)
80
+ return
81
+ }
82
+
83
+ if (!this.validateSize(file)) {
84
+ this.showError(`File too large. Maximum size: ${this.formatFileSize(this.maxSizeValue)}`)
85
+ return
86
+ }
87
+
88
+ if (this.hasInputTarget && !this.inputTarget.files.length) {
89
+ const dataTransfer = new DataTransfer()
90
+ dataTransfer.items.add(file)
91
+ this.inputTarget.files = dataTransfer.files
92
+ }
93
+
94
+ this.showFileInfo(file)
95
+
96
+ if (this.previewValue && this.isImage(file)) {
97
+ this.showImagePreview(file)
98
+ }
99
+
100
+ if (this.hasRemoveButtonTarget) {
101
+ this.removeButtonTarget.classList.remove("hidden")
102
+ }
103
+
104
+ this.dispatch("select", { detail: { file } })
105
+ }
106
+
107
+ validateType(file) {
108
+ if (!this.acceptValue) return true
109
+
110
+ const acceptTypes = this.acceptValue.split(",").map((t) => t.trim())
111
+
112
+ return acceptTypes.some((type) => {
113
+ if (type === "*/*") return true
114
+ if (type.endsWith("/*")) {
115
+ const category = type.replace("/*", "")
116
+ return file.type.startsWith(category)
117
+ }
118
+ if (type.startsWith(".")) {
119
+ return file.name.toLowerCase().endsWith(type.toLowerCase())
120
+ }
121
+ return file.type === type
122
+ })
123
+ }
124
+
125
+ validateSize(file) {
126
+ return file.size <= this.maxSizeValue
127
+ }
128
+
129
+ isImage(file) {
130
+ return file.type.startsWith("image/")
131
+ }
132
+
133
+ showFileInfo(file) {
134
+ if (!this.hasFilenameTarget) return
135
+
136
+ const fileName = file.name
137
+ const fileSize = this.formatFileSize(file.size)
138
+
139
+ this.filenameTarget.innerHTML = `
140
+ <div class="flex items-center gap-2">
141
+ <svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
142
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
143
+ </svg>
144
+ <span class="font-medium text-slate-900 dark:text-white">${fileName}</span>
145
+ <span class="text-slate-500 dark:text-slate-400">(${fileSize})</span>
146
+ </div>
147
+ `
148
+ }
149
+
150
+ showImagePreview(file) {
151
+ if (!this.hasImagePreviewTarget) return
152
+
153
+ const reader = new FileReader()
154
+ reader.onload = (e) => {
155
+ this.imagePreviewTarget.src = e.target.result
156
+ this.imagePreviewTarget.classList.remove("hidden")
157
+ }
158
+ reader.readAsDataURL(file)
159
+ }
160
+
161
+ showExistingPreview() {
162
+ if (this.hasImagePreviewTarget && this.existingUrlValue) {
163
+ this.imagePreviewTarget.src = this.existingUrlValue
164
+ this.imagePreviewTarget.classList.remove("hidden")
165
+ }
166
+
167
+ if (this.hasRemoveButtonTarget) {
168
+ this.removeButtonTarget.classList.remove("hidden")
169
+ }
170
+ }
171
+
172
+ showError(message) {
173
+ if (!this.hasFilenameTarget) return
174
+
175
+ this.filenameTarget.innerHTML = `
176
+ <div class="flex items-center gap-2 text-red-600 dark:text-red-400">
177
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
178
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
179
+ </svg>
180
+ <span>${message}</span>
181
+ </div>
182
+ `
183
+
184
+ if (this.hasInputTarget) {
185
+ this.inputTarget.value = ""
186
+ }
187
+ }
188
+
189
+ showProgress(percent) {
190
+ if (!this.hasProgressTarget) return
191
+
192
+ this.progressTarget.classList.remove("hidden")
193
+ this.progressTarget.innerHTML = `
194
+ <div class="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2">
195
+ <div class="bg-amber-500 h-2 rounded-full transition-all duration-300" style="width: ${percent}%"></div>
196
+ </div>
197
+ <span class="text-xs text-slate-500 dark:text-slate-400">${percent}%</span>
198
+ `
199
+ }
200
+
201
+ hideProgress() {
202
+ if (this.hasProgressTarget) {
203
+ this.progressTarget.classList.add("hidden")
204
+ }
205
+ }
206
+
207
+ remove() {
208
+ if (this.hasInputTarget) {
209
+ this.inputTarget.value = ""
210
+ }
211
+
212
+ if (this.hasFilenameTarget) {
213
+ this.filenameTarget.innerHTML = `
214
+ <span class="text-slate-500 dark:text-slate-400">No file selected</span>
215
+ `
216
+ }
217
+
218
+ if (this.hasImagePreviewTarget) {
219
+ this.imagePreviewTarget.src = ""
220
+ this.imagePreviewTarget.classList.add("hidden")
221
+ }
222
+
223
+ if (this.hasRemoveButtonTarget) {
224
+ this.removeButtonTarget.classList.add("hidden")
225
+ }
226
+
227
+ this.dispatch("remove")
228
+ }
229
+
230
+ formatFileSize(bytes) {
231
+ if (bytes === 0) return "0 Bytes"
232
+ const k = 1024
233
+ const sizes = ["Bytes", "KB", "MB", "GB"]
234
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
235
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
236
+ }
237
+ }
238
+