panda-core 0.2.4 → 0.6.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/tailwind/application.css +199 -7
  3. data/app/assets/tailwind/tailwind.config.js +8 -0
  4. data/app/components/panda/core/UI/badge.rb +107 -0
  5. data/app/components/panda/core/UI/button.rb +110 -0
  6. data/app/components/panda/core/UI/card.rb +88 -0
  7. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  8. data/app/components/panda/core/admin/button_component.rb +46 -28
  9. data/app/components/panda/core/admin/container_component.rb +75 -4
  10. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  11. data/app/components/panda/core/admin/flash_message_component.rb +98 -15
  12. data/app/components/panda/core/admin/form_error_component.rb +48 -0
  13. data/app/components/panda/core/admin/form_input_component.rb +50 -0
  14. data/app/components/panda/core/admin/form_select_component.rb +68 -0
  15. data/app/components/panda/core/admin/heading_component.rb +53 -24
  16. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  17. data/app/components/panda/core/admin/panel_component.rb +33 -4
  18. data/app/components/panda/core/admin/slideover_component.rb +66 -4
  19. data/app/components/panda/core/admin/statistics_component.rb +19 -0
  20. data/app/components/panda/core/admin/tab_bar_component.rb +101 -0
  21. data/app/components/panda/core/admin/table_component.rb +92 -11
  22. data/app/components/panda/core/admin/tag_component.rb +58 -16
  23. data/app/components/panda/core/admin/user_activity_component.rb +43 -0
  24. data/app/components/panda/core/admin/user_display_component.rb +77 -0
  25. data/app/components/panda/core/base.rb +122 -0
  26. data/app/controllers/panda/core/admin/base_controller.rb +68 -0
  27. data/app/controllers/panda/core/admin/dashboard_controller.rb +5 -3
  28. data/app/controllers/panda/core/admin/my_profile_controller.rb +4 -4
  29. data/app/controllers/panda/core/admin/sessions_controller.rb +15 -8
  30. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  31. data/app/helpers/panda/core/asset_helper.rb +31 -5
  32. data/app/helpers/panda/core/sessions_helper.rb +27 -2
  33. data/app/javascript/panda/core/application.js +8 -1
  34. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  35. data/app/javascript/panda/core/controllers/index.js +3 -3
  36. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  37. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  38. data/app/javascript/panda/core/vendor/@hotwired--stimulus.js +4 -0
  39. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +160 -0
  40. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +4 -0
  41. data/app/models/panda/core/user.rb +61 -14
  42. data/app/services/panda/core/attach_avatar_service.rb +67 -0
  43. data/app/views/layouts/panda/core/admin.html.erb +40 -3
  44. data/app/views/layouts/panda/core/admin_simple.html.erb +6 -0
  45. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +4 -4
  46. data/app/views/panda/core/admin/dashboard/show.html.erb +2 -2
  47. data/app/views/panda/core/admin/my_profile/edit.html.erb +36 -25
  48. data/app/views/panda/core/admin/sessions/new.html.erb +9 -10
  49. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +27 -34
  50. data/app/views/panda/core/admin/shared/_flash.html.erb +4 -30
  51. data/app/views/panda/core/admin/shared/_sidebar.html.erb +41 -20
  52. data/app/views/panda/core/shared/_header.html.erb +13 -5
  53. data/config/importmap.rb +19 -6
  54. data/config/routes.rb +10 -3
  55. data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
  56. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  57. data/lib/panda/core/asset_loader.rb +23 -8
  58. data/lib/panda/core/configuration.rb +12 -9
  59. data/lib/panda/core/debug.rb +47 -0
  60. data/lib/panda/core/engine.rb +55 -9
  61. data/lib/panda/core/services/base_service.rb +19 -4
  62. data/lib/panda/core/version.rb +1 -1
  63. data/lib/panda/core.rb +2 -0
  64. data/lib/tasks/panda_core_users.rake +158 -0
  65. metadata +103 -14
  66. data/app/components/panda/core/admin/container_component.html.erb +0 -12
  67. data/app/components/panda/core/admin/flash_message_component.html.erb +0 -31
  68. data/app/components/panda/core/admin/panel_component.html.erb +0 -7
  69. data/app/components/panda/core/admin/slideover_component.html.erb +0 -9
  70. data/app/components/panda/core/admin/table_component.html.erb +0 -29
  71. data/app/controllers/panda/core/admin_controller.rb +0 -30
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Breadcrumb navigation component with responsive behavior.
7
+ #
8
+ # Shows a "Back" link on mobile and full breadcrumb trail on larger screens.
9
+ # Follows Tailwind UI Plus pattern for breadcrumb navigation.
10
+ #
11
+ # @example Basic breadcrumbs
12
+ # render Panda::Core::Admin::BreadcrumbComponent.new(
13
+ # items: [
14
+ # { text: "Pages", href: "/admin/pages" },
15
+ # { text: "Blog Posts", href: "/admin/pages/blog" },
16
+ # { text: "Edit Post", href: "/admin/pages/blog/1/edit" }
17
+ # ]
18
+ # )
19
+ #
20
+ # @example Without back link (first page in section)
21
+ # render Panda::Core::Admin::BreadcrumbComponent.new(
22
+ # items: [
23
+ # { text: "Dashboard", href: "/admin" }
24
+ # ],
25
+ # show_back: false
26
+ # )
27
+ #
28
+ class BreadcrumbComponent < Panda::Core::Base
29
+ prop :items, Array, default: -> { [] }
30
+ prop :show_back, _Boolean, default: true
31
+
32
+ def view_template
33
+ div do
34
+ # Mobile back link
35
+ if @show_back && @items.any?
36
+ nav(
37
+ aria: {label: "Back"},
38
+ class: "sm:hidden"
39
+ ) do
40
+ a(
41
+ href: back_link_href,
42
+ class: "flex items-center text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
43
+ ) do
44
+ render_chevron_left_icon
45
+ plain "Back"
46
+ end
47
+ end
48
+ end
49
+
50
+ # Desktop breadcrumb trail
51
+ nav(
52
+ aria: {label: "Breadcrumb"},
53
+ class: "hidden sm:flex"
54
+ ) do
55
+ ol(
56
+ role: "list",
57
+ class: "flex items-center space-x-4"
58
+ ) do
59
+ @items.each_with_index do |item, index|
60
+ li do
61
+ if index.zero?
62
+ # First item (no separator)
63
+ div(class: "flex") do
64
+ a(
65
+ href: item[:href],
66
+ class: breadcrumb_link_classes(index)
67
+ ) { item[:text] }
68
+ end
69
+ else
70
+ # Subsequent items (with separator)
71
+ div(class: "flex items-center") do
72
+ render_chevron_right_icon
73
+ a(
74
+ href: item[:href],
75
+ aria: ((index == @items.length - 1) ? {current: "page"} : nil),
76
+ class: breadcrumb_link_classes(index)
77
+ ) { item[:text] }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def back_link_href
90
+ (@items.length > 1) ? @items[-2][:href] : @items.first[:href]
91
+ end
92
+
93
+ def breadcrumb_link_classes(index)
94
+ base = "text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
95
+ base += " ml-4" unless index.zero?
96
+ base
97
+ end
98
+
99
+ def render_chevron_left_icon
100
+ svg(
101
+ viewBox: "0 0 20 20",
102
+ fill: "currentColor",
103
+ data: {slot: "icon"},
104
+ aria: {hidden: "true"},
105
+ class: "mr-1 -ml-1 size-5 shrink-0 text-gray-400 dark:text-gray-500"
106
+ ) do |s|
107
+ s.path(
108
+ d: "M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z",
109
+ clip_rule: "evenodd",
110
+ fill_rule: "evenodd"
111
+ )
112
+ end
113
+ end
114
+
115
+ def render_chevron_right_icon
116
+ svg(
117
+ viewBox: "0 0 20 20",
118
+ fill: "currentColor",
119
+ data: {slot: "icon"},
120
+ aria: {hidden: "true"},
121
+ class: "size-5 shrink-0 text-gray-400 dark:text-gray-500"
122
+ ) do |s|
123
+ s.path(
124
+ d: "M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z",
125
+ clip_rule: "evenodd",
126
+ fill_rule: "evenodd"
127
+ )
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ 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-solid 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,81 @@
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
+ prop :full_height, _Nilable(_Boolean), default: -> { false }
8
+
9
+ def view_template(&block)
10
+ # Capture block content differently based on context (ERB vs Phlex)
11
+ if block_given?
12
+ if defined?(view_context) && view_context
13
+ # Called from ERB - capture HTML output
14
+ @body_html = view_context.capture { yield(self) }
15
+ else
16
+ # Called from Phlex - execute block directly to set instance variables
17
+ yield(self)
18
+ end
19
+ end
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
+ end
30
+
31
+ main(class: "overflow-auto flex-1 h-full min-h-full max-h-full") do
32
+ div(class: "overflow-auto px-2 pt-4 mx-auto sm:px-6 lg:px-6") do
33
+ @heading_content&.call
34
+ @tab_bar_content&.call
35
+
36
+ section(class: section_classes) do
37
+ div(class: "flex-1 mt-4 w-full h-full") do
38
+ if @main_content
39
+ @main_content.call
40
+ elsif @body_html
41
+ raw(@body_html)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ def content(&block)
50
+ @main_content = if defined?(view_context) && view_context
51
+ # Capture ERB content
52
+ -> { raw(view_context.capture(&block)) }
53
+ else
54
+ block
55
+ end
56
+ end
57
+
58
+ def heading(**props, &block)
59
+ @heading_content = -> { render(Panda::Core::Admin::HeadingComponent.new(**props), &block) }
60
+ end
61
+
62
+ def tab_bar(**props, &block)
63
+ @tab_bar_content = -> { render(Panda::Core::Admin::TabBarComponent.new(**props), &block) } if defined?(Panda::Core::Admin::TabBarComponent)
64
+ end
65
+
66
+ def slideover(**props, &block)
67
+ @slideover_title = props[:title] || "Settings"
68
+ @slideover_block = block # Save the block for content_for
69
+ end
70
+
71
+ # Alias for ViewComponent-style API compatibility
72
+ alias_method :with_slideover, :slideover
73
+
74
+ private
75
+
76
+ def section_classes
77
+ base = "flex-auto"
78
+ height = @full_height ? "h-[calc(100vh-9rem)]" : nil
79
+ [base, height].compact.join(" ")
80
+ end
10
81
  end
11
82
  end
12
83
  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
@@ -3,35 +3,118 @@
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
10
+ prop :subtitle, _Nilable(String), default: -> {}
8
11
 
9
- def initialize(message:, kind:, temporary: true)
10
- @kind = kind.to_sym
11
- @message = message
12
- @temporary = temporary
12
+ def view_template
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
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
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}",
38
+ data: {
39
+ controller: "alert",
40
+ alert_dismiss_after_value: (@temporary ? "5000" : nil)
41
+ }.compact
42
+ }
43
+ end
44
+
45
+ def render_icon
46
+ div(class: "shrink-0") do
47
+ i(class: "fa-solid size-6 #{icon_css} #{icon_colour_css}")
48
+ end
49
+ end
50
+
51
+ def render_content
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
57
+ end
58
+ end
59
+
60
+ def render_close_button
61
+ div(class: "ml-4 flex shrink-0") do
62
+ button(
63
+ type: "button",
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",
65
+ data: {action: "alert#close"}
66
+ ) do
67
+ span(class: "sr-only") { "Close" }
68
+ svg(
69
+ viewBox: "0 0 20 20",
70
+ fill: "currentColor",
71
+ data: {slot: "icon"},
72
+ aria: {hidden: "true"},
73
+ class: "size-5"
74
+ ) do |s|
75
+ s.path(
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"
77
+ )
78
+ end
79
+ end
80
+ end
13
81
  end
14
82
 
15
- def text_colour_css
16
- case kind
83
+ def icon_colour_css
84
+ case @kind
17
85
  when :success
18
- "text-green-600"
86
+ "text-green-400 dark:text-green-500"
19
87
  when :alert, :error
20
- "text-red-600"
88
+ "text-red-400 dark:text-red-500"
21
89
  when :warning
22
- "text-yellow-600"
90
+ "text-yellow-400 dark:text-yellow-500"
23
91
  when :info, :notice
24
- "text-blue-600"
92
+ "text-blue-400 dark:text-blue-500"
25
93
  else
26
- "text-gray-600"
94
+ "text-gray-400 dark:text-gray-500"
95
+ end
96
+ end
97
+
98
+ def border_color_css
99
+ case @kind
100
+ when :success
101
+ "ring-2 ring-green-400/20 dark:ring-green-500/30"
102
+ when :alert, :error
103
+ "ring-2 ring-red-400/20 dark:ring-red-500/30"
104
+ when :warning
105
+ "ring-2 ring-yellow-400/20 dark:ring-yellow-500/30"
106
+ when :info, :notice
107
+ "ring-2 ring-blue-400/20 dark:ring-blue-500/30"
108
+ else
109
+ "ring-1 ring-gray-400/10 dark:ring-gray-500/20"
27
110
  end
28
111
  end
29
112
 
30
113
  def icon_css
31
- case kind
114
+ case @kind
32
115
  when :success
33
116
  "fa-circle-check"
34
- when :alert
117
+ when :alert, :error
35
118
  "fa-circle-xmark"
36
119
  when :warning
37
120
  "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