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.
- checksums.yaml +4 -4
- data/app/assets/tailwind/application.css +95 -0
- data/app/assets/tailwind/tailwind.config.js +8 -0
- data/app/builders/panda/core/form_builder.rb +163 -11
- data/app/components/panda/core/UI/button.rb +45 -24
- data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
- data/app/components/panda/core/admin/button_component.rb +27 -12
- data/app/components/panda/core/admin/container_component.rb +40 -5
- data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
- data/app/components/panda/core/admin/flash_message_component.rb +54 -36
- data/app/components/panda/core/admin/heading_component.rb +28 -19
- data/app/components/panda/core/admin/page_header_component.rb +107 -0
- data/app/components/panda/core/admin/panel_component.rb +1 -1
- data/app/components/panda/core/admin/slideover_component.rb +92 -4
- data/app/components/panda/core/admin/table_component.rb +11 -11
- data/app/components/panda/core/admin/tag_component.rb +39 -2
- data/app/components/panda/core/admin/user_display_component.rb +4 -5
- data/app/controllers/panda/core/admin/my_profile_controller.rb +10 -3
- data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
- data/app/helpers/panda/core/asset_helper.rb +33 -5
- data/app/helpers/panda/core/sessions_helper.rb +26 -1
- data/app/javascript/panda/core/application.js +8 -1
- data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
- data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
- data/app/javascript/panda/core/controllers/index.js +9 -3
- data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
- data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
- data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
- data/app/models/panda/core/user.rb +60 -6
- data/app/services/panda/core/attach_avatar_service.rb +71 -0
- data/app/views/layouts/panda/core/admin.html.erb +39 -14
- data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -1
- data/app/views/panda/core/admin/dashboard/show.html.erb +3 -3
- data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
- data/app/views/panda/core/admin/sessions/new.html.erb +3 -4
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +69 -14
- data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
- data/config/importmap.rb +20 -7
- data/config/routes.rb +10 -1
- data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
- data/lib/panda/core/asset_loader.rb +5 -2
- data/lib/panda/core/engine.rb +38 -28
- data/lib/panda/core/oauth_providers.rb +3 -3
- data/lib/panda/core/services/base_service.rb +19 -4
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +1 -0
- data/lib/tasks/panda_core_users.rake +158 -0
- metadata +13 -69
- data/lib/generators/panda/core/authentication/templates/reek_spec.rb +0 -43
- data/lib/generators/panda/core/dev_tools/USAGE +0 -24
- data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +0 -13
- data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +0 -18
- data/lib/generators/panda/core/dev_tools_generator.rb +0 -143
- data/lib/generators/panda/core/install_generator.rb +0 -41
- data/lib/generators/panda/core/templates/README +0 -25
- data/lib/generators/panda/core/templates/initializer.rb +0 -44
- data/lib/generators/panda/core/templates_generator.rb +0 -27
- data/lib/panda/core/testing/capybara_config.rb +0 -70
- data/lib/panda/core/testing/omniauth_helpers.rb +0 -52
- 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
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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: "
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?(:
|
|
34
|
-
resolved_user.
|
|
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.
|
|
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: :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
+
}
|