panda-core 0.2.4 → 0.6.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 +199 -7
- data/app/assets/tailwind/tailwind.config.js +8 -0
- data/app/components/panda/core/UI/badge.rb +107 -0
- data/app/components/panda/core/UI/button.rb +110 -0
- data/app/components/panda/core/UI/card.rb +88 -0
- data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
- data/app/components/panda/core/admin/button_component.rb +46 -28
- data/app/components/panda/core/admin/container_component.rb +75 -4
- data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
- data/app/components/panda/core/admin/flash_message_component.rb +98 -15
- data/app/components/panda/core/admin/form_error_component.rb +48 -0
- data/app/components/panda/core/admin/form_input_component.rb +50 -0
- data/app/components/panda/core/admin/form_select_component.rb +68 -0
- data/app/components/panda/core/admin/heading_component.rb +53 -24
- data/app/components/panda/core/admin/page_header_component.rb +107 -0
- data/app/components/panda/core/admin/panel_component.rb +33 -4
- data/app/components/panda/core/admin/slideover_component.rb +66 -4
- data/app/components/panda/core/admin/statistics_component.rb +19 -0
- data/app/components/panda/core/admin/tab_bar_component.rb +101 -0
- data/app/components/panda/core/admin/table_component.rb +92 -11
- data/app/components/panda/core/admin/tag_component.rb +58 -16
- data/app/components/panda/core/admin/user_activity_component.rb +43 -0
- data/app/components/panda/core/admin/user_display_component.rb +77 -0
- data/app/components/panda/core/base.rb +122 -0
- data/app/controllers/panda/core/admin/base_controller.rb +68 -0
- data/app/controllers/panda/core/admin/dashboard_controller.rb +5 -3
- data/app/controllers/panda/core/admin/my_profile_controller.rb +4 -4
- data/app/controllers/panda/core/admin/sessions_controller.rb +15 -8
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
- data/app/helpers/panda/core/asset_helper.rb +31 -5
- data/app/helpers/panda/core/sessions_helper.rb +27 -2
- 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/index.js +3 -3
- data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
- data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
- data/app/javascript/panda/core/vendor/@hotwired--stimulus.js +4 -0
- data/app/javascript/panda/core/vendor/@hotwired--turbo.js +160 -0
- data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +4 -0
- data/app/models/panda/core/user.rb +61 -14
- data/app/services/panda/core/attach_avatar_service.rb +67 -0
- data/app/views/layouts/panda/core/admin.html.erb +40 -3
- data/app/views/layouts/panda/core/admin_simple.html.erb +6 -0
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +4 -4
- data/app/views/panda/core/admin/dashboard/show.html.erb +2 -2
- data/app/views/panda/core/admin/my_profile/edit.html.erb +36 -25
- data/app/views/panda/core/admin/sessions/new.html.erb +9 -10
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +27 -34
- data/app/views/panda/core/admin/shared/_flash.html.erb +4 -30
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +41 -20
- data/app/views/panda/core/shared/_header.html.erb +13 -5
- data/config/importmap.rb +19 -6
- data/config/routes.rb +10 -3
- data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
- data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
- data/lib/panda/core/asset_loader.rb +23 -8
- data/lib/panda/core/configuration.rb +12 -9
- data/lib/panda/core/debug.rb +47 -0
- data/lib/panda/core/engine.rb +55 -9
- data/lib/panda/core/services/base_service.rb +19 -4
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +2 -0
- data/lib/tasks/panda_core_users.rake +158 -0
- metadata +103 -14
- data/app/components/panda/core/admin/container_component.html.erb +0 -12
- data/app/components/panda/core/admin/flash_message_component.html.erb +0 -31
- data/app/components/panda/core/admin/panel_component.html.erb +0 -7
- data/app/components/panda/core/admin/slideover_component.html.erb +0 -9
- data/app/components/panda/core/admin/table_component.html.erb +0 -29
- data/app/controllers/panda/core/admin_controller.rb +0 -30
|
@@ -3,32 +3,113 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class TableComponent <
|
|
6
|
+
class TableComponent < Panda::Core::Base
|
|
7
|
+
prop :term, String
|
|
8
|
+
prop :rows, _Nilable(Object), default: -> { [] }
|
|
9
|
+
prop :icon, String, default: ""
|
|
10
|
+
|
|
7
11
|
attr_reader :columns
|
|
8
12
|
|
|
9
|
-
def initialize(
|
|
10
|
-
|
|
11
|
-
@rows = rows
|
|
13
|
+
def initialize(**props)
|
|
14
|
+
super
|
|
12
15
|
@columns = []
|
|
13
16
|
end
|
|
14
17
|
|
|
15
|
-
def
|
|
16
|
-
|
|
18
|
+
def view_template(&block)
|
|
19
|
+
# Capture the block to populate columns
|
|
20
|
+
instance_eval(&block) if block_given?
|
|
21
|
+
|
|
22
|
+
if @rows.any?
|
|
23
|
+
render_table_with_rows
|
|
24
|
+
else
|
|
25
|
+
render_empty_table
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def column(label, width: nil, &cell_block)
|
|
30
|
+
@columns << Column.new(label, width, &cell_block)
|
|
17
31
|
end
|
|
18
32
|
|
|
19
33
|
private
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
def render_table_with_rows
|
|
36
|
+
div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark", style: "table-layout: fixed;") do
|
|
37
|
+
render_header
|
|
38
|
+
render_rows
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_empty_table
|
|
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?
|
|
45
|
+
h3(class: "py-1 text-xl font-semibold text-gray-900") { "No #{@term.pluralize}" }
|
|
46
|
+
p(class: "py-1 text-base text-gray-500") { "Get started by creating a new #{@term}." }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_header
|
|
51
|
+
div(class: "table-header-group") do
|
|
52
|
+
div(class: "table-row text-base font-medium text-white bg-dark") do
|
|
53
|
+
@columns.each_with_index do |column, i|
|
|
54
|
+
header_classes = "table-cell sticky top-0 z-10 p-4"
|
|
55
|
+
header_classes += " rounded-tl-md" if i.zero?
|
|
56
|
+
header_classes += " rounded-tr-md" if i == @columns.size - 1
|
|
57
|
+
|
|
58
|
+
header_style = column.width ? "width: #{column.width};" : nil
|
|
59
|
+
div(class: header_classes, style: header_style) { column.label }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_rows
|
|
66
|
+
div(class: "table-row-group") do
|
|
67
|
+
@rows.each do |row|
|
|
68
|
+
div(
|
|
69
|
+
class: "table-row relative bg-gray-500/5 hover:bg-gray-500/20",
|
|
70
|
+
data: {post_id: row.id}
|
|
71
|
+
) do
|
|
72
|
+
@columns.each do |column|
|
|
73
|
+
div(class: "table-cell py-5 px-3 h-20 text-sm align-middle whitespace-nowrap border-b border-dark/20") do
|
|
74
|
+
# Capture the cell content by calling the block with the row
|
|
75
|
+
render_cell_content(row, column.cell)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_cell_content(row, cell_block)
|
|
84
|
+
# When called from ERB, we need to capture the block's output buffer
|
|
85
|
+
# When called from Phlex, evaluate directly
|
|
86
|
+
if defined?(view_context) && view_context
|
|
87
|
+
# Use capture to get ERB output buffer content
|
|
88
|
+
captured_html = view_context.capture(row, &cell_block)
|
|
89
|
+
# Render the captured HTML (already html_safe from capture)
|
|
90
|
+
raw(captured_html)
|
|
91
|
+
else
|
|
92
|
+
# Pure Phlex context - execute block directly
|
|
93
|
+
result = cell_block.call(row)
|
|
94
|
+
|
|
95
|
+
# Handle different return types
|
|
96
|
+
if result.is_a?(String)
|
|
97
|
+
plain(result)
|
|
98
|
+
elsif result.respond_to?(:render_in)
|
|
99
|
+
render(result)
|
|
100
|
+
else
|
|
101
|
+
plain(result.to_s)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
24
104
|
end
|
|
25
105
|
end
|
|
26
106
|
|
|
27
107
|
class Column
|
|
28
|
-
attr_reader :label, :cell
|
|
108
|
+
attr_reader :label, :cell, :width
|
|
29
109
|
|
|
30
|
-
def initialize(label, &block)
|
|
110
|
+
def initialize(label, width = nil, &block)
|
|
31
111
|
@label = label
|
|
112
|
+
@width = width
|
|
32
113
|
@cell = block
|
|
33
114
|
end
|
|
34
115
|
end
|
|
@@ -3,30 +3,72 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class TagComponent <
|
|
7
|
-
|
|
6
|
+
class TagComponent < Panda::Core::Base
|
|
7
|
+
prop :status, Symbol, default: :active
|
|
8
|
+
prop :text, _Nilable(String), default: -> {}
|
|
9
|
+
prop :page_type, _Nilable(Symbol), default: -> {}
|
|
8
10
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
@text = text || status.to_s.humanize
|
|
11
|
+
def view_template
|
|
12
|
+
span(class: tag_classes) { computed_text }
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
classes = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
|
|
15
|
+
private
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
def computed_text
|
|
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
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tag_classes
|
|
37
|
+
base = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
|
|
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
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def status_classes
|
|
59
|
+
case @status
|
|
18
60
|
when :active
|
|
19
|
-
"text-white ring-black/30 bg-green-600 border-0
|
|
61
|
+
"text-white ring-black/30 bg-green-600 border-0"
|
|
20
62
|
when :draft
|
|
21
|
-
"text-black ring-black/30 bg-yellow-400
|
|
63
|
+
"text-black ring-black/30 bg-yellow-400"
|
|
22
64
|
when :inactive, :hidden
|
|
23
|
-
"text-black ring-black/30 bg-black/5 bg-white
|
|
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"
|
|
24
70
|
else
|
|
25
|
-
"text-black bg-white
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
content_tag :span, class: classes do
|
|
29
|
-
@text
|
|
71
|
+
"text-black bg-white"
|
|
30
72
|
end
|
|
31
73
|
end
|
|
32
74
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class UserActivityComponent < Panda::Core::Base
|
|
7
|
+
include ActionView::Helpers::DateHelper
|
|
8
|
+
|
|
9
|
+
prop :model, _Nilable(Object), default: -> {}
|
|
10
|
+
prop :at, _Nilable(Object), default: -> {}
|
|
11
|
+
prop :user, _Nilable(Object), default: -> {}
|
|
12
|
+
|
|
13
|
+
def view_template
|
|
14
|
+
return unless should_render?
|
|
15
|
+
|
|
16
|
+
if @user.is_a?(Panda::Core::User) && time
|
|
17
|
+
render Panda::Core::Admin::UserDisplayComponent.new(
|
|
18
|
+
user: @user,
|
|
19
|
+
metadata: "#{time_ago_in_words(time)} ago"
|
|
20
|
+
)
|
|
21
|
+
elsif @user.is_a?(Panda::Core::User)
|
|
22
|
+
render Panda::Core::Admin::UserDisplayComponent.new(
|
|
23
|
+
user: @user,
|
|
24
|
+
metadata: "Not published"
|
|
25
|
+
)
|
|
26
|
+
elsif time
|
|
27
|
+
div(class: "text-black/60") { "#{time_ago_in_words(time)} ago" }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def time
|
|
34
|
+
@at if @at.is_a?(ActiveSupport::TimeWithZone)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def should_render?
|
|
38
|
+
@user.is_a?(Panda::Core::User) || time
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class UserDisplayComponent < Panda::Core::Base
|
|
7
|
+
prop :user_id, _Nilable(String), default: -> {}
|
|
8
|
+
prop :user, _Nilable(Object), default: -> {}
|
|
9
|
+
prop :metadata, String, default: ""
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
return unless resolved_user
|
|
13
|
+
|
|
14
|
+
div(class: "block flex-shrink-0 group") do
|
|
15
|
+
div(class: "flex items-center") do
|
|
16
|
+
render_avatar
|
|
17
|
+
render_user_info
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def resolved_user
|
|
25
|
+
@resolved_user ||= if @user.nil? && @user_id.present?
|
|
26
|
+
Panda::Core::User.find_by(id: @user_id)
|
|
27
|
+
else
|
|
28
|
+
@user
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render_avatar
|
|
33
|
+
has_image = resolved_user.respond_to?(:avatar_url) &&
|
|
34
|
+
resolved_user.avatar_url.present?
|
|
35
|
+
|
|
36
|
+
if has_image
|
|
37
|
+
div do
|
|
38
|
+
img(
|
|
39
|
+
class: "inline-block w-10 h-10 rounded-full object-cover",
|
|
40
|
+
src: resolved_user.avatar_url,
|
|
41
|
+
alt: ""
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
div(class: "inline-block w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center") do
|
|
46
|
+
span(class: "text-sm font-medium text-gray-600") { user_initials }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def user_initials
|
|
52
|
+
return "" unless resolved_user.respond_to?(:name)
|
|
53
|
+
|
|
54
|
+
name_parts = resolved_user.name.to_s.split
|
|
55
|
+
if name_parts.length >= 2
|
|
56
|
+
"#{name_parts.first[0]}#{name_parts.last[0]}".upcase
|
|
57
|
+
elsif name_parts.length == 1
|
|
58
|
+
name_parts.first[0..1].upcase
|
|
59
|
+
else
|
|
60
|
+
""
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_user_info
|
|
65
|
+
div(class: "ml-3") do
|
|
66
|
+
p(class: "text-sm text-black") { resolved_user.name }
|
|
67
|
+
if @metadata.present?
|
|
68
|
+
p(class: "text-sm text-black/60") { @metadata }
|
|
69
|
+
elsif resolved_user.respond_to?(:email) && resolved_user.email.present?
|
|
70
|
+
p(class: "text-sm text-gray-500") { resolved_user.email }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
# Base class for all Phlex components in the Panda ecosystem.
|
|
6
|
+
#
|
|
7
|
+
# This base component provides:
|
|
8
|
+
# - Type-safe properties via Literal
|
|
9
|
+
# - Tailwind CSS class merging
|
|
10
|
+
# - Attribute merging with sensible defaults
|
|
11
|
+
# - Rails helper integration
|
|
12
|
+
# - Development-mode debugging comments
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# class MyComponent < Panda::Core::Base
|
|
16
|
+
# prop :title, String
|
|
17
|
+
# prop :variant, Symbol, default: :primary
|
|
18
|
+
#
|
|
19
|
+
# def view_template
|
|
20
|
+
# div(**@attrs) { title }
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# def default_attrs
|
|
24
|
+
# { class: "my-component my-component--#{variant}" }
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example With attribute merging
|
|
29
|
+
# # Component definition
|
|
30
|
+
# class Button < Panda::Core::Base
|
|
31
|
+
# prop :text, String
|
|
32
|
+
#
|
|
33
|
+
# def view_template
|
|
34
|
+
# button(**@attrs) { text }
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# def default_attrs
|
|
38
|
+
# { class: "btn btn-primary", type: "button" }
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# # Usage - user attrs merge with defaults
|
|
43
|
+
# render Button.new(text: "Click me", class: "mt-4", type: "submit")
|
|
44
|
+
# # => <button type="submit" class="btn btn-primary mt-4">Click me</button>
|
|
45
|
+
#
|
|
46
|
+
class Base < Phlex::HTML
|
|
47
|
+
# Frozen instance of TailwindMerge for efficient class merging
|
|
48
|
+
TAILWIND_MERGER = ::TailwindMerge::Merger.new.freeze unless defined?(TAILWIND_MERGER)
|
|
49
|
+
|
|
50
|
+
# Enable type-safe properties via Literal
|
|
51
|
+
extend Literal::Properties
|
|
52
|
+
|
|
53
|
+
# Include Rails helpers for routes, etc.
|
|
54
|
+
include Phlex::Rails::Helpers::Routes
|
|
55
|
+
|
|
56
|
+
# Special handling for the attrs property - merges user attributes with defaults
|
|
57
|
+
# and intelligently handles Tailwind class merging
|
|
58
|
+
#
|
|
59
|
+
# @param value [Hash] User-provided attributes
|
|
60
|
+
# @return [Hash] Merged attributes with Tailwind classes properly combined
|
|
61
|
+
prop :attrs, Hash, :**, reader: :private do |value|
|
|
62
|
+
merge_attrs(value, default_attrs)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Merges user-provided attributes with default attributes.
|
|
66
|
+
# Special handling for :class to merge Tailwind classes intelligently.
|
|
67
|
+
#
|
|
68
|
+
# @param user_attrs [Hash] Attributes provided by the user
|
|
69
|
+
# @param default_attrs [Hash] Default attributes from the component
|
|
70
|
+
# @return [Hash] Merged attributes
|
|
71
|
+
def merge_attrs(user_attrs, default_attrs)
|
|
72
|
+
attrs = default_attrs.merge(user_attrs)
|
|
73
|
+
if attrs[:class].is_a?(String)
|
|
74
|
+
attrs[:class] = TAILWIND_MERGER.merge(attrs[:class])
|
|
75
|
+
end
|
|
76
|
+
attrs
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Helper alias for merge_attrs with clearer intent
|
|
80
|
+
#
|
|
81
|
+
# @param user_attrs [Hash] Attributes provided by the user
|
|
82
|
+
# @param default_attrs [Hash] Default attributes from the component
|
|
83
|
+
# @return [Hash] Merged attributes with Tailwind classes combined
|
|
84
|
+
def tailwind_merge_attrs(user_attrs, default_attrs)
|
|
85
|
+
merge_attrs(user_attrs, default_attrs)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Override this method in subclasses to provide default attributes
|
|
89
|
+
# for your component.
|
|
90
|
+
#
|
|
91
|
+
# @return [Hash] Default HTML attributes for the component
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# def default_attrs
|
|
95
|
+
# {
|
|
96
|
+
# class: "btn btn-#{variant}",
|
|
97
|
+
# type: "button",
|
|
98
|
+
# data: { controller: "button" }
|
|
99
|
+
# }
|
|
100
|
+
# end
|
|
101
|
+
def default_attrs
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# In development mode, wrap components with HTML comments
|
|
106
|
+
# showing their class name for easier debugging
|
|
107
|
+
if Rails.env.development?
|
|
108
|
+
def before_template
|
|
109
|
+
class_name = self.class.name
|
|
110
|
+
comment { "Begin #{class_name}" }
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def after_template
|
|
115
|
+
class_name = self.class.name
|
|
116
|
+
super
|
|
117
|
+
comment { "End #{class_name}" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
# Base controller for all admin interfaces across Panda gems
|
|
7
|
+
# Provides authentication, helpers, and hooks for extending functionality
|
|
8
|
+
class BaseController < ::ActionController::Base
|
|
9
|
+
layout "panda/core/admin"
|
|
10
|
+
|
|
11
|
+
protect_from_forgery with: :exception
|
|
12
|
+
|
|
13
|
+
# Add flash types for improved alert support with Tailwind
|
|
14
|
+
add_flash_types :success, :warning, :error, :info
|
|
15
|
+
|
|
16
|
+
# Include helper modules
|
|
17
|
+
helper Panda::Core::SessionsHelper
|
|
18
|
+
helper Panda::Core::AssetHelper if defined?(Panda::Core::AssetHelper)
|
|
19
|
+
|
|
20
|
+
before_action :set_current_request_details
|
|
21
|
+
before_action :authenticate_admin_user!
|
|
22
|
+
|
|
23
|
+
helper_method :breadcrumbs
|
|
24
|
+
helper_method :current_user
|
|
25
|
+
helper_method :user_signed_in?
|
|
26
|
+
|
|
27
|
+
def breadcrumbs
|
|
28
|
+
@breadcrumbs ||= []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_breadcrumb(name, path = nil)
|
|
32
|
+
breadcrumbs << Breadcrumb.new(name, path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Set the current request details
|
|
36
|
+
# @return [void]
|
|
37
|
+
def set_current_request_details
|
|
38
|
+
# Set Core current attributes
|
|
39
|
+
Panda::Core::Current.request_id = request.uuid
|
|
40
|
+
Panda::Core::Current.user_agent = request.user_agent
|
|
41
|
+
Panda::Core::Current.ip_address = request.ip
|
|
42
|
+
Panda::Core::Current.root = request.base_url
|
|
43
|
+
Panda::Core::Current.user ||= Panda::Core::User.find_by(id: session[:user_id]) if session[:user_id]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def authenticate_user!
|
|
47
|
+
redirect_to main_app.root_path, flash: {error: "Please login to view this!"} unless user_signed_in?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def authenticate_admin_user!
|
|
51
|
+
return if user_signed_in? && current_user.admin?
|
|
52
|
+
|
|
53
|
+
redirect_to panda_core.admin_login_path,
|
|
54
|
+
flash: {error: "Please login to view this!"}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Required for paper_trail and seems as good as convention these days
|
|
58
|
+
def current_user
|
|
59
|
+
Panda::Core::Current.user
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def user_signed_in?
|
|
63
|
+
!!Panda::Core::Current.user
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class DashboardController <
|
|
6
|
+
class DashboardController < BaseController
|
|
7
7
|
# Authentication is automatically enforced by AdminController
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
10
|
# If a custom dashboard path is configured, redirect there
|
|
11
|
-
if Panda::Core.
|
|
12
|
-
|
|
11
|
+
if Panda::Core.config.dashboard_redirect_path
|
|
12
|
+
redirect_path = Panda::Core.config.dashboard_redirect_path
|
|
13
|
+
redirect_path = redirect_path.call if redirect_path.respond_to?(:call)
|
|
14
|
+
redirect_to redirect_path
|
|
13
15
|
else
|
|
14
16
|
# Render the dashboard view
|
|
15
17
|
render :show
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class MyProfileController <
|
|
6
|
+
class MyProfileController < BaseController
|
|
7
7
|
before_action :set_initial_breadcrumb, only: %i[edit update]
|
|
8
8
|
|
|
9
9
|
# Shows the edit form for the current user's profile
|
|
@@ -21,7 +21,7 @@ module Panda
|
|
|
21
21
|
flash[:success] = "Your profile has been updated successfully."
|
|
22
22
|
redirect_to edit_admin_my_profile_path
|
|
23
23
|
else
|
|
24
|
-
render :edit, locals: {user: current_user}, status: :
|
|
24
|
+
render :edit, locals: {user: current_user}, status: :unprocessable_content
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -36,10 +36,10 @@ module Panda
|
|
|
36
36
|
# @return ActionController::StrongParameters
|
|
37
37
|
def user_params
|
|
38
38
|
# Base parameters that Core always allows
|
|
39
|
-
base_params = [:
|
|
39
|
+
base_params = [:name, :email, :current_theme, :avatar]
|
|
40
40
|
|
|
41
41
|
# Allow additional params from configuration
|
|
42
|
-
additional_params = Core.
|
|
42
|
+
additional_params = Core.config.additional_user_params || []
|
|
43
43
|
|
|
44
44
|
params.require(:user).permit(*(base_params + additional_params))
|
|
45
45
|
end
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class SessionsController <
|
|
6
|
+
class SessionsController < BaseController
|
|
7
|
+
layout "panda/core/admin_simple"
|
|
8
|
+
|
|
7
9
|
# Skip authentication for login/logout actions
|
|
8
10
|
skip_before_action :authenticate_admin_user!, only: [:new, :create, :destroy, :failure]
|
|
9
11
|
|
|
10
12
|
def new
|
|
11
|
-
@providers = Core.
|
|
13
|
+
@providers = Core.config.authentication_providers.keys
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def create
|
|
@@ -18,7 +20,7 @@ module Panda
|
|
|
18
20
|
# Find the actual provider key (might be using path_name override)
|
|
19
21
|
provider = find_provider_by_path(provider_path)
|
|
20
22
|
|
|
21
|
-
unless provider && Core.
|
|
23
|
+
unless provider && Core.config.authentication_providers.key?(provider)
|
|
22
24
|
redirect_to admin_login_path, flash: {error: "Authentication provider not enabled"}
|
|
23
25
|
return
|
|
24
26
|
end
|
|
@@ -28,7 +30,9 @@ module Panda
|
|
|
28
30
|
if user.persisted?
|
|
29
31
|
# Check if user is admin before allowing access
|
|
30
32
|
unless user.admin?
|
|
31
|
-
|
|
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
|
|
32
36
|
return
|
|
33
37
|
end
|
|
34
38
|
|
|
@@ -40,7 +44,8 @@ module Panda
|
|
|
40
44
|
provider: provider)
|
|
41
45
|
|
|
42
46
|
# Use configured dashboard path or default to admin_root_path
|
|
43
|
-
redirect_path = Panda::Core.
|
|
47
|
+
redirect_path = Panda::Core.config.dashboard_redirect_path || admin_root_path
|
|
48
|
+
redirect_path = redirect_path.call if redirect_path.respond_to?(:call)
|
|
44
49
|
redirect_to redirect_path, flash: {success: "Successfully logged in as #{user.name}"}
|
|
45
50
|
else
|
|
46
51
|
redirect_to admin_login_path, flash: {error: "Unable to create account: #{user.errors.full_messages.join(", ")}"}
|
|
@@ -55,7 +60,9 @@ module Panda
|
|
|
55
60
|
strategy = params[:strategy] || "unknown"
|
|
56
61
|
|
|
57
62
|
Rails.logger.error "OmniAuth failure: strategy=#{strategy}, message=#{message}"
|
|
58
|
-
|
|
63
|
+
flash[:error] = "Authentication failed: #{message}"
|
|
64
|
+
flash.keep(:error) if Rails.env.test?
|
|
65
|
+
redirect_to admin_login_path
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def destroy
|
|
@@ -72,10 +79,10 @@ module Panda
|
|
|
72
79
|
# Find the provider key by path name (handles path_name override)
|
|
73
80
|
def find_provider_by_path(provider_path)
|
|
74
81
|
# First check if it's a direct match
|
|
75
|
-
return provider_path if Core.
|
|
82
|
+
return provider_path if Core.config.authentication_providers.key?(provider_path)
|
|
76
83
|
|
|
77
84
|
# Then check if any provider has a matching path_name
|
|
78
|
-
Core.
|
|
85
|
+
Core.config.authentication_providers.each do |key, config|
|
|
79
86
|
return key if config[:path_name]&.to_sym == provider_path
|
|
80
87
|
end
|
|
81
88
|
|