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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/tailwind/application.css +104 -7
  3. data/app/components/panda/core/UI/badge.rb +107 -0
  4. data/app/components/panda/core/UI/button.rb +89 -0
  5. data/app/components/panda/core/UI/card.rb +88 -0
  6. data/app/components/panda/core/admin/button_component.rb +46 -28
  7. data/app/components/panda/core/admin/container_component.rb +52 -4
  8. data/app/components/panda/core/admin/flash_message_component.rb +74 -9
  9. data/app/components/panda/core/admin/form_error_component.rb +48 -0
  10. data/app/components/panda/core/admin/form_input_component.rb +50 -0
  11. data/app/components/panda/core/admin/form_select_component.rb +68 -0
  12. data/app/components/panda/core/admin/heading_component.rb +52 -24
  13. data/app/components/panda/core/admin/panel_component.rb +33 -4
  14. data/app/components/panda/core/admin/slideover_component.rb +8 -4
  15. data/app/components/panda/core/admin/statistics_component.rb +19 -0
  16. data/app/components/panda/core/admin/tab_bar_component.rb +101 -0
  17. data/app/components/panda/core/admin/table_component.rb +90 -9
  18. data/app/components/panda/core/admin/tag_component.rb +21 -16
  19. data/app/components/panda/core/admin/user_activity_component.rb +43 -0
  20. data/app/components/panda/core/admin/user_display_component.rb +78 -0
  21. data/app/components/panda/core/base.rb +122 -0
  22. data/app/controllers/panda/core/admin/base_controller.rb +68 -0
  23. data/app/controllers/panda/core/admin/dashboard_controller.rb +5 -3
  24. data/app/controllers/panda/core/admin/my_profile_controller.rb +3 -3
  25. data/app/controllers/panda/core/admin/sessions_controller.rb +9 -6
  26. data/app/helpers/panda/core/sessions_helper.rb +1 -1
  27. data/app/javascript/panda/core/vendor/@hotwired--stimulus.js +4 -0
  28. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +160 -0
  29. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +4 -0
  30. data/app/models/panda/core/user.rb +17 -13
  31. data/app/views/layouts/panda/core/admin.html.erb +40 -3
  32. data/app/views/layouts/panda/core/admin_simple.html.erb +5 -0
  33. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +4 -4
  34. data/app/views/panda/core/admin/dashboard/show.html.erb +2 -2
  35. data/app/views/panda/core/admin/my_profile/edit.html.erb +13 -27
  36. data/app/views/panda/core/admin/sessions/new.html.erb +7 -7
  37. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +27 -34
  38. data/app/views/panda/core/admin/shared/_flash.html.erb +4 -30
  39. data/app/views/panda/core/admin/shared/_sidebar.html.erb +36 -20
  40. data/app/views/panda/core/shared/_header.html.erb +13 -5
  41. data/config/importmap.rb +11 -6
  42. data/config/routes.rb +2 -4
  43. data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
  44. data/lib/panda/core/asset_loader.rb +23 -8
  45. data/lib/panda/core/configuration.rb +12 -9
  46. data/lib/panda/core/debug.rb +47 -0
  47. data/lib/panda/core/engine.rb +43 -6
  48. data/lib/panda/core/version.rb +1 -1
  49. data/lib/panda/core.rb +1 -0
  50. metadata +93 -14
  51. data/app/components/panda/core/admin/container_component.html.erb +0 -12
  52. data/app/components/panda/core/admin/flash_message_component.html.erb +0 -31
  53. data/app/components/panda/core/admin/panel_component.html.erb +0 -7
  54. data/app/components/panda/core/admin/slideover_component.html.erb +0 -9
  55. data/app/components/panda/core/admin/table_component.html.erb +0 -29
  56. 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 < ::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
@@ -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 < ViewComponent::Base
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(term:, rows:)
10
- @term = term
11
- @rows = rows
12
+ def initialize(**props)
13
+ super
12
14
  @columns = []
13
15
  end
14
16
 
15
- def column(label, &)
16
- @columns << Column.new(label, &)
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
- # Ensures @columns gets populated [https://dev.to/rolandstuder/supercharged-table-component-built-with-viewcomponent-3j6i]
22
- def before_render
23
- content
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