panda-core 0.2.4 → 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/app/assets/tailwind/application.css +104 -7
- 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 +5 -3
- data/app/controllers/panda/core/admin/my_profile_controller.rb +3 -3
- data/app/controllers/panda/core/admin/sessions_controller.rb +9 -6
- data/app/helpers/panda/core/sessions_helper.rb +1 -1
- 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 -3
- data/app/views/layouts/panda/core/admin_simple.html.erb +5 -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 +13 -27
- data/app/views/panda/core/admin/sessions/new.html.erb +7 -7
- 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/_header.html.erb +13 -5
- data/config/importmap.rb +11 -6
- data/config/routes.rb +2 -4
- data/db/migrate/20250810120000_add_current_theme_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 +43 -6
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +1 -0
- metadata +93 -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,17 +3,82 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class FlashMessageComponent < ::
|
|
7
|
-
|
|
6
|
+
class FlashMessageComponent < Panda::Core::Base
|
|
7
|
+
prop :message, String
|
|
8
|
+
prop :kind, Symbol
|
|
9
|
+
prop :temporary, _Boolean, default: true
|
|
8
10
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
def view_template
|
|
12
|
+
div(**container_attrs) do
|
|
13
|
+
div(class: "overflow-hidden w-full max-w-sm bg-white rounded-lg ring-1 ring-black ring-opacity-5 shadow-lg") do
|
|
14
|
+
div(class: "p-4") do
|
|
15
|
+
div(class: "flex items-start") do
|
|
16
|
+
render_icon
|
|
17
|
+
render_content
|
|
18
|
+
render_close_button
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def container_attrs
|
|
28
|
+
attrs = {
|
|
29
|
+
class: "fixed top-2 right-2 z-[9999] p-2 space-y-4 w-full max-w-sm sm:items-end",
|
|
30
|
+
data: {
|
|
31
|
+
controller: "alert",
|
|
32
|
+
transition_enter: "ease-in-out duration-500",
|
|
33
|
+
transition_enter_from: "translate-x-full opacity-0",
|
|
34
|
+
transition_enter_to: "translate-x-0 opacity-100",
|
|
35
|
+
transition_leave: "ease-in-out duration-500",
|
|
36
|
+
transition_leave_from: "translate-x-0 opacity-100",
|
|
37
|
+
transition_leave_to: "translate-x-full opacity-0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
attrs[:data][:alert_dismiss_after_value] = "3000" if @temporary
|
|
42
|
+
attrs
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_icon
|
|
46
|
+
div(class: "flex-shrink-0") do
|
|
47
|
+
i(class: "fa-regular text-xl #{icon_css} #{text_colour_css}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render_content
|
|
52
|
+
div(class: "flex-1 pt-0.5 ml-3 w-0") do
|
|
53
|
+
p(class: "mb-1 text-sm font-medium flash-message-title #{text_colour_css}") { @kind.to_s.titleize }
|
|
54
|
+
p(class: "mt-1 mb-0 text-sm text-gray-500 flash-message-text") { @message }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_close_button
|
|
59
|
+
div(class: "flex flex-shrink-0 ml-4") do
|
|
60
|
+
button(
|
|
61
|
+
type: "button",
|
|
62
|
+
class: "inline-flex text-gray-400 bg-white rounded-md transition duration-150 ease-in-out hover:text-gray-500 focus:ring-2 focus:ring-offset-2 focus:outline-none focus:ring-sky-500",
|
|
63
|
+
data: {action: "alert#close"}
|
|
64
|
+
) do
|
|
65
|
+
span(class: "sr-only") { "Close" }
|
|
66
|
+
svg(
|
|
67
|
+
class: "w-5 h-5",
|
|
68
|
+
viewBox: "0 0 20 20",
|
|
69
|
+
fill: "currentColor",
|
|
70
|
+
aria: {hidden: "true"}
|
|
71
|
+
) do |s|
|
|
72
|
+
s.path(
|
|
73
|
+
d: "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
13
78
|
end
|
|
14
79
|
|
|
15
80
|
def text_colour_css
|
|
16
|
-
case kind
|
|
81
|
+
case @kind
|
|
17
82
|
when :success
|
|
18
83
|
"text-green-600"
|
|
19
84
|
when :alert, :error
|
|
@@ -28,10 +93,10 @@ module Panda
|
|
|
28
93
|
end
|
|
29
94
|
|
|
30
95
|
def icon_css
|
|
31
|
-
case kind
|
|
96
|
+
case @kind
|
|
32
97
|
when :success
|
|
33
98
|
"fa-circle-check"
|
|
34
|
-
when :alert
|
|
99
|
+
when :alert, :error
|
|
35
100
|
"fa-circle-xmark"
|
|
36
101
|
when :warning
|
|
37
102
|
"fa-triangle-exclamation"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class FormErrorComponent < Panda::Core::Base
|
|
7
|
+
prop :errors, _Nilable(_Union(ActiveModel::Errors, Array)), default: -> {}
|
|
8
|
+
prop :model, _Nilable(Object), default: -> {}
|
|
9
|
+
|
|
10
|
+
def view_template
|
|
11
|
+
return unless should_render?
|
|
12
|
+
|
|
13
|
+
div(**@attrs) do
|
|
14
|
+
div(class: "text-sm text-red-600") do
|
|
15
|
+
error_messages.each do |message|
|
|
16
|
+
p { message }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def default_attrs
|
|
23
|
+
{
|
|
24
|
+
class: "mb-4 p-4 bg-red-50 border border-red-200 rounded-md"
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def should_render?
|
|
31
|
+
error_messages.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def error_messages
|
|
35
|
+
@error_messages ||= if @model&.respond_to?(:errors)
|
|
36
|
+
@model.errors.full_messages
|
|
37
|
+
elsif @errors.is_a?(ActiveModel::Errors)
|
|
38
|
+
@errors.full_messages
|
|
39
|
+
elsif @errors.is_a?(Array)
|
|
40
|
+
@errors
|
|
41
|
+
else
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class FormInputComponent < Panda::Core::Base
|
|
7
|
+
prop :name, String
|
|
8
|
+
prop :value, _Nilable(String), default: -> {}
|
|
9
|
+
prop :type, Symbol, default: :text
|
|
10
|
+
prop :placeholder, _Nilable(String), default: -> {}
|
|
11
|
+
prop :required, _Boolean, default: -> { false }
|
|
12
|
+
prop :disabled, _Boolean, default: -> { false }
|
|
13
|
+
prop :autocomplete, _Nilable(String), default: -> {}
|
|
14
|
+
|
|
15
|
+
def view_template
|
|
16
|
+
input(**@attrs)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_attrs
|
|
20
|
+
base_attrs = {
|
|
21
|
+
type: @type.to_s,
|
|
22
|
+
name: @name,
|
|
23
|
+
id: @name.to_s.gsub(/[\[\]]/, "_").gsub("__", "_").chomp("_"),
|
|
24
|
+
class: input_classes
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
base_attrs[:value] = @value if @value
|
|
28
|
+
base_attrs[:placeholder] = @placeholder if @placeholder
|
|
29
|
+
base_attrs[:required] = true if @required
|
|
30
|
+
base_attrs[:disabled] = true if @disabled
|
|
31
|
+
base_attrs[:autocomplete] = @autocomplete if @autocomplete
|
|
32
|
+
|
|
33
|
+
base_attrs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def input_classes
|
|
39
|
+
classes = "block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset placeholder:text-gray-300 focus:ring-1 focus:ring-inset sm:leading-6"
|
|
40
|
+
|
|
41
|
+
if @disabled
|
|
42
|
+
classes + " ring-gray-300 focus:ring-gray-300 bg-gray-50 cursor-not-allowed"
|
|
43
|
+
else
|
|
44
|
+
classes + " ring-mid focus:ring-dark hover:cursor-pointer"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class FormSelectComponent < Panda::Core::Base
|
|
7
|
+
prop :name, String
|
|
8
|
+
prop :options, Array
|
|
9
|
+
prop :selected, _Nilable(_Union(String, Integer)), default: -> {}
|
|
10
|
+
prop :prompt, _Nilable(String), default: -> {}
|
|
11
|
+
prop :required, _Boolean, default: -> { false }
|
|
12
|
+
prop :disabled, _Boolean, default: -> { false }
|
|
13
|
+
prop :include_blank, _Boolean, default: -> { false }
|
|
14
|
+
|
|
15
|
+
def view_template
|
|
16
|
+
select(**@attrs) do
|
|
17
|
+
render_prompt if @prompt
|
|
18
|
+
render_blank if @include_blank && !@prompt
|
|
19
|
+
render_options
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def default_attrs
|
|
24
|
+
base_attrs = {
|
|
25
|
+
name: @name,
|
|
26
|
+
id: @name.to_s.gsub(/[\[\]]/, "_").gsub("__", "_").chomp("_"),
|
|
27
|
+
class: select_classes
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
base_attrs[:required] = true if @required
|
|
31
|
+
base_attrs[:disabled] = true if @disabled
|
|
32
|
+
|
|
33
|
+
base_attrs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def select_classes
|
|
39
|
+
classes = "block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset focus:ring-1 focus:ring-inset sm:leading-6"
|
|
40
|
+
|
|
41
|
+
if @disabled
|
|
42
|
+
classes + " ring-gray-300 focus:ring-gray-300 bg-gray-50 cursor-not-allowed"
|
|
43
|
+
else
|
|
44
|
+
classes + " ring-mid focus:ring-dark hover:cursor-pointer"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render_prompt
|
|
49
|
+
option(value: "", disabled: true, selected: @selected.nil?) { @prompt }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_blank
|
|
53
|
+
option(value: "") { "" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render_options
|
|
57
|
+
@options.each do |option_data|
|
|
58
|
+
label, value = option_data
|
|
59
|
+
option_attrs = {value: value.to_s}
|
|
60
|
+
option_attrs[:selected] = true if value.to_s == @selected.to_s
|
|
61
|
+
|
|
62
|
+
option(**option_attrs) { label.to_s }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -3,42 +3,70 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class HeadingComponent <
|
|
7
|
-
|
|
6
|
+
class HeadingComponent < Panda::Core::Base
|
|
7
|
+
prop :text, String
|
|
8
|
+
prop :level, _Nilable(_Union(Integer, Symbol)), default: -> { 2 }
|
|
9
|
+
prop :icon, String, default: ""
|
|
10
|
+
prop :additional_styles, _Nilable(_Union(String, Array)), default: -> { "" }
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
def view_template(&block)
|
|
13
|
+
# Capture any buttons defined via block
|
|
14
|
+
instance_eval(&block) if block_given?
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
case @level
|
|
17
|
+
when 1
|
|
18
|
+
h1(class: heading_classes) { render_content }
|
|
19
|
+
when 2
|
|
20
|
+
h2(class: heading_classes) { render_content }
|
|
21
|
+
when 3
|
|
22
|
+
h3(class: heading_classes) { render_content }
|
|
23
|
+
when :panel
|
|
24
|
+
h3(class: panel_heading_classes) { render_content }
|
|
25
|
+
else
|
|
26
|
+
h2(class: heading_classes) { render_content }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def button(**props)
|
|
31
|
+
@buttons ||= []
|
|
32
|
+
@buttons << Panda::Core::Admin::ButtonComponent.new(**props)
|
|
17
33
|
end
|
|
18
34
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def render_content
|
|
38
|
+
div(class: "grow") { @text }
|
|
22
39
|
|
|
23
|
-
if buttons?
|
|
24
|
-
|
|
25
|
-
|
|
40
|
+
if @buttons&.any?
|
|
41
|
+
span(class: "actions flex gap-x-2 -mt-1") do
|
|
42
|
+
@buttons.each { |btn| render(btn) }
|
|
26
43
|
end
|
|
27
44
|
end
|
|
45
|
+
end
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
case level
|
|
47
|
+
def heading_classes
|
|
48
|
+
base = "flex pt-1 text-black mb-5 -mt-1"
|
|
49
|
+
styles = case @level
|
|
33
50
|
when 1
|
|
34
|
-
|
|
51
|
+
"text-2xl font-medium"
|
|
35
52
|
when 2
|
|
36
|
-
|
|
53
|
+
"text-xl font-medium"
|
|
37
54
|
when 3
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
"text-xl font-light"
|
|
56
|
+
else
|
|
57
|
+
"text-xl font-medium"
|
|
41
58
|
end
|
|
59
|
+
|
|
60
|
+
[base, styles, *additional_styles_array].compact.join(" ")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def panel_heading_classes
|
|
64
|
+
"text-base font-medium p-4 text-white"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def additional_styles_array
|
|
68
|
+
return [] if @additional_styles.blank?
|
|
69
|
+
@additional_styles.is_a?(String) ? @additional_styles.split(" ") : @additional_styles
|
|
42
70
|
end
|
|
43
71
|
end
|
|
44
72
|
end
|
|
@@ -3,10 +3,39 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class PanelComponent <
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
class PanelComponent < Panda::Core::Base
|
|
7
|
+
def view_template(&block)
|
|
8
|
+
# Capture block content differently based on context (ERB vs Phlex)
|
|
9
|
+
if block_given?
|
|
10
|
+
if defined?(view_context) && view_context
|
|
11
|
+
# Called from ERB - capture HTML output
|
|
12
|
+
@body_html = view_context.capture { yield(self) }
|
|
13
|
+
else
|
|
14
|
+
# Called from Phlex - execute block directly to set instance variables
|
|
15
|
+
yield(self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
div(class: "col-span-3 mt-5 rounded-lg shadow-md bg-gray-500 shadow-inherit/20") do
|
|
20
|
+
@heading_content&.call
|
|
21
|
+
|
|
22
|
+
div(class: "p-4 text-black bg-white rounded-b-lg") do
|
|
23
|
+
if @body_content
|
|
24
|
+
@body_content.call
|
|
25
|
+
elsif @body_html
|
|
26
|
+
raw(@body_html)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def heading(**props)
|
|
33
|
+
@heading_content = -> { render(Panda::Core::Admin::HeadingComponent.new(**props.merge(level: :panel))) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def body(&block)
|
|
37
|
+
@body_content = block
|
|
38
|
+
end
|
|
10
39
|
end
|
|
11
40
|
end
|
|
12
41
|
end
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module Core
|
|
5
5
|
module Admin
|
|
6
|
-
class SlideoverComponent <
|
|
7
|
-
|
|
6
|
+
class SlideoverComponent < Panda::Core::Base
|
|
7
|
+
prop :title, String, default: "Settings"
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
|
|
9
|
+
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)
|
|
14
|
+
end
|
|
11
15
|
end
|
|
12
16
|
end
|
|
13
17
|
end
|
|
@@ -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
|
|