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.
- checksums.yaml +4 -4
- data/app/assets/tailwind/application.css +199 -7
- data/app/assets/tailwind/tailwind.config.js +8 -0
- data/app/components/panda/core/UI/badge.rb +107 -0
- data/app/components/panda/core/UI/button.rb +110 -0
- data/app/components/panda/core/UI/card.rb +88 -0
- data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
- data/app/components/panda/core/admin/button_component.rb +46 -28
- data/app/components/panda/core/admin/container_component.rb +75 -4
- data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
- data/app/components/panda/core/admin/flash_message_component.rb +98 -15
- data/app/components/panda/core/admin/form_error_component.rb +48 -0
- data/app/components/panda/core/admin/form_input_component.rb +50 -0
- data/app/components/panda/core/admin/form_select_component.rb +68 -0
- data/app/components/panda/core/admin/heading_component.rb +53 -24
- data/app/components/panda/core/admin/page_header_component.rb +107 -0
- data/app/components/panda/core/admin/panel_component.rb +33 -4
- data/app/components/panda/core/admin/slideover_component.rb +66 -4
- data/app/components/panda/core/admin/statistics_component.rb +19 -0
- data/app/components/panda/core/admin/tab_bar_component.rb +101 -0
- data/app/components/panda/core/admin/table_component.rb +92 -11
- data/app/components/panda/core/admin/tag_component.rb +58 -16
- data/app/components/panda/core/admin/user_activity_component.rb +43 -0
- data/app/components/panda/core/admin/user_display_component.rb +77 -0
- data/app/components/panda/core/base.rb +122 -0
- data/app/controllers/panda/core/admin/base_controller.rb +68 -0
- data/app/controllers/panda/core/admin/dashboard_controller.rb +5 -3
- data/app/controllers/panda/core/admin/my_profile_controller.rb +4 -4
- data/app/controllers/panda/core/admin/sessions_controller.rb +15 -8
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
- data/app/helpers/panda/core/asset_helper.rb +31 -5
- data/app/helpers/panda/core/sessions_helper.rb +27 -2
- data/app/javascript/panda/core/application.js +8 -1
- data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
- data/app/javascript/panda/core/controllers/index.js +3 -3
- data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
- data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
- data/app/javascript/panda/core/vendor/@hotwired--stimulus.js +4 -0
- data/app/javascript/panda/core/vendor/@hotwired--turbo.js +160 -0
- data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +4 -0
- data/app/models/panda/core/user.rb +61 -14
- data/app/services/panda/core/attach_avatar_service.rb +67 -0
- data/app/views/layouts/panda/core/admin.html.erb +40 -3
- data/app/views/layouts/panda/core/admin_simple.html.erb +6 -0
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +4 -4
- data/app/views/panda/core/admin/dashboard/show.html.erb +2 -2
- data/app/views/panda/core/admin/my_profile/edit.html.erb +36 -25
- data/app/views/panda/core/admin/sessions/new.html.erb +9 -10
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +27 -34
- data/app/views/panda/core/admin/shared/_flash.html.erb +4 -30
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +41 -20
- data/app/views/panda/core/shared/_header.html.erb +13 -5
- data/config/importmap.rb +19 -6
- data/config/routes.rb +10 -3
- data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
- data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
- data/lib/panda/core/asset_loader.rb +23 -8
- data/lib/panda/core/configuration.rb +12 -9
- data/lib/panda/core/debug.rb +47 -0
- data/lib/panda/core/engine.rb +55 -9
- data/lib/panda/core/services/base_service.rb +19 -4
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +2 -0
- data/lib/tasks/panda_core_users.rake +158 -0
- metadata +103 -14
- data/app/components/panda/core/admin/container_component.html.erb +0 -12
- data/app/components/panda/core/admin/flash_message_component.html.erb +0 -31
- data/app/components/panda/core/admin/panel_component.html.erb +0 -7
- data/app/components/panda/core/admin/slideover_component.html.erb +0 -9
- data/app/components/panda/core/admin/table_component.html.erb +0 -29
- data/app/controllers/panda/core/admin_controller.rb +0 -30
|
@@ -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 <
|
|
7
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def computed_icon
|
|
35
|
+
@computed_icon ||= @icon || icon_from_action(@action)
|
|
36
|
+
end
|
|
23
37
|
|
|
24
|
-
|
|
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
|
-
|
|
46
|
+
"gap-x-1.5 px-2.5 py-1.5 text-sm "
|
|
29
47
|
when :medium, :regular, :md
|
|
30
|
-
|
|
48
|
+
"gap-x-1.5 px-3 py-2 text-base "
|
|
31
49
|
when :large, :lg
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 < ::
|
|
7
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
16
|
-
case kind
|
|
83
|
+
def icon_colour_css
|
|
84
|
+
case @kind
|
|
17
85
|
when :success
|
|
18
|
-
"text-green-
|
|
86
|
+
"text-green-400 dark:text-green-500"
|
|
19
87
|
when :alert, :error
|
|
20
|
-
"text-red-
|
|
88
|
+
"text-red-400 dark:text-red-500"
|
|
21
89
|
when :warning
|
|
22
|
-
"text-yellow-
|
|
90
|
+
"text-yellow-400 dark:text-yellow-500"
|
|
23
91
|
when :info, :notice
|
|
24
|
-
"text-blue-
|
|
92
|
+
"text-blue-400 dark:text-blue-500"
|
|
25
93
|
else
|
|
26
|
-
"text-gray-
|
|
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
|