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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +185 -0
  3. data/app/assets/tailwind/application.css +279 -0
  4. data/app/assets/tailwind/tailwind.config.js +21 -0
  5. data/app/components/panda/core/UI/badge.rb +107 -0
  6. data/app/components/panda/core/UI/button.rb +89 -0
  7. data/app/components/panda/core/UI/card.rb +88 -0
  8. data/app/components/panda/core/admin/button_component.rb +46 -28
  9. data/app/components/panda/core/admin/container_component.rb +52 -4
  10. data/app/components/panda/core/admin/flash_message_component.rb +74 -9
  11. data/app/components/panda/core/admin/form_error_component.rb +48 -0
  12. data/app/components/panda/core/admin/form_input_component.rb +50 -0
  13. data/app/components/panda/core/admin/form_select_component.rb +68 -0
  14. data/app/components/panda/core/admin/heading_component.rb +52 -24
  15. data/app/components/panda/core/admin/panel_component.rb +33 -4
  16. data/app/components/panda/core/admin/slideover_component.rb +8 -4
  17. data/app/components/panda/core/admin/statistics_component.rb +19 -0
  18. data/app/components/panda/core/admin/tab_bar_component.rb +101 -0
  19. data/app/components/panda/core/admin/table_component.rb +90 -9
  20. data/app/components/panda/core/admin/tag_component.rb +21 -16
  21. data/app/components/panda/core/admin/user_activity_component.rb +43 -0
  22. data/app/components/panda/core/admin/user_display_component.rb +78 -0
  23. data/app/components/panda/core/base.rb +122 -0
  24. data/app/controllers/panda/core/admin/base_controller.rb +68 -0
  25. data/app/controllers/panda/core/admin/dashboard_controller.rb +7 -6
  26. data/app/controllers/panda/core/admin/my_profile_controller.rb +3 -3
  27. data/app/controllers/panda/core/admin/sessions_controller.rb +26 -5
  28. data/app/helpers/panda/core/sessions_helper.rb +21 -0
  29. data/app/javascript/panda/core/application.js +1 -0
  30. data/app/javascript/panda/core/vendor/@hotwired--stimulus.js +4 -0
  31. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +160 -0
  32. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +4 -0
  33. data/app/models/panda/core/user.rb +17 -13
  34. data/app/views/layouts/panda/core/admin.html.erb +40 -57
  35. data/app/views/layouts/panda/core/admin_simple.html.erb +5 -0
  36. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +73 -0
  37. data/app/views/panda/core/admin/dashboard/show.html.erb +4 -10
  38. data/app/views/panda/core/admin/my_profile/edit.html.erb +13 -27
  39. data/app/views/panda/core/admin/sessions/new.html.erb +13 -12
  40. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +27 -34
  41. data/app/views/panda/core/admin/shared/_flash.html.erb +4 -30
  42. data/app/views/panda/core/admin/shared/_sidebar.html.erb +36 -20
  43. data/app/views/panda/core/shared/_footer.html.erb +2 -0
  44. data/app/views/panda/core/shared/_header.html.erb +19 -0
  45. data/config/importmap.rb +15 -0
  46. data/config/initializers/panda_core.rb +37 -1
  47. data/config/routes.rb +7 -7
  48. data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
  49. data/lib/generators/panda/core/install_generator.rb +3 -9
  50. data/lib/generators/panda/core/templates/README +25 -0
  51. data/lib/generators/panda/core/templates/initializer.rb +28 -0
  52. data/lib/panda/core/asset_loader.rb +23 -8
  53. data/lib/panda/core/configuration.rb +41 -9
  54. data/lib/panda/core/debug.rb +47 -0
  55. data/lib/panda/core/engine.rb +82 -8
  56. data/lib/panda/core/version.rb +1 -1
  57. data/lib/panda/core.rb +1 -0
  58. data/lib/tasks/assets.rake +58 -392
  59. data/lib/tasks/panda_core_tasks.rake +16 -0
  60. metadata +102 -14
  61. data/app/components/panda/core/admin/container_component.html.erb +0 -12
  62. data/app/components/panda/core/admin/flash_message_component.html.erb +0 -31
  63. data/app/components/panda/core/admin/panel_component.html.erb +0 -7
  64. data/app/components/panda/core/admin/slideover_component.html.erb +0 -9
  65. data/app/components/panda/core/admin/table_component.html.erb +0 -29
  66. data/app/controllers/panda/core/admin_controller.rb +0 -28
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module UI
6
+ # Card component for containing related content.
7
+ #
8
+ # Cards are flexible containers that can hold any content,
9
+ # with optional padding, shadows, and border variations.
10
+ #
11
+ # @example Basic card
12
+ # render Panda::Core::UI::Card.new do
13
+ # "Card content here"
14
+ # end
15
+ #
16
+ # @example Card with header and footer
17
+ # render Panda::Core::UI::Card.new(padding: :large) do |card|
18
+ # card.with_header { h3 { "Card Title" } }
19
+ # card.with_body { p { "Main content" } }
20
+ # card.with_footer { "Footer content" }
21
+ # end
22
+ #
23
+ # @example Elevated card with no padding
24
+ # render Panda::Core::UI::Card.new(
25
+ # elevation: :high,
26
+ # padding: :none
27
+ # ) do
28
+ # img(src: "/image.jpg", alt: "Card image")
29
+ # end
30
+ #
31
+ class Card < Panda::Core::Base
32
+ prop :padding, Symbol, default: :medium
33
+ prop :elevation, Symbol, default: :low
34
+ prop :border, _Boolean, default: true
35
+
36
+ def view_template(&block)
37
+ div(**@attrs, &block)
38
+ end
39
+
40
+ def default_attrs
41
+ {
42
+ class: card_classes
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def card_classes
49
+ base = "bg-white rounded-lg overflow-hidden"
50
+ base += " #{padding_classes}"
51
+ base += " #{elevation_classes}"
52
+ base += " #{border_classes}"
53
+ base
54
+ end
55
+
56
+ def padding_classes
57
+ case padding
58
+ when :none
59
+ ""
60
+ when :small, :sm
61
+ "p-4"
62
+ when :large, :lg
63
+ "p-8"
64
+ else # :medium, :md
65
+ "p-6"
66
+ end
67
+ end
68
+
69
+ def elevation_classes
70
+ case elevation
71
+ when :none
72
+ ""
73
+ when :medium, :md
74
+ "shadow-md"
75
+ when :high, :lg
76
+ "shadow-lg"
77
+ else # :low, :sm
78
+ "shadow-sm"
79
+ end
80
+ end
81
+
82
+ def border_classes
83
+ border ? "border border-gray-200" : ""
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -3,56 +3,74 @@
3
3
  module Panda
4
4
  module Core
5
5
  module Admin
6
- class ButtonComponent < ViewComponent::Base
7
- attr_accessor :text, :action, :link, :icon, :size, :data
6
+ class ButtonComponent < Panda::Core::Base
7
+ prop :text, String, default: "Button"
8
+ prop :action, _Nilable(Symbol), default: -> {}
9
+ prop :href, String, default: "#"
10
+ prop :icon, _Nilable(String), default: -> {}
11
+ prop :size, Symbol, default: :regular
12
+ prop :id, _Nilable(String), default: -> {}
8
13
 
9
- def initialize(text: "Button", action: nil, data: {}, link: "#", icon: nil, size: :regular, id: nil)
10
- @text = text
11
- @action = action
12
- @data = data
13
- @link = link
14
- @icon = icon
15
- @size = size
16
- @id = id
14
+ def view_template
15
+ a(**@attrs) do
16
+ if computed_icon
17
+ i(class: "mr-2 fa-regular fa-#{computed_icon}")
18
+ plain " "
19
+ end
20
+ plain @text.titleize
21
+ end
22
+ end
23
+
24
+ def default_attrs
25
+ {
26
+ href: @href,
27
+ class: button_classes,
28
+ id: @id
29
+ }
17
30
  end
18
31
 
19
- def call
20
- @icon = set_icon_from_action(@action) if @action && @icon.nil?
21
- icon = content_tag(:i, "", class: "mr-2 fa-regular fa-#{@icon}") if @icon
22
- @text = "#{icon} #{@text.titleize}".html_safe
32
+ private
33
+
34
+ def computed_icon
35
+ @computed_icon ||= @icon || icon_from_action(@action)
36
+ end
23
37
 
24
- classes = "inline-flex items-center rounded-md font-medium shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
38
+ def button_classes
39
+ base = "inline-flex items-center rounded-md font-medium shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
40
+ base + size_classes + action_classes
41
+ end
25
42
 
43
+ def size_classes
26
44
  case @size
27
45
  when :small, :sm
28
- classes += "gap-x-1.5 px-2.5 py-1.5 text-sm "
46
+ "gap-x-1.5 px-2.5 py-1.5 text-sm "
29
47
  when :medium, :regular, :md
30
- classes += "gap-x-1.5 px-3 py-2 text-base "
48
+ "gap-x-1.5 px-3 py-2 text-base "
31
49
  when :large, :lg
32
- classes += "gap-x-2 px-3.5 py-2.5 text-lg "
50
+ "gap-x-2 px-3.5 py-2.5 text-lg "
51
+ else
52
+ "gap-x-1.5 px-3 py-2 text-base "
33
53
  end
54
+ end
34
55
 
35
- classes += case @action
56
+ def action_classes
57
+ case @action
36
58
  when :save, :create
37
59
  "text-white bg-green-600 hover:bg-green-700"
38
60
  when :save_inactive
39
61
  "text-white bg-gray-400"
40
62
  when :secondary
41
- "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all "
63
+ "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all"
42
64
  when :delete, :destroy, :danger
43
- "text-red-600 border border-red-600 bg-red-100 hover:bg-red-200 hover:text-red-700 focus-visible:outline-red-300 "
65
+ "text-red-600 border border-red-600 bg-red-100 hover:bg-red-200 hover:text-red-700 focus-visible:outline-red-300"
44
66
  else
45
- "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all "
46
- end
47
-
48
- content_tag :a, href: @link, class: classes, data: @data, id: @id do
49
- @text
67
+ "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all"
50
68
  end
51
69
  end
52
70
 
53
- private
71
+ def icon_from_action(action)
72
+ return nil unless action
54
73
 
55
- def set_icon_from_action(action)
56
74
  case action
57
75
  when :add, :new, :create
58
76
  "plus"
@@ -3,10 +3,58 @@
3
3
  module Panda
4
4
  module Core
5
5
  module Admin
6
- class ContainerComponent < ViewComponent::Base
7
- renders_one :heading, "Panda::Core::Admin::HeadingComponent"
8
- renders_one :tab_bar, "Panda::Core::Admin::TabBarComponent"
9
- renders_one :slideover, "Panda::Core::Admin::SlideoverComponent"
6
+ class ContainerComponent < 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
+ main(class: "overflow-auto flex-1 h-full min-h-full max-h-full") do
20
+ div(class: "overflow-auto px-2 pt-4 mx-auto sm:px-6 lg:px-6") do
21
+ @heading_content&.call
22
+ @tab_bar_content&.call
23
+
24
+ section(class: "flex-auto") do
25
+ div(class: "flex-1 mt-4 w-full") do
26
+ if @main_content
27
+ @main_content.call
28
+ elsif @body_html
29
+ raw(@body_html)
30
+ end
31
+ end
32
+ @slideover_content&.call
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ def content(&block)
39
+ @main_content = if defined?(view_context) && view_context
40
+ # Capture ERB content
41
+ -> { raw(view_context.capture(&block)) }
42
+ else
43
+ block
44
+ end
45
+ end
46
+
47
+ def heading(**props, &block)
48
+ @heading_content = -> { render(Panda::Core::Admin::HeadingComponent.new(**props), &block) }
49
+ end
50
+
51
+ def tab_bar(**props, &block)
52
+ @tab_bar_content = -> { render(Panda::Core::Admin::TabBarComponent.new(**props), &block) } if defined?(Panda::Core::Admin::TabBarComponent)
53
+ end
54
+
55
+ def slideover(**props, &block)
56
+ @slideover_content = -> { render(Panda::Core::Admin::SlideoverComponent.new(**props), &block) }
57
+ end
10
58
  end
11
59
  end
12
60
  end
@@ -3,17 +3,82 @@
3
3
  module Panda
4
4
  module Core
5
5
  module Admin
6
- class FlashMessageComponent < ::ViewComponent::Base
7
- attr_reader :kind, :message
6
+ class FlashMessageComponent < Panda::Core::Base
7
+ prop :message, String
8
+ prop :kind, Symbol
9
+ prop :temporary, _Boolean, default: true
8
10
 
9
- def initialize(message:, kind:, temporary: true)
10
- @kind = kind.to_sym
11
- @message = message
12
- @temporary = temporary
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 < ViewComponent::Base
7
- renders_many :buttons, Panda::Core::Admin::ButtonComponent
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
- attr_reader :text, :level, :icon, :additional_styles
12
+ def view_template(&block)
13
+ # Capture any buttons defined via block
14
+ instance_eval(&block) if block_given?
10
15
 
11
- def initialize(text:, level: 2, icon: "", additional_styles: "")
12
- @text = text
13
- @level = level
14
- @icon = icon
15
- @additional_styles = additional_styles
16
- @additional_styles = @additional_styles.split(" ") if @additional_styles.is_a?(String)
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
- def call
20
- output = ""
21
- output += content_tag(:div, @text, class: "grow")
35
+ private
36
+
37
+ def render_content
38
+ div(class: "grow") { @text }
22
39
 
23
- if buttons?
24
- output += content_tag(:span, class: "actions flex gap-x-2 -mt-1") do
25
- safe_join(buttons, "")
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
- output = output.html_safe
30
- base_heading_styles = "flex pt-1 text-black mb-5 -mt-1"
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
- content_tag(:h1, output, class: [base_heading_styles, "text-2xl font-medium", @additional_styles])
51
+ "text-2xl font-medium"
35
52
  when 2
36
- content_tag(:h2, output, class: [base_heading_styles, "text-xl font-medium", @additional_styles])
53
+ "text-xl font-medium"
37
54
  when 3
38
- content_tag(:h3, output, class: [base_heading_styles, "text-xl", "font-light", @additional_styles])
39
- when :panel
40
- content_tag(:h3, output, class: ["text-base font-medium p-4 text-white"])
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 < ViewComponent::Base
7
- renders_one :heading, lambda { |text:, icon: "", level: :panel, additional_styles: ""|
8
- Panda::Core::Admin::HeadingComponent.new(text: text, icon: icon, level: level, additional_styles: additional_styles)
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 < ViewComponent::Base
7
- attr_reader :title
6
+ class SlideoverComponent < Panda::Core::Base
7
+ prop :title, String, default: "Settings"
8
8
 
9
- def initialize(title: "Settings")
10
- @title = title
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