panda-core 0.4.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/tailwind/application.css +95 -0
  3. data/app/assets/tailwind/tailwind.config.js +8 -0
  4. data/app/builders/panda/core/form_builder.rb +163 -11
  5. data/app/components/panda/core/UI/button.rb +45 -24
  6. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  7. data/app/components/panda/core/admin/button_component.rb +27 -12
  8. data/app/components/panda/core/admin/container_component.rb +40 -5
  9. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  10. data/app/components/panda/core/admin/flash_message_component.rb +54 -36
  11. data/app/components/panda/core/admin/heading_component.rb +28 -19
  12. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  13. data/app/components/panda/core/admin/panel_component.rb +1 -1
  14. data/app/components/panda/core/admin/slideover_component.rb +92 -4
  15. data/app/components/panda/core/admin/table_component.rb +11 -11
  16. data/app/components/panda/core/admin/tag_component.rb +39 -2
  17. data/app/components/panda/core/admin/user_display_component.rb +4 -5
  18. data/app/controllers/panda/core/admin/my_profile_controller.rb +10 -3
  19. data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
  20. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  21. data/app/helpers/panda/core/asset_helper.rb +33 -5
  22. data/app/helpers/panda/core/sessions_helper.rb +26 -1
  23. data/app/javascript/panda/core/application.js +8 -1
  24. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  25. data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
  26. data/app/javascript/panda/core/controllers/index.js +9 -3
  27. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
  28. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  29. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  30. data/app/models/panda/core/user.rb +60 -6
  31. data/app/services/panda/core/attach_avatar_service.rb +71 -0
  32. data/app/views/layouts/panda/core/admin.html.erb +39 -14
  33. data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
  34. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -1
  35. data/app/views/panda/core/admin/dashboard/show.html.erb +3 -3
  36. data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
  37. data/app/views/panda/core/admin/sessions/new.html.erb +3 -4
  38. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
  39. data/app/views/panda/core/admin/shared/_sidebar.html.erb +69 -14
  40. data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
  41. data/config/importmap.rb +20 -7
  42. data/config/routes.rb +10 -1
  43. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  44. data/lib/panda/core/asset_loader.rb +5 -2
  45. data/lib/panda/core/engine.rb +38 -28
  46. data/lib/panda/core/oauth_providers.rb +3 -3
  47. data/lib/panda/core/services/base_service.rb +19 -4
  48. data/lib/panda/core/version.rb +1 -1
  49. data/lib/panda/core.rb +1 -0
  50. data/lib/tasks/panda_core_users.rake +158 -0
  51. metadata +13 -69
  52. data/lib/generators/panda/core/authentication/templates/reek_spec.rb +0 -43
  53. data/lib/generators/panda/core/dev_tools/USAGE +0 -24
  54. data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +0 -13
  55. data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +0 -18
  56. data/lib/generators/panda/core/dev_tools_generator.rb +0 -143
  57. data/lib/generators/panda/core/install_generator.rb +0 -41
  58. data/lib/generators/panda/core/templates/README +0 -25
  59. data/lib/generators/panda/core/templates/initializer.rb +0 -44
  60. data/lib/generators/panda/core/templates_generator.rb +0 -27
  61. data/lib/panda/core/testing/capybara_config.rb +0 -70
  62. data/lib/panda/core/testing/omniauth_helpers.rb +0 -52
  63. data/lib/panda/core/testing/rspec_config.rb +0 -72
@@ -6,37 +6,52 @@ module Panda
6
6
  class ButtonComponent < Panda::Core::Base
7
7
  prop :text, String, default: "Button"
8
8
  prop :action, _Nilable(Symbol), default: -> {}
9
- prop :href, String, default: "#"
9
+ prop :href, _Nilable(String), default: -> { "#" }
10
10
  prop :icon, _Nilable(String), default: -> {}
11
11
  prop :size, Symbol, default: :regular
12
12
  prop :id, _Nilable(String), default: -> {}
13
+ prop :as_button, _Boolean, default: -> { false }
13
14
 
14
15
  def view_template
15
- a(**@attrs) do
16
- if computed_icon
17
- i(class: "mr-2 fa-regular fa-#{computed_icon}")
18
- plain " "
16
+ if @as_button
17
+ button(**@attrs) do
18
+ render_content
19
+ end
20
+ else
21
+ a(**@attrs) do
22
+ render_content
19
23
  end
20
- plain @text.titleize
21
24
  end
22
25
  end
23
26
 
24
27
  def default_attrs
25
- {
26
- href: @href,
28
+ base = {
27
29
  class: button_classes,
28
30
  id: @id
29
31
  }
32
+
33
+ if @as_button
34
+ base.merge(type: "button")
35
+ else
36
+ base.merge(href: @href)
37
+ end
30
38
  end
31
39
 
32
40
  private
33
41
 
42
+ def render_content
43
+ if computed_icon
44
+ i(class: "fa-solid fa-#{computed_icon}")
45
+ end
46
+ plain @text.titleize
47
+ end
48
+
34
49
  def computed_icon
35
50
  @computed_icon ||= @icon || icon_from_action(@action)
36
51
  end
37
52
 
38
53
  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 "
54
+ base = "inline-flex items-center rounded-md font-medium shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 cursor-pointer "
40
55
  base + size_classes + action_classes
41
56
  end
42
57
 
@@ -56,15 +71,15 @@ module Panda
56
71
  def action_classes
57
72
  case @action
58
73
  when :save, :create
59
- "text-white bg-green-600 hover:bg-green-700"
74
+ "text-white bg-mid hover:bg-mid/80"
60
75
  when :save_inactive
61
76
  "text-white bg-gray-400"
62
77
  when :secondary
63
- "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all"
78
+ "text-gray-700 border-2 border-gray-500 bg-white hover:bg-gray-100 active:bg-gray-200 transition-colors"
64
79
  when :delete, :destroy, :danger
65
80
  "text-red-600 border border-red-600 bg-red-100 hover:bg-red-200 hover:text-red-700 focus-visible:outline-red-300"
66
81
  else
67
- "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all"
82
+ "text-gray-700 border-2 border-gray-500 bg-white hover:bg-gray-100 active:bg-gray-200 transition-colors"
68
83
  end
69
84
  end
70
85
 
@@ -4,6 +4,8 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class ContainerComponent < Panda::Core::Base
7
+ prop :full_height, _Nilable(_Boolean), default: -> { false }
8
+
7
9
  def view_template(&block)
8
10
  # Capture block content differently based on context (ERB vs Phlex)
9
11
  if block_given?
@@ -16,20 +18,36 @@ module Panda
16
18
  end
17
19
  end
18
20
 
21
+ # Set content_for :sidebar if slideover is present (enables breadcrumb toggle button)
22
+ # This must happen before rendering so the layout can use it
23
+ if @slideover_block && @slideover_title && defined?(view_context) && view_context
24
+ view_context.content_for(:sidebar) do
25
+ # The block contains ERB content, capture it for the sidebar
26
+ view_context.capture(&@slideover_block)
27
+ end
28
+ view_context.content_for(:sidebar_title, @slideover_title)
29
+
30
+ # Set footer content if present
31
+ if @footer_block
32
+ view_context.content_for(:sidebar_footer) do
33
+ view_context.capture(&@footer_block)
34
+ end
35
+ end
36
+ end
37
+
19
38
  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
39
+ div(class: "overflow-auto px-2 pt-2 mx-auto sm:px-6 lg:px-6") do
21
40
  @heading_content&.call
22
41
  @tab_bar_content&.call
23
42
 
24
- section(class: "flex-auto") do
25
- div(class: "flex-1 mt-4 w-full") do
43
+ section(class: section_classes) do
44
+ div(class: "flex-1 mt-4 w-full h-full") do
26
45
  if @main_content
27
46
  @main_content.call
28
47
  elsif @body_html
29
48
  raw(@body_html)
30
49
  end
31
50
  end
32
- @slideover_content&.call
33
51
  end
34
52
  end
35
53
  end
@@ -53,7 +71,24 @@ module Panda
53
71
  end
54
72
 
55
73
  def slideover(**props, &block)
56
- @slideover_content = -> { render(Panda::Core::Admin::SlideoverComponent.new(**props), &block) }
74
+ @slideover_title = props[:title] || "Settings"
75
+ @slideover_block = block # Save the block for content_for
76
+ end
77
+
78
+ def footer(&block)
79
+ @footer_block = block
80
+ end
81
+
82
+ # Alias for ViewComponent-style API compatibility
83
+ alias_method :with_slideover, :slideover
84
+ alias_method :with_footer, :footer
85
+
86
+ private
87
+
88
+ def section_classes
89
+ base = "flex-auto"
90
+ height = @full_height ? "h-[calc(100vh-9rem)]" : nil
91
+ [base, height].compact.join(" ")
57
92
  end
58
93
  end
59
94
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class FileGalleryComponent < Panda::Core::Base
7
+ prop :files, _Nilable(Object), default: -> { [] }
8
+ prop :selected_file, _Nilable(Object), default: nil
9
+
10
+ def view_template
11
+ if @files.any?
12
+ render_gallery
13
+ else
14
+ render_empty_state
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def render_gallery
21
+ section do
22
+ h2(id: "gallery-heading", class: "sr-only") { "Files" }
23
+ ul(
24
+ role: "list",
25
+ class: "grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8"
26
+ ) do
27
+ @files.each do |file|
28
+ render_file_item(file)
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ def render_file_item(file)
35
+ is_selected = @selected_file && @selected_file.id == file.id
36
+
37
+ li(class: "relative") do
38
+ div(
39
+ class: file_container_classes(is_selected),
40
+ style: "cursor: pointer;"
41
+ ) do
42
+ if file.image?
43
+ img(
44
+ src: url_for(file),
45
+ alt: file.filename.to_s,
46
+ class: file_image_classes(is_selected)
47
+ )
48
+ else
49
+ render_file_icon(file)
50
+ end
51
+
52
+ button(
53
+ type: "button",
54
+ class: "absolute inset-0 focus:outline-hidden",
55
+ data: {
56
+ action: "click->file-gallery#selectFile",
57
+ file_id: file.id,
58
+ file_url: url_for(file),
59
+ file_name: file.filename.to_s,
60
+ file_size: file.byte_size,
61
+ file_type: file.content_type,
62
+ file_created: file.created_at.to_s
63
+ }
64
+ ) do
65
+ span(class: "sr-only") { "View details for #{file.filename}" }
66
+ end
67
+ end
68
+
69
+ p(class: "pointer-events-none mt-2 block truncate text-sm font-medium text-gray-900 dark:text-white") do
70
+ plain file.filename.to_s
71
+ end
72
+ p(class: "pointer-events-none block text-sm font-medium text-gray-500 dark:text-gray-400") do
73
+ plain number_to_human_size(file.byte_size)
74
+ end
75
+ end
76
+ end
77
+
78
+ def file_container_classes(selected)
79
+ base = "group overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
80
+ focus = if selected
81
+ "outline-2 outline-offset-2 outline-panda-dark dark:outline-panda-light outline"
82
+ else
83
+ "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-indigo-600 dark:focus-within:outline-indigo-500"
84
+ end
85
+ "#{base} #{focus}"
86
+ end
87
+
88
+ def file_image_classes(selected)
89
+ base = "pointer-events-none aspect-10/7 rounded-lg object-cover outline -outline-offset-1 outline-black/5 dark:outline-white/10"
90
+ hover = selected ? "" : "group-hover:opacity-75"
91
+ "#{base} #{hover}"
92
+ end
93
+
94
+ def render_file_icon(file)
95
+ div(class: "flex items-center justify-center h-full") do
96
+ div(class: "text-center") do
97
+ svg(
98
+ class: "mx-auto h-12 w-12 text-gray-400",
99
+ fill: "none",
100
+ viewBox: "0 0 24 24",
101
+ stroke: "currentColor",
102
+ aria: {hidden: "true"}
103
+ ) do
104
+ path(
105
+ stroke_linecap: "round",
106
+ stroke_linejoin: "round",
107
+ d: "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
108
+ )
109
+ end
110
+ p(class: "mt-1 text-xs text-gray-500 uppercase") { file.content_type&.split("/")&.last || "file" }
111
+ end
112
+ end
113
+ end
114
+
115
+ def render_empty_state
116
+ div(class: "text-center py-12 border border-dashed rounded-lg") do
117
+ svg(
118
+ class: "mx-auto h-12 w-12 text-gray-400",
119
+ fill: "none",
120
+ viewBox: "0 0 24 24",
121
+ stroke: "currentColor",
122
+ aria: {hidden: "true"}
123
+ ) do
124
+ path(
125
+ stroke_linecap: "round",
126
+ stroke_linejoin: "round",
127
+ d: "M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
128
+ )
129
+ end
130
+ h3(class: "mt-2 text-sm font-semibold text-gray-900") { "No files" }
131
+ p(class: "mt-1 text-sm text-gray-500") { "Get started by uploading a file." }
132
+ end
133
+ end
134
+
135
+ # Helper method to generate URL for ActiveStorage attachment
136
+ def url_for(file)
137
+ if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:rails_blob_path)
138
+ Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)
139
+ else
140
+ "#"
141
+ end
142
+ end
143
+
144
+ # Helper method for human-readable file sizes
145
+ def number_to_human_size(size)
146
+ return "0 Bytes" if size.zero?
147
+
148
+ units = ["Bytes", "KB", "MB", "GB", "TB"]
149
+ exp = (Math.log(size) / Math.log(1024)).to_i
150
+ exp = [exp, units.length - 1].min
151
+
152
+ "%.1f %s" % [size.to_f / (1024**exp), units[exp]]
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -7,15 +7,23 @@ module Panda
7
7
  prop :message, String
8
8
  prop :kind, Symbol
9
9
  prop :temporary, _Boolean, default: true
10
+ prop :subtitle, _Nilable(String), default: -> {}
10
11
 
11
12
  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
13
+ # Global notification container (fixed position)
14
+ div(
15
+ aria: {live: "assertive"},
16
+ class: "pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
17
+ ) do
18
+ div(class: "flex w-full flex-col items-center space-y-4 sm:items-end") do
19
+ # Notification panel with Tailwind Plus styling
20
+ div(**notification_attrs) do
21
+ div(class: "p-4") do
22
+ div(class: "flex items-start") do
23
+ render_icon
24
+ render_content
25
+ render_close_button
26
+ end
19
27
  end
20
28
  end
21
29
  end
@@ -24,71 +32,81 @@ module Panda
24
32
 
25
33
  private
26
34
 
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",
35
+ def notification_attrs
36
+ {
37
+ class: "pointer-events-auto w-full max-w-sm translate-y-0 transform rounded-lg bg-white opacity-100 shadow-lg transition duration-300 ease-out sm:translate-x-0 dark:bg-gray-800 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0 #{border_color_css}",
30
38
  data: {
31
39
  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
- }
40
+ alert_dismiss_after_value: (@temporary ? "5000" : nil)
41
+ }.compact
39
42
  }
40
-
41
- attrs[:data][:alert_dismiss_after_value] = "3000" if @temporary
42
- attrs
43
43
  end
44
44
 
45
45
  def render_icon
46
- div(class: "flex-shrink-0") do
47
- i(class: "fa-regular text-xl #{icon_css} #{text_colour_css}")
46
+ div(class: "shrink-0") do
47
+ i(class: "fa-solid size-6 #{icon_css} #{icon_colour_css}")
48
48
  end
49
49
  end
50
50
 
51
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 }
52
+ div(class: "ml-3 w-0 flex-1 pt-0.5") do
53
+ p(class: "text-sm font-medium text-gray-900 dark:text-white flash-message-title") { @message }
54
+ if @subtitle
55
+ p(class: "mt-1 text-sm text-gray-500 dark:text-gray-400 flash-message-subtitle") { @subtitle }
56
+ end
55
57
  end
56
58
  end
57
59
 
58
60
  def render_close_button
59
- div(class: "flex flex-shrink-0 ml-4") do
61
+ div(class: "ml-4 flex shrink-0") do
60
62
  button(
61
63
  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",
64
+ class: "inline-flex rounded-md text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 dark:hover:text-white dark:focus:outline-blue-500",
63
65
  data: {action: "alert#close"}
64
66
  ) do
65
67
  span(class: "sr-only") { "Close" }
66
68
  svg(
67
- class: "w-5 h-5",
68
69
  viewBox: "0 0 20 20",
69
70
  fill: "currentColor",
70
- aria: {hidden: "true"}
71
+ data: {slot: "icon"},
72
+ aria: {hidden: "true"},
73
+ class: "size-5"
71
74
  ) do |s|
72
75
  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"
76
+ d: "M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
74
77
  )
75
78
  end
76
79
  end
77
80
  end
78
81
  end
79
82
 
80
- def text_colour_css
83
+ def icon_colour_css
84
+ case @kind
85
+ when :success
86
+ "text-green-400 dark:text-green-500"
87
+ when :alert, :error
88
+ "text-red-400 dark:text-red-500"
89
+ when :warning
90
+ "text-yellow-400 dark:text-yellow-500"
91
+ when :info, :notice
92
+ "text-blue-400 dark:text-blue-500"
93
+ else
94
+ "text-gray-400 dark:text-gray-500"
95
+ end
96
+ end
97
+
98
+ def border_color_css
81
99
  case @kind
82
100
  when :success
83
- "text-green-600"
101
+ "ring-2 ring-green-400/20 dark:ring-green-500/30"
84
102
  when :alert, :error
85
- "text-red-600"
103
+ "ring-2 ring-red-400/20 dark:ring-red-500/30"
86
104
  when :warning
87
- "text-yellow-600"
105
+ "ring-2 ring-yellow-400/20 dark:ring-yellow-500/30"
88
106
  when :info, :notice
89
- "text-blue-600"
107
+ "ring-2 ring-blue-400/20 dark:ring-blue-500/30"
90
108
  else
91
- "text-gray-600"
109
+ "ring-1 ring-gray-400/10 dark:ring-gray-500/20"
92
110
  end
93
111
  end
94
112
 
@@ -7,23 +7,30 @@ module Panda
7
7
  prop :text, String
8
8
  prop :level, _Nilable(_Union(Integer, Symbol)), default: -> { 2 }
9
9
  prop :icon, String, default: ""
10
+ prop :meta, _Nilable(String), default: -> {}
10
11
  prop :additional_styles, _Nilable(_Union(String, Array)), default: -> { "" }
11
12
 
12
13
  def view_template(&block)
13
14
  # Capture any buttons defined via block
14
15
  instance_eval(&block) if block_given?
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 }
17
+ div(class: "heading-wrapper") do
18
+ case @level
19
+ when 1
20
+ h1(class: heading_classes(@meta.present?)) { render_content }
21
+ when 2
22
+ h2(class: heading_classes(@meta.present?)) { render_content }
23
+ when 3
24
+ h3(class: heading_classes(@meta.present?)) { render_content }
25
+ when :panel
26
+ h3(class: panel_heading_classes) { @text }
27
+ else
28
+ h2(class: heading_classes(@meta.present?)) { render_content }
29
+ end
30
+
31
+ if @meta
32
+ p(class: "text-sm text-black/60 -mt-1 mb-5") { raw(@meta) }
33
+ end
27
34
  end
28
35
  end
29
36
 
@@ -35,17 +42,19 @@ module Panda
35
42
  private
36
43
 
37
44
  def render_content
38
- div(class: "grow") { @text }
45
+ div(class: "grow flex items-center gap-x-2") do
46
+ i(class: @icon) if @icon.present?
47
+ span { @text }
48
+ end
39
49
 
40
- if @buttons&.any?
41
- span(class: "actions flex gap-x-2 -mt-1") do
42
- @buttons.each { |btn| render(btn) }
43
- end
50
+ span(class: "actions flex gap-x-2 mt-1 min-h-[2.5rem]") do
51
+ @buttons&.each { |btn| render(btn) }
44
52
  end
45
53
  end
46
54
 
47
- def heading_classes
48
- base = "flex pt-1 text-black mb-5 -mt-1"
55
+ def heading_classes(has_meta = false)
56
+ margin_bottom = has_meta ? "mb-0.5" : "mb-5"
57
+ base = "flex text-black #{margin_bottom} -mt-2"
49
58
  styles = case @level
50
59
  when 1
51
60
  "text-2xl font-medium"
@@ -61,7 +70,7 @@ module Panda
61
70
  end
62
71
 
63
72
  def panel_heading_classes
64
- "text-base font-medium p-4 text-white"
73
+ "text-base font-medium px-4 py-3 text-white"
65
74
  end
66
75
 
67
76
  def additional_styles_array
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Page header component with title, optional breadcrumbs, and action buttons.
7
+ #
8
+ # Follows Tailwind UI Plus pattern for page headers with responsive layout
9
+ # and support for multiple action buttons.
10
+ #
11
+ # @example Basic header with title only
12
+ # render Panda::Core::Admin::PageHeaderComponent.new(
13
+ # title: "Back End Developer"
14
+ # )
15
+ #
16
+ # @example Header with breadcrumbs
17
+ # render Panda::Core::Admin::PageHeaderComponent.new(
18
+ # title: "Back End Developer",
19
+ # breadcrumbs: [
20
+ # { text: "Jobs", href: "/admin/jobs" },
21
+ # { text: "Engineering", href: "/admin/jobs/engineering" },
22
+ # { text: "Back End Developer", href: "/admin/jobs/engineering/1" }
23
+ # ]
24
+ # )
25
+ #
26
+ # @example Header with action buttons using block
27
+ # render Panda::Core::Admin::PageHeaderComponent.new(
28
+ # title: "Back End Developer",
29
+ # breadcrumbs: breadcrumb_items
30
+ # ) do |header|
31
+ # header.button(text: "Edit", variant: :secondary, href: edit_path)
32
+ # header.button(text: "Publish", variant: :primary, href: publish_path)
33
+ # end
34
+ #
35
+ class PageHeaderComponent < Panda::Core::Base
36
+ prop :title, String
37
+ prop :breadcrumbs, _Nilable(Array), default: -> {}
38
+ prop :show_back, _Boolean, default: true
39
+
40
+ def initialize(**props)
41
+ super
42
+ @buttons = []
43
+ end
44
+
45
+ def view_template(&block)
46
+ # Allow buttons to be defined via block
47
+ instance_eval(&block) if block_given?
48
+
49
+ div do
50
+ # Breadcrumbs section
51
+ if @breadcrumbs
52
+ render Panda::Core::Admin::BreadcrumbComponent.new(
53
+ items: @breadcrumbs,
54
+ show_back: @show_back
55
+ )
56
+ end
57
+
58
+ # Title and actions section
59
+ div(class: "mt-2 md:flex md:items-center md:justify-between") do
60
+ # Title
61
+ div(class: "min-w-0 flex-1") do
62
+ h2(class: "text-2xl/7 font-bold text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight dark:text-white") do
63
+ @title
64
+ end
65
+ end
66
+
67
+ # Action buttons
68
+ if @buttons.any?
69
+ div(class: "mt-4 flex shrink-0 md:mt-0 md:ml-4") do
70
+ @buttons.each_with_index do |button_data, index|
71
+ render create_button(button_data, index)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ # Define a button to be rendered in the header actions area
80
+ #
81
+ # @param text [String] Button text
82
+ # @param variant [Symbol] Button variant (:primary or :secondary)
83
+ # @param href [String] Link href
84
+ # @param props [Hash] Additional button properties
85
+ def button(text:, variant: :secondary, href: "#", **props)
86
+ @buttons << {text: text, variant: variant, href: href, **props}
87
+ end
88
+
89
+ private
90
+
91
+ def create_button(button_data, index)
92
+ Panda::Core::UI::Button.new(
93
+ text: button_data[:text],
94
+ variant: button_data[:variant],
95
+ href: button_data[:href],
96
+ class: button_margin_class(index),
97
+ **button_data.except(:text, :variant, :href)
98
+ )
99
+ end
100
+
101
+ def button_margin_class(index)
102
+ index.zero? ? "" : "ml-3"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -16,7 +16,7 @@ module Panda
16
16
  end
17
17
  end
18
18
 
19
- div(class: "col-span-3 mt-5 rounded-lg shadow-md bg-gray-500 shadow-inherit/20") do
19
+ div(class: "col-span-3 mt-5 rounded-lg shadow-md bg-dark shadow-inherit/20") do
20
20
  @heading_content&.call
21
21
 
22
22
  div(class: "p-4 text-black bg-white rounded-b-lg") do