panda-core 0.4.1 → 0.7.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/tailwind/application.css +95 -0
  3. data/app/assets/tailwind/tailwind.config.js +8 -0
  4. data/app/builders/panda/core/form_builder.rb +163 -11
  5. data/app/components/panda/core/UI/button.rb +45 -24
  6. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  7. data/app/components/panda/core/admin/button_component.rb +27 -12
  8. data/app/components/panda/core/admin/container_component.rb +40 -5
  9. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  10. data/app/components/panda/core/admin/flash_message_component.rb +54 -36
  11. data/app/components/panda/core/admin/heading_component.rb +28 -19
  12. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  13. data/app/components/panda/core/admin/panel_component.rb +1 -1
  14. data/app/components/panda/core/admin/slideover_component.rb +92 -4
  15. data/app/components/panda/core/admin/table_component.rb +11 -11
  16. data/app/components/panda/core/admin/tag_component.rb +39 -2
  17. data/app/components/panda/core/admin/user_display_component.rb +4 -5
  18. data/app/controllers/panda/core/admin/my_profile_controller.rb +10 -3
  19. data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
  20. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  21. data/app/helpers/panda/core/asset_helper.rb +33 -5
  22. data/app/helpers/panda/core/sessions_helper.rb +26 -1
  23. data/app/javascript/panda/core/application.js +8 -1
  24. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  25. data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
  26. data/app/javascript/panda/core/controllers/index.js +9 -3
  27. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
  28. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  29. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  30. data/app/models/panda/core/user.rb +60 -6
  31. data/app/services/panda/core/attach_avatar_service.rb +71 -0
  32. data/app/views/layouts/panda/core/admin.html.erb +39 -14
  33. data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
  34. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -1
  35. data/app/views/panda/core/admin/dashboard/show.html.erb +3 -3
  36. data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
  37. data/app/views/panda/core/admin/sessions/new.html.erb +3 -4
  38. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
  39. data/app/views/panda/core/admin/shared/_sidebar.html.erb +69 -14
  40. data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
  41. data/config/importmap.rb +20 -7
  42. data/config/routes.rb +10 -1
  43. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  44. data/lib/panda/core/asset_loader.rb +5 -2
  45. data/lib/panda/core/engine.rb +38 -28
  46. data/lib/panda/core/oauth_providers.rb +3 -3
  47. data/lib/panda/core/services/base_service.rb +19 -4
  48. data/lib/panda/core/version.rb +1 -1
  49. data/lib/panda/core.rb +1 -0
  50. data/lib/tasks/panda_core_users.rake +158 -0
  51. metadata +13 -69
  52. data/lib/generators/panda/core/authentication/templates/reek_spec.rb +0 -43
  53. data/lib/generators/panda/core/dev_tools/USAGE +0 -24
  54. data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +0 -13
  55. data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +0 -18
  56. data/lib/generators/panda/core/dev_tools_generator.rb +0 -143
  57. data/lib/generators/panda/core/install_generator.rb +0 -41
  58. data/lib/generators/panda/core/templates/README +0 -25
  59. data/lib/generators/panda/core/templates/initializer.rb +0 -44
  60. data/lib/generators/panda/core/templates_generator.rb +0 -27
  61. data/lib/panda/core/testing/capybara_config.rb +0 -70
  62. data/lib/panda/core/testing/omniauth_helpers.rb +0 -52
  63. data/lib/panda/core/testing/rspec_config.rb +0 -72
@@ -5,13 +5,101 @@ module Panda
5
5
  module Admin
6
6
  class SlideoverComponent < Panda::Core::Base
7
7
  prop :title, String, default: "Settings"
8
+ prop :open, _Nilable(_Boolean), default: -> { false }
8
9
 
9
10
  def view_template(&block)
10
- # Set content_for equivalents that can be accessed by the layout
11
- helpers.content_for(:sidebar_title) { title }
12
- helpers.content_for(:sidebar) do
13
- aside(class: "hidden overflow-y-auto w-96 h-full bg-white lg:block", &block)
11
+ # Capture block content
12
+ if block_given?
13
+ if defined?(view_context) && view_context
14
+ @content_html = view_context.capture(&block)
15
+ else
16
+ @content_block = block
17
+ end
14
18
  end
19
+
20
+ div(
21
+ **default_attrs,
22
+ data: {
23
+ toggle_target: "toggleable",
24
+ transition_enter: "transform transition ease-in-out duration-500 sm:duration-700",
25
+ transition_enter_from: "translate-x-full",
26
+ transition_enter_to: "translate-x-0",
27
+ transition_leave: "transform transition ease-in-out duration-500 sm:duration-700",
28
+ transition_leave_from: "translate-x-0",
29
+ transition_leave_to: "translate-x-full"
30
+ }
31
+ ) do
32
+ # Main container
33
+ div(class: "relative flex h-full flex-col bg-white shadow-xl dark:bg-gray-800") do
34
+ # Header with title and close button
35
+ div(class: "bg-gradient-admin px-4 py-6 sm:px-6") do
36
+ div(class: "flex items-center justify-between") do
37
+ h2(class: "text-base font-semibold text-white", id: "slideover-title") do
38
+ plain @title
39
+ end
40
+ div(class: "ml-3 flex h-7 items-center") do
41
+ button(
42
+ type: "button",
43
+ data: {action: "click->toggle#toggle touch->toggle#toggle"},
44
+ class: "relative rounded-md text-white/80 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
45
+ ) do
46
+ span(class: "absolute -inset-2.5")
47
+ span(class: "sr-only") { "Close panel" }
48
+ # SVG close icon
49
+ svg(viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "1.5", aria_hidden: "true", class: "size-6") do
50
+ path(d: "M6 18 18 6M6 6l12 12", stroke_linecap: "round", stroke_linejoin: "round")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # Scrollable content area
58
+ div(class: "flex-1 overflow-y-auto") do
59
+ if @content_html
60
+ raw(@content_html)
61
+ elsif @content_block
62
+ instance_eval(@content_block)
63
+ end
64
+ end
65
+
66
+ # Sticky footer (if footer content exists)
67
+ if @footer_html || @footer_block
68
+ div(class: "flex shrink-0 justify-end gap-x-3 border-t border-gray-200 px-4 py-4 dark:border-white/10") do
69
+ if @footer_html
70
+ raw(@footer_html)
71
+ elsif @footer_block
72
+ instance_eval(&@footer_block)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def footer(&block)
81
+ if defined?(view_context) && view_context
82
+ @footer_html = view_context.capture(&block)
83
+ else
84
+ @footer_block = block
85
+ end
86
+ end
87
+
88
+ alias_method :with_footer, :footer
89
+
90
+ private
91
+
92
+ def default_attrs
93
+ {
94
+ id: "slideover",
95
+ class: slideover_classes
96
+ }
97
+ end
98
+
99
+ def slideover_classes
100
+ base = "ml-auto block size-full max-w-md transform absolute right-0 h-full z-50"
101
+ visibility = @open ? "" : "hidden"
102
+ [base, visibility].compact.join(" ")
15
103
  end
16
104
  end
17
105
  end
@@ -6,6 +6,7 @@ module Panda
6
6
  class TableComponent < Panda::Core::Base
7
7
  prop :term, String
8
8
  prop :rows, _Nilable(Object), default: -> { [] }
9
+ prop :icon, String, default: ""
9
10
 
10
11
  attr_reader :columns
11
12
 
@@ -25,25 +26,22 @@ module Panda
25
26
  end
26
27
  end
27
28
 
28
- def column(label, &cell_block)
29
- @columns << Column.new(label, &cell_block)
29
+ def column(label, width: nil, &cell_block)
30
+ @columns << Column.new(label, width, &cell_block)
30
31
  end
31
32
 
32
33
  private
33
34
 
34
35
  def render_table_with_rows
35
- div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark") do
36
+ div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark", style: "table-layout: fixed;") do
36
37
  render_header
37
38
  render_rows
38
39
  end
39
40
  end
40
41
 
41
42
  def render_empty_table
42
- div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark") do
43
- render_header
44
- end
45
-
46
- div(class: "text-center mx-12 block border border-dashed py-12 rounded-lg") do
43
+ div(class: "text-center block border border-dashed py-12 rounded-lg") do
44
+ i(class: "#{@icon} text-4xl text-gray-400 mb-3") if @icon.present?
47
45
  h3(class: "py-1 text-xl font-semibold text-gray-900") { "No #{@term.pluralize}" }
48
46
  p(class: "py-1 text-base text-gray-500") { "Get started by creating a new #{@term}." }
49
47
  end
@@ -57,7 +55,8 @@ module Panda
57
55
  header_classes += " rounded-tl-md" if i.zero?
58
56
  header_classes += " rounded-tr-md" if i == @columns.size - 1
59
57
 
60
- div(class: header_classes) { column.label }
58
+ header_style = column.width ? "width: #{column.width};" : nil
59
+ div(class: header_classes, style: header_style) { column.label }
61
60
  end
62
61
  end
63
62
  end
@@ -106,10 +105,11 @@ module Panda
106
105
  end
107
106
 
108
107
  class Column
109
- attr_reader :label, :cell
108
+ attr_reader :label, :cell, :width
110
109
 
111
- def initialize(label, &block)
110
+ def initialize(label, width = nil, &block)
112
111
  @label = label
112
+ @width = width
113
113
  @cell = block
114
114
  end
115
115
  end
@@ -6,6 +6,7 @@ module Panda
6
6
  class TagComponent < Panda::Core::Base
7
7
  prop :status, Symbol, default: :active
8
8
  prop :text, _Nilable(String), default: -> {}
9
+ prop :page_type, _Nilable(Symbol), default: -> {}
9
10
 
10
11
  def view_template
11
12
  span(class: tag_classes) { computed_text }
@@ -14,12 +15,44 @@ module Panda
14
15
  private
15
16
 
16
17
  def computed_text
17
- @text || @status.to_s.humanize
18
+ if @page_type
19
+ @text || type_display_text
20
+ else
21
+ @text || @status.to_s.humanize
22
+ end
23
+ end
24
+
25
+ def type_display_text
26
+ case @page_type
27
+ when :standard
28
+ "Active"
29
+ when :hidden_type
30
+ "Hidden"
31
+ else
32
+ @page_type.to_s.humanize
33
+ end
18
34
  end
19
35
 
20
36
  def tag_classes
21
37
  base = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
22
- base + status_classes
38
+ base + (@page_type ? type_classes : status_classes)
39
+ end
40
+
41
+ def type_classes
42
+ case @page_type
43
+ when :system
44
+ "text-red-700 bg-red-100 ring-red-600/20 dark:bg-red-400/10 dark:text-red-400"
45
+ when :posts
46
+ "text-purple-700 bg-purple-100 ring-purple-600/20 dark:bg-purple-400/10 dark:text-purple-400"
47
+ when :code
48
+ "text-blue-700 bg-blue-100 ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400"
49
+ when :standard
50
+ "text-green-700 bg-green-100 ring-green-600/20 dark:bg-green-400/10 dark:text-green-400"
51
+ when :hidden_type
52
+ "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
53
+ else
54
+ "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
55
+ end
23
56
  end
24
57
 
25
58
  def status_classes
@@ -30,6 +63,10 @@ module Panda
30
63
  "text-black ring-black/30 bg-yellow-400"
31
64
  when :inactive, :hidden
32
65
  "text-black ring-black/30 bg-black/5 bg-white"
66
+ when :auto
67
+ "text-blue-700 bg-blue-100 ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400"
68
+ when :static
69
+ "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
33
70
  else
34
71
  "text-black bg-white"
35
72
  end
@@ -30,15 +30,14 @@ module Panda
30
30
  end
31
31
 
32
32
  def render_avatar
33
- has_image = resolved_user.respond_to?(:image_url) &&
34
- resolved_user.image_url.present? &&
35
- !resolved_user.image_url.empty?
33
+ has_image = resolved_user.respond_to?(:avatar_url) &&
34
+ resolved_user.avatar_url.present?
36
35
 
37
36
  if has_image
38
37
  div do
39
38
  img(
40
- class: "inline-block w-10 h-10 rounded-full",
41
- src: resolved_user.image_url,
39
+ class: "inline-block w-10 h-10 rounded-full object-cover",
40
+ src: resolved_user.avatar_url,
42
41
  alt: ""
43
42
  )
44
43
  end
@@ -4,7 +4,14 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class MyProfileController < BaseController
7
- before_action :set_initial_breadcrumb, only: %i[edit update]
7
+ before_action :set_initial_breadcrumb, only: %i[show edit update]
8
+
9
+ # Redirects to the edit form
10
+ # @type GET
11
+ # @return void
12
+ def show
13
+ redirect_to edit_admin_my_profile_path
14
+ end
8
15
 
9
16
  # Shows the edit form for the current user's profile
10
17
  # @type GET
@@ -21,7 +28,7 @@ module Panda
21
28
  flash[:success] = "Your profile has been updated successfully."
22
29
  redirect_to edit_admin_my_profile_path
23
30
  else
24
- render :edit, locals: {user: current_user}, status: :unprocessable_entity
31
+ render :edit, locals: {user: current_user}, status: :unprocessable_content
25
32
  end
26
33
  end
27
34
 
@@ -36,7 +43,7 @@ module Panda
36
43
  # @return ActionController::StrongParameters
37
44
  def user_params
38
45
  # Base parameters that Core always allows
39
- base_params = [:name, :email, :current_theme]
46
+ base_params = [:name, :email, :current_theme, :avatar]
40
47
 
41
48
  # Allow additional params from configuration
42
49
  additional_params = Core.config.additional_user_params || []
@@ -30,7 +30,9 @@ module Panda
30
30
  if user.persisted?
31
31
  # Check if user is admin before allowing access
32
32
  unless user.admin?
33
- redirect_to admin_login_path, flash: {error: "You do not have permission to access the admin area"}
33
+ flash[:error] = "You do not have permission to access the admin area"
34
+ flash.keep(:error) if Rails.env.test?
35
+ redirect_to admin_login_path
34
36
  return
35
37
  end
36
38
 
@@ -58,7 +60,9 @@ module Panda
58
60
  strategy = params[:strategy] || "unknown"
59
61
 
60
62
  Rails.logger.error "OmniAuth failure: strategy=#{strategy}, message=#{message}"
61
- redirect_to admin_login_path, flash: {error: "Authentication failed: #{message}"}
63
+ flash[:error] = "Authentication failed: #{message}"
64
+ flash.keep(:error) if Rails.env.test?
65
+ redirect_to admin_login_path
62
66
  end
63
67
 
64
68
  def destroy
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Test-only controller for setting up authentication in system tests
7
+ # This bypasses OAuth to avoid cross-process issues with Capybara
8
+ # Security: This route is only defined in test environments, never in production
9
+ #
10
+ # Usage in tests:
11
+ # post "/admin/test_sessions", params: { user_id: user.id }
12
+ # post "/admin/test_sessions", params: { user_id: user.id, return_to: "/some/path" }
13
+ class TestSessionsController < ActionController::Base
14
+ # Enable CSRF protection for consistency, then skip for test-only endpoint
15
+ protect_from_forgery with: :exception
16
+ skip_before_action :verify_authenticity_token, raise: false
17
+
18
+ def create
19
+ user = Panda::Core::User.find(params[:user_id])
20
+
21
+ # Check if user is admin (mimics real OAuth behavior)
22
+ unless user.admin?
23
+ # Non-admin users are redirected to login with error (mimics real OAuth flow)
24
+ flash[:alert] = "You do not have permission to access the admin area."
25
+ # Keep flash for one more request to survive redirect in tests
26
+ flash.keep(:alert) if Rails.env.test?
27
+ # Use string path since route helpers aren't available in ActionController::Base
28
+ redirect_to "#{Panda::Core.config.admin_path || "/admin"}/login", allow_other_host: false, status: :found
29
+ return
30
+ end
31
+
32
+ # Set session (mimics real OAuth callback)
33
+ session[:user_id] = user.id
34
+ Panda::Core::Current.user = user
35
+
36
+ # Support custom redirect path for test flexibility
37
+ redirect_path = params[:return_to] || determine_default_redirect_path
38
+ redirect_to redirect_path, allow_other_host: false, status: :found
39
+ rescue ActiveRecord::RecordNotFound
40
+ render html: "User not found: #{params[:user_id]}", status: :not_found
41
+ rescue => e
42
+ render html: "Error: #{e.class} - #{e.message}<br>#{e.backtrace.first(5).join("<br>")}", status: :internal_server_error
43
+ end
44
+
45
+ private
46
+
47
+ def determine_default_redirect_path
48
+ # Use configured dashboard path if available, otherwise default to admin root
49
+ if Panda::Core.config.dashboard_redirect_path
50
+ path = Panda::Core.config.dashboard_redirect_path
51
+ path.respond_to?(:call) ? path.call : path
52
+ else
53
+ # Use string path since route helpers aren't available in ActionController::Base
54
+ Panda::Core.config.admin_path || "/admin"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -10,13 +10,41 @@ module Panda
10
10
 
11
11
  # Include only Core JavaScript
12
12
  def panda_core_javascript
13
- js_url = Panda::Core::AssetLoader.javascript_url
14
- return "" unless js_url
13
+ # Use asset_tags for development mode (importmap) compatibility
14
+ # In development, this will use importmap; in production/test, compiled bundles
15
+ if Panda::Core::AssetLoader.use_github_assets?
16
+ js_url = Panda::Core::AssetLoader.javascript_url
17
+ return "" unless js_url
15
18
 
16
- if js_url.start_with?("/panda-core-assets/")
17
- javascript_include_tag(js_url)
19
+ if js_url.start_with?("/panda-core-assets/")
20
+ javascript_include_tag(js_url)
21
+ else
22
+ javascript_include_tag(js_url, type: "module")
23
+ end
18
24
  else
19
- javascript_include_tag(js_url, type: "module")
25
+ # Development mode - Load JavaScript with import map
26
+ # Files are served by Rack::Static middleware from engine's app/javascript
27
+ importmap_html = <<~HTML
28
+ <script type="importmap">
29
+ {
30
+ "imports": {
31
+ "@hotwired/stimulus": "/panda/core/vendor/@hotwired--stimulus.js",
32
+ "@hotwired/turbo": "/panda/core/vendor/@hotwired--turbo.js",
33
+ "@rails/actioncable/src": "/panda/core/vendor/@rails--actioncable--src.js",
34
+ "tailwindcss-stimulus-components": "/panda/core/tailwindcss-stimulus-components.js",
35
+ "@fortawesome/fontawesome-free": "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@7.1.0/js/all.js",
36
+ "@tailwindplus/elements": "https://esm.sh/@tailwindplus/elements@1",
37
+ "cropperjs": "https://esm.sh/cropperjs@1.6.2",
38
+ "panda/core/application": "/panda/core/application.js",
39
+ "panda/core/controllers/toggle_controller": "/panda/core/controllers/toggle_controller.js",
40
+ "panda/core/controllers/theme_form_controller": "/panda/core/controllers/theme_form_controller.js"
41
+ }
42
+ }
43
+ </script>
44
+ <script type="module" src="/panda/core/application.js"></script>
45
+ <script type="module" src="/panda/core/controllers/index.js"></script>
46
+ HTML
47
+ importmap_html.html_safe
20
48
  end
21
49
  end
22
50
 
@@ -10,11 +10,36 @@ module Panda
10
10
  github: "github"
11
11
  }.freeze
12
12
 
13
+ # Map of providers that don't use fa-brands (use fa-solid instead)
14
+ PROVIDER_NON_BRAND_ICONS = {
15
+ developer: "code"
16
+ }.freeze
17
+
18
+ # Map OAuth provider names to their display names
19
+ PROVIDER_NAME_MAP = {
20
+ google_oauth2: "Google",
21
+ microsoft_graph: "Microsoft",
22
+ github: "GitHub",
23
+ developer: "Developer"
24
+ }.freeze
25
+
13
26
  # Returns the FontAwesome icon name for a given provider
14
27
  # Checks provider config first, then falls back to the mapping, then uses the provider name as-is
15
28
  def oauth_provider_icon(provider)
16
29
  provider_config = Panda::Core.config.authentication_providers[provider]
17
- provider_config&.dig(:icon) || PROVIDER_ICON_MAP[provider.to_sym] || provider.to_s
30
+ provider_config&.dig(:icon) || PROVIDER_ICON_MAP[provider.to_sym] || PROVIDER_NON_BRAND_ICONS[provider.to_sym] || provider.to_s
31
+ end
32
+
33
+ # Returns true if the provider uses a non-brand icon (fa-solid, fa-regular, etc.)
34
+ def oauth_provider_non_brand?(provider)
35
+ PROVIDER_NON_BRAND_ICONS.key?(provider.to_sym)
36
+ end
37
+
38
+ # Returns the display name for a given provider
39
+ # Checks provider config first, then falls back to the mapping, then humanizes the provider name
40
+ def oauth_provider_name(provider, provider_config = nil)
41
+ provider_config ||= Panda::Core.config.authentication_providers[provider]
42
+ provider_config&.dig(:name) || PROVIDER_NAME_MAP[provider.to_sym] || provider.to_s.humanize
18
43
  end
19
44
  end
20
45
  end
@@ -7,4 +7,11 @@ const application = Application.start()
7
7
  application.debug = false
8
8
  window.Stimulus = application
9
9
 
10
- export { application }
10
+ export { application }
11
+
12
+ // Note: controllers/index.js must be loaded separately in the HTML to avoid circular dependency
13
+ // It will import this application and register all controllers
14
+
15
+ // Tailwind Plus Elements can be loaded by importing "panda/core/tailwindplus-elements"
16
+ // or by adding the script tag directly to your HTML:
17
+ // <script src="https://cdn.jsdelivr.net/npm/@tailwindplus/elements@1" type="module"></script>
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ dismissAfter: Number
6
+ }
7
+
8
+ connect() {
9
+ // Auto-dismiss if dismissAfter value is set
10
+ if (this.hasDismissAfterValue && this.dismissAfterValue > 0) {
11
+ this.timeout = setTimeout(() => {
12
+ this.close()
13
+ }, this.dismissAfterValue)
14
+ }
15
+ }
16
+
17
+ disconnect() {
18
+ // Clean up timeout if controller is disconnected
19
+ if (this.timeout) {
20
+ clearTimeout(this.timeout)
21
+ }
22
+ }
23
+
24
+ close() {
25
+ // Clear any pending timeout
26
+ if (this.timeout) {
27
+ clearTimeout(this.timeout)
28
+ }
29
+
30
+ // Remove the element with a fade-out animation
31
+ this.element.style.transition = "opacity 0.3s ease-out"
32
+ this.element.style.opacity = "0"
33
+
34
+ setTimeout(() => {
35
+ this.element.remove()
36
+ }, 300)
37
+ }
38
+ }
@@ -0,0 +1,158 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Cropper from "cropperjs"
3
+
4
+ // Connects to data-controller="image-cropper"
5
+ export default class extends Controller {
6
+ static targets = ["input", "preview", "cropperContainer", "croppedInput"]
7
+ static values = {
8
+ aspectRatio: Number,
9
+ minWidth: { type: Number, default: 0 },
10
+ minHeight: { type: Number, default: 0 }
11
+ }
12
+
13
+ connect() {
14
+ this.cropper = null
15
+ this.originalFile = null
16
+ }
17
+
18
+ disconnect() {
19
+ if (this.cropper) {
20
+ this.cropper.destroy()
21
+ this.cropper = null
22
+ }
23
+ }
24
+
25
+ handleFileSelect(event) {
26
+ const file = event.target.files[0]
27
+ if (file && file.type.startsWith('image/')) {
28
+ this.originalFile = file
29
+ this.showCropper(file)
30
+ }
31
+ }
32
+
33
+ showCropper(file) {
34
+ const reader = new FileReader()
35
+ reader.onload = (e) => {
36
+ // Show the cropper container
37
+ this.cropperContainerTarget.classList.remove('hidden')
38
+
39
+ // Set the image source
40
+ this.previewTarget.src = e.target.result
41
+
42
+ // Initialize cropper after a short delay to ensure image is loaded
43
+ setTimeout(() => this.initializeCropper(), 100)
44
+ }
45
+ reader.readAsDataURL(file)
46
+ }
47
+
48
+ initializeCropper() {
49
+ // Destroy existing cropper if any
50
+ if (this.cropper) {
51
+ this.cropper.destroy()
52
+ }
53
+
54
+ const options = {
55
+ viewMode: 1,
56
+ dragMode: 'move',
57
+ aspectRatio: this.aspectRatioValue || NaN,
58
+ autoCropArea: 1,
59
+ restore: false,
60
+ guides: true,
61
+ center: true,
62
+ highlight: true,
63
+ cropBoxMovable: true,
64
+ cropBoxResizable: true,
65
+ toggleDragModeOnDblclick: false,
66
+ responsive: true,
67
+ checkOrientation: true,
68
+ minContainerWidth: 200,
69
+ minContainerHeight: 200
70
+ }
71
+
72
+ this.cropper = new Cropper(this.previewTarget, options)
73
+ }
74
+
75
+ crop() {
76
+ if (!this.cropper) return
77
+
78
+ const canvas = this.cropper.getCroppedCanvas({
79
+ minWidth: this.minWidthValue,
80
+ minHeight: this.minHeightValue,
81
+ maxWidth: 4096,
82
+ maxHeight: 4096,
83
+ fillColor: '#fff',
84
+ imageSmoothingEnabled: true,
85
+ imageSmoothingQuality: 'high'
86
+ })
87
+
88
+ canvas.toBlob((blob) => {
89
+ // Create a new File object with the cropped image
90
+ const fileName = this.originalFile.name
91
+ const croppedFile = new File([blob], fileName, { type: this.originalFile.type })
92
+
93
+ // Create a DataTransfer to set the file input value
94
+ const dataTransfer = new DataTransfer()
95
+ dataTransfer.items.add(croppedFile)
96
+ this.inputTarget.files = dataTransfer.files
97
+
98
+ // Hide the cropper
99
+ this.cropperContainerTarget.classList.add('hidden')
100
+
101
+ // Destroy the cropper instance
102
+ if (this.cropper) {
103
+ this.cropper.destroy()
104
+ this.cropper = null
105
+ }
106
+
107
+ // Dispatch event to notify form of change
108
+ this.inputTarget.dispatchEvent(new Event('change', { bubbles: true }))
109
+ }, this.originalFile.type)
110
+ }
111
+
112
+ cancel() {
113
+ // Clear the file input
114
+ this.inputTarget.value = ''
115
+
116
+ // Hide the cropper
117
+ this.cropperContainerTarget.classList.add('hidden')
118
+
119
+ // Destroy the cropper instance
120
+ if (this.cropper) {
121
+ this.cropper.destroy()
122
+ this.cropper = null
123
+ }
124
+ }
125
+
126
+ reset() {
127
+ if (this.cropper) {
128
+ this.cropper.reset()
129
+ }
130
+ }
131
+
132
+ rotate(event) {
133
+ const degrees = parseInt(event.currentTarget.dataset.degrees) || 90
134
+ if (this.cropper) {
135
+ this.cropper.rotate(degrees)
136
+ }
137
+ }
138
+
139
+ flip(event) {
140
+ const direction = event.currentTarget.dataset.direction || 'horizontal'
141
+ if (this.cropper) {
142
+ if (direction === 'horizontal') {
143
+ const scaleX = this.cropper.getData().scaleX || 1
144
+ this.cropper.scaleX(-scaleX)
145
+ } else {
146
+ const scaleY = this.cropper.getData().scaleY || 1
147
+ this.cropper.scaleY(-scaleY)
148
+ }
149
+ }
150
+ }
151
+
152
+ zoom(event) {
153
+ const ratio = parseFloat(event.currentTarget.dataset.ratio) || 0.1
154
+ if (this.cropper) {
155
+ this.cropper.zoom(ratio)
156
+ }
157
+ }
158
+ }