admin_suite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +7 -0
- data/Rakefile +11 -0
- data/app/assets/admin_suite.css +444 -0
- data/app/assets/admin_suite_tailwind.css +8 -0
- data/app/assets/builds/admin_suite_tailwind.css +8 -0
- data/app/assets/rouge.css +218 -0
- data/app/assets/tailwind/admin_suite.css +22 -0
- data/app/controllers/admin_suite/application_controller.rb +118 -0
- data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
- data/app/controllers/admin_suite/docs_controller.rb +155 -0
- data/app/controllers/admin_suite/portals_controller.rb +22 -0
- data/app/controllers/admin_suite/resources_controller.rb +238 -0
- data/app/helpers/admin_suite/base_helper.rb +1199 -0
- data/app/helpers/admin_suite/icon_helper.rb +61 -0
- data/app/helpers/admin_suite/panels_helper.rb +52 -0
- data/app/helpers/admin_suite/resources_helper.rb +15 -0
- data/app/helpers/admin_suite/theme_helper.rb +99 -0
- data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
- data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
- data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
- data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
- data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
- data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
- data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
- data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
- data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
- data/app/views/admin_suite/dashboard/index.html.erb +21 -0
- data/app/views/admin_suite/docs/index.html.erb +86 -0
- data/app/views/admin_suite/panels/_cards.html.erb +107 -0
- data/app/views/admin_suite/panels/_chart.html.erb +47 -0
- data/app/views/admin_suite/panels/_health.html.erb +44 -0
- data/app/views/admin_suite/panels/_recent.html.erb +56 -0
- data/app/views/admin_suite/panels/_stat.html.erb +64 -0
- data/app/views/admin_suite/panels/_table.html.erb +36 -0
- data/app/views/admin_suite/portals/show.html.erb +75 -0
- data/app/views/admin_suite/resources/_form.html.erb +32 -0
- data/app/views/admin_suite/resources/edit.html.erb +24 -0
- data/app/views/admin_suite/resources/index.html.erb +315 -0
- data/app/views/admin_suite/resources/new.html.erb +22 -0
- data/app/views/admin_suite/resources/show.html.erb +184 -0
- data/app/views/admin_suite/shared/_flash.html.erb +30 -0
- data/app/views/admin_suite/shared/_form.html.erb +60 -0
- data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
- data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
- data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
- data/app/views/layouts/admin_suite/application.html.erb +79 -0
- data/lib/admin/base/action_executor.rb +155 -0
- data/lib/admin/base/action_handler.rb +31 -0
- data/lib/admin/base/filter_builder.rb +121 -0
- data/lib/admin/base/resource.rb +541 -0
- data/lib/admin_suite/configuration.rb +42 -0
- data/lib/admin_suite/engine.rb +101 -0
- data/lib/admin_suite/markdown_renderer.rb +115 -0
- data/lib/admin_suite/portal_definition.rb +64 -0
- data/lib/admin_suite/portal_registry.rb +32 -0
- data/lib/admin_suite/theme_palette.rb +36 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
- data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
- data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
- data/lib/admin_suite/version.rb +10 -0
- data/lib/admin_suite.rb +54 -0
- data/lib/generators/admin_suite/install/install_generator.rb +23 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
- data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
- data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
- data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
- data/lib/tasks/admin_suite_tailwind.rake +28 -0
- data/lib/tasks/admin_suite_test.rake +11 -0
- data/test/dummy/Gemfile +21 -0
- data/test/dummy/README.md +24 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/application_record.rb +2 -0
- data/test/dummy/app/views/layouts/application.html.erb +28 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/ci +6 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +35 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/ci.rb +19 -0
- data/test/dummy/config/database.yml +31 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +57 -0
- data/test/dummy/config/environments/production.rb +67 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +7 -0
- data/test/dummy/config/initializers/content_security_policy.rb +29 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +39 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config.ru +6 -0
- data/test/dummy/db/seeds.rb +9 -0
- data/test/dummy/log/test.log +441 -0
- data/test/dummy/public/400.html +135 -0
- data/test/dummy/public/404.html +135 -0
- data/test/dummy/public/406-unsupported-browser.html +135 -0
- data/test/dummy/public/422.html +135 -0
- data/test/dummy/public/500.html +135 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/public/robots.txt +1 -0
- data/test/dummy/test/test_helper.rb +15 -0
- data/test/dummy/tmp/local_secret.txt +1 -0
- data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
- data/test/integration/dashboard_test.rb +13 -0
- data/test/integration/docs_test.rb +46 -0
- data/test/integration/theme_test.rb +27 -0
- data/test/lib/markdown_renderer_test.rb +20 -0
- data/test/lib/theme_palette_test.rb +24 -0
- data/test/test_helper.rb +11 -0
- metadata +264 -0
|
@@ -0,0 +1,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
|
+
|