panda-core 0.2.3 → 0.4.1
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/README.md +185 -0
- data/app/assets/tailwind/application.css +279 -0
- data/app/assets/tailwind/tailwind.config.js +21 -0
- data/app/components/panda/core/UI/badge.rb +107 -0
- data/app/components/panda/core/UI/button.rb +89 -0
- data/app/components/panda/core/UI/card.rb +88 -0
- data/app/components/panda/core/admin/button_component.rb +46 -28
- data/app/components/panda/core/admin/container_component.rb +52 -4
- data/app/components/panda/core/admin/flash_message_component.rb +74 -9
- 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 +52 -24
- data/app/components/panda/core/admin/panel_component.rb +33 -4
- data/app/components/panda/core/admin/slideover_component.rb +8 -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 +90 -9
- data/app/components/panda/core/admin/tag_component.rb +21 -16
- data/app/components/panda/core/admin/user_activity_component.rb +43 -0
- data/app/components/panda/core/admin/user_display_component.rb +78 -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 +7 -6
- data/app/controllers/panda/core/admin/my_profile_controller.rb +3 -3
- data/app/controllers/panda/core/admin/sessions_controller.rb +26 -5
- data/app/helpers/panda/core/sessions_helper.rb +21 -0
- data/app/javascript/panda/core/application.js +1 -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 +17 -13
- data/app/views/layouts/panda/core/admin.html.erb +40 -57
- data/app/views/layouts/panda/core/admin_simple.html.erb +5 -0
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +73 -0
- data/app/views/panda/core/admin/dashboard/show.html.erb +4 -10
- data/app/views/panda/core/admin/my_profile/edit.html.erb +13 -27
- data/app/views/panda/core/admin/sessions/new.html.erb +13 -12
- 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 +36 -20
- data/app/views/panda/core/shared/_footer.html.erb +2 -0
- data/app/views/panda/core/shared/_header.html.erb +19 -0
- data/config/importmap.rb +15 -0
- data/config/initializers/panda_core.rb +37 -1
- data/config/routes.rb +7 -7
- data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
- data/lib/generators/panda/core/install_generator.rb +3 -9
- data/lib/generators/panda/core/templates/README +25 -0
- data/lib/generators/panda/core/templates/initializer.rb +28 -0
- data/lib/panda/core/asset_loader.rb +23 -8
- data/lib/panda/core/configuration.rb +41 -9
- data/lib/panda/core/debug.rb +47 -0
- data/lib/panda/core/engine.rb +82 -8
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +1 -0
- data/lib/tasks/assets.rake +58 -392
- data/lib/tasks/panda_core_tasks.rake +16 -0
- metadata +102 -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 -28
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class StatisticsComponent < Panda::Core::Base
|
|
7
|
+
prop :metric, String
|
|
8
|
+
prop :value, _Nilable(_Union(String, Integer, Float))
|
|
9
|
+
|
|
10
|
+
def view_template
|
|
11
|
+
div(class: "overflow-hidden p-4 bg-gradient-to-br rounded-lg border-2 from-light/20 to-light border-mid") do
|
|
12
|
+
dt(class: "text-base font-medium truncate text-dark") { @metric }
|
|
13
|
+
dd(class: "mt-1 text-3xl font-medium tracking-tight text-dark") { @value }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class TabBarComponent < Panda::Core::Base
|
|
7
|
+
prop :tabs, Array, default: -> { [].freeze }
|
|
8
|
+
|
|
9
|
+
def view_template
|
|
10
|
+
div(class: "mt-3 sm:mt-2") do
|
|
11
|
+
render_mobile_select
|
|
12
|
+
render_desktop_tabs
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def render_mobile_select
|
|
19
|
+
div(class: "sm:hidden") do
|
|
20
|
+
label(for: "tabs", class: "sr-only") { "Select a tab" }
|
|
21
|
+
select(
|
|
22
|
+
id: "tabs",
|
|
23
|
+
name: "tabs",
|
|
24
|
+
class: "block py-1.5 pr-10 pl-3 w-full text-gray-900 rounded-md border-0 ring-1 ring-inset focus:ring-2 focus:ring-inset ring-mid focus:border-panda-dark focus:ring-panda-dark"
|
|
25
|
+
) do
|
|
26
|
+
@tabs.each do |tab|
|
|
27
|
+
option { tab[:name] }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_desktop_tabs
|
|
34
|
+
div(class: "hidden sm:block") do
|
|
35
|
+
div(class: "flex items-center border-b border-gray-200") do
|
|
36
|
+
nav(class: "flex flex-1 -mb-px space-x-6 xl:space-x-8", aria: {label: "Tabs"}) do
|
|
37
|
+
@tabs.each_with_index do |tab, index|
|
|
38
|
+
render_tab(tab, index.zero?)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
render_view_toggle
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render_tab(tab, is_current = false)
|
|
47
|
+
classes = "py-4 px-1 text-sm font-medium whitespace-nowrap border-b-2 "
|
|
48
|
+
classes += if is_current || tab[:current]
|
|
49
|
+
"border-panda-dark text-panda-dark"
|
|
50
|
+
else
|
|
51
|
+
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
a(
|
|
55
|
+
href: tab[:url] || "#",
|
|
56
|
+
class: classes,
|
|
57
|
+
aria: {current: (is_current || tab[:current]) ? "page" : nil}
|
|
58
|
+
) { tab[:name] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_view_toggle
|
|
62
|
+
div(class: "hidden items-center p-0.5 ml-6 bg-gray-100 rounded-lg sm:flex") do
|
|
63
|
+
render_view_button(:list)
|
|
64
|
+
render_view_button(:grid, selected: true)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_view_button(type, selected: false)
|
|
69
|
+
button_class = "p-1.5 text-gray-400 rounded-md focus:ring-2 focus:ring-inset focus:outline-none focus:ring-panda-dark"
|
|
70
|
+
button_class += if selected
|
|
71
|
+
" ml-0.5 bg-white shadow-sm"
|
|
72
|
+
else
|
|
73
|
+
" hover:bg-white hover:shadow-sm"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
button(type: "button", class: button_class) do
|
|
77
|
+
if type == :list
|
|
78
|
+
svg(class: "w-5 h-5", viewBox: "0 0 20 20", fill: "currentColor", aria: {hidden: "true"}) do |s|
|
|
79
|
+
s.path(
|
|
80
|
+
fill_rule: "evenodd",
|
|
81
|
+
d: "M2 3.75A.75.75 0 012.75 3h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.166a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z",
|
|
82
|
+
clip_rule: "evenodd"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
span(class: "sr-only") { "Use list view" }
|
|
86
|
+
else
|
|
87
|
+
svg(class: "w-5 h-5", viewBox: "0 0 20 20", fill: "currentColor", aria: {hidden: "true"}) do |s|
|
|
88
|
+
s.path(
|
|
89
|
+
fill_rule: "evenodd",
|
|
90
|
+
d: "M4.25 2A2.25 2.25 0 002 4.25v2.5A2.25 2.25 0 004.25 9h2.5A2.25 2.25 0 009 6.75v-2.5A2.25 2.25 0 006.75 2h-2.5zm0 9A2.25 2.25 0 002 13.25v2.5A2.25 2.25 0 004.25 18h2.5A2.25 2.25 0 009 15.75v-2.5A2.25 2.25 0 006.75 11h-2.5zm9-9A2.25 2.25 0 0011 4.25v2.5A2.25 2.25 0 0013.25 9h2.5A2.25 2.25 0 0018 6.75v-2.5A2.25 2.25 0 0015.75 2h-2.5zm0 9A2.25 2.25 0 0011 13.25v2.5A2.25 2.25 0 0013.25 18h2.5A2.25 2.25 0 0018 15.75v-2.5A2.25 2.25 0 0015.75 11h-2.5z",
|
|
91
|
+
clip_rule: "evenodd"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
span(class: "sr-only") { "Use grid view" }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -3,24 +3,105 @@
|
|
|
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
|
+
|
|
7
10
|
attr_reader :columns
|
|
8
11
|
|
|
9
|
-
def initialize(
|
|
10
|
-
|
|
11
|
-
@rows = rows
|
|
12
|
+
def initialize(**props)
|
|
13
|
+
super
|
|
12
14
|
@columns = []
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
+
def view_template(&block)
|
|
18
|
+
# Capture the block to populate columns
|
|
19
|
+
instance_eval(&block) if block_given?
|
|
20
|
+
|
|
21
|
+
if @rows.any?
|
|
22
|
+
render_table_with_rows
|
|
23
|
+
else
|
|
24
|
+
render_empty_table
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def column(label, &cell_block)
|
|
29
|
+
@columns << Column.new(label, &cell_block)
|
|
17
30
|
end
|
|
18
31
|
|
|
19
32
|
private
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
def render_table_with_rows
|
|
35
|
+
div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark") do
|
|
36
|
+
render_header
|
|
37
|
+
render_rows
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
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
|
|
47
|
+
h3(class: "py-1 text-xl font-semibold text-gray-900") { "No #{@term.pluralize}" }
|
|
48
|
+
p(class: "py-1 text-base text-gray-500") { "Get started by creating a new #{@term}." }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_header
|
|
53
|
+
div(class: "table-header-group") do
|
|
54
|
+
div(class: "table-row text-base font-medium text-white bg-dark") do
|
|
55
|
+
@columns.each_with_index do |column, i|
|
|
56
|
+
header_classes = "table-cell sticky top-0 z-10 p-4"
|
|
57
|
+
header_classes += " rounded-tl-md" if i.zero?
|
|
58
|
+
header_classes += " rounded-tr-md" if i == @columns.size - 1
|
|
59
|
+
|
|
60
|
+
div(class: header_classes) { column.label }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_rows
|
|
67
|
+
div(class: "table-row-group") do
|
|
68
|
+
@rows.each do |row|
|
|
69
|
+
div(
|
|
70
|
+
class: "table-row relative bg-gray-500/5 hover:bg-gray-500/20",
|
|
71
|
+
data: {post_id: row.id}
|
|
72
|
+
) do
|
|
73
|
+
@columns.each do |column|
|
|
74
|
+
div(class: "table-cell py-5 px-3 h-20 text-sm align-middle whitespace-nowrap border-b border-dark/20") do
|
|
75
|
+
# Capture the cell content by calling the block with the row
|
|
76
|
+
render_cell_content(row, column.cell)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_cell_content(row, cell_block)
|
|
85
|
+
# When called from ERB, we need to capture the block's output buffer
|
|
86
|
+
# When called from Phlex, evaluate directly
|
|
87
|
+
if defined?(view_context) && view_context
|
|
88
|
+
# Use capture to get ERB output buffer content
|
|
89
|
+
captured_html = view_context.capture(row, &cell_block)
|
|
90
|
+
# Render the captured HTML (already html_safe from capture)
|
|
91
|
+
raw(captured_html)
|
|
92
|
+
else
|
|
93
|
+
# Pure Phlex context - execute block directly
|
|
94
|
+
result = cell_block.call(row)
|
|
95
|
+
|
|
96
|
+
# Handle different return types
|
|
97
|
+
if result.is_a?(String)
|
|
98
|
+
plain(result)
|
|
99
|
+
elsif result.respond_to?(:render_in)
|
|
100
|
+
render(result)
|
|
101
|
+
else
|
|
102
|
+
plain(result.to_s)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
24
105
|
end
|
|
25
106
|
end
|
|
26
107
|
|
|
@@ -3,30 +3,35 @@
|
|
|
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: -> {}
|
|
8
9
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
@text = text || status.to_s.humanize
|
|
10
|
+
def view_template
|
|
11
|
+
span(class: tag_classes) { computed_text }
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
classes = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
|
|
14
|
+
private
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
def computed_text
|
|
17
|
+
@text || @status.to_s.humanize
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tag_classes
|
|
21
|
+
base = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
|
|
22
|
+
base + status_classes
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def status_classes
|
|
26
|
+
case @status
|
|
18
27
|
when :active
|
|
19
|
-
"text-white ring-black/30 bg-green-600 border-0
|
|
28
|
+
"text-white ring-black/30 bg-green-600 border-0"
|
|
20
29
|
when :draft
|
|
21
|
-
"text-black ring-black/30 bg-yellow-400
|
|
30
|
+
"text-black ring-black/30 bg-yellow-400"
|
|
22
31
|
when :inactive, :hidden
|
|
23
|
-
"text-black ring-black/30 bg-black/5 bg-white
|
|
32
|
+
"text-black ring-black/30 bg-black/5 bg-white"
|
|
24
33
|
else
|
|
25
|
-
"text-black bg-white
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
content_tag :span, class: classes do
|
|
29
|
-
@text
|
|
34
|
+
"text-black bg-white"
|
|
30
35
|
end
|
|
31
36
|
end
|
|
32
37
|
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,78 @@
|
|
|
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?(:image_url) &&
|
|
34
|
+
resolved_user.image_url.present? &&
|
|
35
|
+
!resolved_user.image_url.empty?
|
|
36
|
+
|
|
37
|
+
if has_image
|
|
38
|
+
div do
|
|
39
|
+
img(
|
|
40
|
+
class: "inline-block w-10 h-10 rounded-full",
|
|
41
|
+
src: resolved_user.image_url,
|
|
42
|
+
alt: ""
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
div(class: "inline-block w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center") do
|
|
47
|
+
span(class: "text-sm font-medium text-gray-600") { user_initials }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def user_initials
|
|
53
|
+
return "" unless resolved_user.respond_to?(:name)
|
|
54
|
+
|
|
55
|
+
name_parts = resolved_user.name.to_s.split
|
|
56
|
+
if name_parts.length >= 2
|
|
57
|
+
"#{name_parts.first[0]}#{name_parts.last[0]}".upcase
|
|
58
|
+
elsif name_parts.length == 1
|
|
59
|
+
name_parts.first[0..1].upcase
|
|
60
|
+
else
|
|
61
|
+
""
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_user_info
|
|
66
|
+
div(class: "ml-3") do
|
|
67
|
+
p(class: "text-sm text-black") { resolved_user.name }
|
|
68
|
+
if @metadata.present?
|
|
69
|
+
p(class: "text-sm text-black/60") { @metadata }
|
|
70
|
+
elsif resolved_user.respond_to?(:email) && resolved_user.email.present?
|
|
71
|
+
p(class: "text-sm text-gray-500") { resolved_user.email }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
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,17 +3,18 @@
|
|
|
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
|
-
#
|
|
15
|
-
|
|
16
|
-
render plain: "Welcome to Panda Admin"
|
|
16
|
+
# Render the dashboard view
|
|
17
|
+
render :show
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -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
|
|
@@ -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]
|
|
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
|