panda-core 0.4.1 → 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 +95 -0
- data/app/assets/tailwind/tailwind.config.js +8 -0
- data/app/components/panda/core/UI/button.rb +45 -24
- data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
- data/app/components/panda/core/admin/button_component.rb +1 -1
- data/app/components/panda/core/admin/container_component.rb +27 -4
- data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
- data/app/components/panda/core/admin/flash_message_component.rb +54 -36
- data/app/components/panda/core/admin/heading_component.rb +8 -7
- data/app/components/panda/core/admin/page_header_component.rb +107 -0
- data/app/components/panda/core/admin/panel_component.rb +1 -1
- data/app/components/panda/core/admin/slideover_component.rb +62 -4
- data/app/components/panda/core/admin/table_component.rb +11 -11
- data/app/components/panda/core/admin/tag_component.rb +39 -2
- data/app/components/panda/core/admin/user_display_component.rb +4 -5
- data/app/controllers/panda/core/admin/my_profile_controller.rb +2 -2
- data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
- 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 +26 -1
- 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/models/panda/core/user.rb +49 -6
- data/app/services/panda/core/attach_avatar_service.rb +67 -0
- data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
- data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
- data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +3 -3
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +17 -12
- data/config/importmap.rb +15 -7
- data/config/routes.rb +9 -0
- data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
- data/lib/panda/core/engine.rb +12 -3
- data/lib/panda/core/services/base_service.rb +19 -4
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +1 -0
- data/lib/tasks/panda_core_users.rake +158 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aafa859a0f30c0eac8666376380b18608a5429f760a1f30f2db283504458533d
|
|
4
|
+
data.tar.gz: 8e63ccaa5fd464c9a7e403a6fa2d3f789bfb0106ce824c31e59f30ffc0bc3dae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8508b00f4c356dfceb223952d34dec3d38eec94078043ab40f08ebcdf2f1e484c2eefbe8534f3c1b86de5b62b71b0cc62dc0c63168e6053f68cf7ed9bf879198
|
|
7
|
+
data.tar.gz: 8c2c61ff18769f7ee23defaaae9f6d24703328a7e8269079af53508a811ce71dedd8948a0c0906f5927ec8f66e65e04319f4649541e3b928291712f49559088c
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
@source "../../../cms/app/components/**/*.rb";
|
|
10
10
|
|
|
11
11
|
@theme {
|
|
12
|
+
/* Legacy colors */
|
|
12
13
|
--color-white: var(--color-white);
|
|
13
14
|
--color-black: var(--color-black);
|
|
14
15
|
--color-light: var(--color-light);
|
|
@@ -19,6 +20,45 @@
|
|
|
19
20
|
--color-inactive: var(--color-inactive);
|
|
20
21
|
--color-warning: var(--color-warning);
|
|
21
22
|
--color-error: var(--color-error);
|
|
23
|
+
|
|
24
|
+
/* Tailwind UI compatible primary scale */
|
|
25
|
+
--color-primary-50: var(--color-primary-50);
|
|
26
|
+
--color-primary-100: var(--color-primary-100);
|
|
27
|
+
--color-primary-200: var(--color-primary-200);
|
|
28
|
+
--color-primary-300: var(--color-primary-300);
|
|
29
|
+
--color-primary-400: var(--color-primary-400);
|
|
30
|
+
--color-primary-500: var(--color-primary-500);
|
|
31
|
+
--color-primary-600: var(--color-primary-600);
|
|
32
|
+
--color-primary-700: var(--color-primary-700);
|
|
33
|
+
--color-primary-800: var(--color-primary-800);
|
|
34
|
+
--color-primary-900: var(--color-primary-900);
|
|
35
|
+
--color-primary-950: var(--color-primary-950);
|
|
36
|
+
|
|
37
|
+
/* Map indigo to primary for Tailwind UI compatibility */
|
|
38
|
+
--color-indigo-50: var(--color-primary-50);
|
|
39
|
+
--color-indigo-100: var(--color-primary-100);
|
|
40
|
+
--color-indigo-200: var(--color-primary-200);
|
|
41
|
+
--color-indigo-300: var(--color-primary-300);
|
|
42
|
+
--color-indigo-400: var(--color-primary-400);
|
|
43
|
+
--color-indigo-500: var(--color-primary-500);
|
|
44
|
+
--color-indigo-600: var(--color-primary-600);
|
|
45
|
+
--color-indigo-700: var(--color-primary-700);
|
|
46
|
+
--color-indigo-800: var(--color-primary-800);
|
|
47
|
+
--color-indigo-900: var(--color-primary-900);
|
|
48
|
+
--color-indigo-950: var(--color-primary-950);
|
|
49
|
+
|
|
50
|
+
/* Gray scale */
|
|
51
|
+
--color-gray-50: var(--color-gray-50);
|
|
52
|
+
--color-gray-100: var(--color-gray-100);
|
|
53
|
+
--color-gray-200: var(--color-gray-200);
|
|
54
|
+
--color-gray-300: var(--color-gray-300);
|
|
55
|
+
--color-gray-400: var(--color-gray-400);
|
|
56
|
+
--color-gray-500: var(--color-gray-500);
|
|
57
|
+
--color-gray-600: var(--color-gray-600);
|
|
58
|
+
--color-gray-700: var(--color-gray-700);
|
|
59
|
+
--color-gray-800: var(--color-gray-800);
|
|
60
|
+
--color-gray-900: var(--color-gray-900);
|
|
61
|
+
--color-gray-950: var(--color-gray-950);
|
|
22
62
|
}
|
|
23
63
|
|
|
24
64
|
@layer base {
|
|
@@ -26,6 +66,7 @@
|
|
|
26
66
|
--color-white: rgb(249, 249, 249); /* #F9F9F9 */
|
|
27
67
|
--color-black: rgb(26, 22, 29); /* #1A161D */
|
|
28
68
|
|
|
69
|
+
/* Legacy color variables (kept for backwards compatibility) */
|
|
29
70
|
--color-light: rgb(238, 206, 230); /* #EECEE6 */
|
|
30
71
|
--color-mid: rgb(141, 94, 183); /* #8D5EB7 */
|
|
31
72
|
--color-dark: rgb(33, 29, 73); /* #211D49 */
|
|
@@ -36,11 +77,39 @@
|
|
|
36
77
|
--color-warning: rgb(250, 207, 142); /* #FACF8E */
|
|
37
78
|
--color-inactive: rgb(216, 247, 245); /* #d6e4f7 */
|
|
38
79
|
--color-error: rgb(245, 129, 129); /* #F58181 */
|
|
80
|
+
|
|
81
|
+
/* Full Tailwind UI compatible color scale - Primary (Purple palette) */
|
|
82
|
+
--color-primary-50: rgb(250, 245, 255); /* Very light purple */
|
|
83
|
+
--color-primary-100: rgb(243, 232, 255); /* Lighter purple */
|
|
84
|
+
--color-primary-200: rgb(233, 213, 255); /* Light purple */
|
|
85
|
+
--color-primary-300: rgb(216, 180, 254); /* Medium light purple */
|
|
86
|
+
--color-primary-400: rgb(192, 132, 252); /* Medium purple */
|
|
87
|
+
--color-primary-500: rgb(141, 94, 183); /* Main brand color (mid) */
|
|
88
|
+
--color-primary-600: rgb(120, 80, 160); /* Darker purple */
|
|
89
|
+
--color-primary-700: rgb(100, 65, 140); /* Dark purple */
|
|
90
|
+
--color-primary-800: rgb(60, 45, 100); /* Very dark purple */
|
|
91
|
+
--color-primary-900: rgb(33, 29, 73); /* Darkest (dark) */
|
|
92
|
+
--color-primary-950: rgb(24, 20, 50); /* Ultra dark */
|
|
93
|
+
|
|
94
|
+
/* Gray scale for UI elements */
|
|
95
|
+
--color-gray-50: rgb(249, 250, 251);
|
|
96
|
+
--color-gray-100: rgb(243, 244, 246);
|
|
97
|
+
--color-gray-200: rgb(229, 231, 235);
|
|
98
|
+
--color-gray-300: rgb(209, 213, 219);
|
|
99
|
+
--color-gray-400: rgb(156, 163, 175);
|
|
100
|
+
--color-gray-500: rgb(107, 114, 128);
|
|
101
|
+
--color-gray-600: rgb(75, 85, 99);
|
|
102
|
+
--color-gray-700: rgb(55, 65, 81);
|
|
103
|
+
--color-gray-800: rgb(31, 41, 55);
|
|
104
|
+
--color-gray-900: rgb(17, 24, 39);
|
|
105
|
+
--color-gray-950: rgb(3, 7, 18);
|
|
39
106
|
}
|
|
40
107
|
|
|
41
108
|
html[data-theme='sky'] {
|
|
42
109
|
--color-white: rgb(249, 249, 249); /* #F9F9F9 */
|
|
43
110
|
--color-black: rgb(26, 22, 29); /* #1A161D */
|
|
111
|
+
|
|
112
|
+
/* Legacy color variables (kept for backwards compatibility) */
|
|
44
113
|
--color-light: rgb(204, 238, 242); /* #CCEEF2 */
|
|
45
114
|
--color-mid: rgb(42, 102, 159); /* #2A669F */
|
|
46
115
|
--color-dark: rgb(20, 32, 74); /* #14204A */
|
|
@@ -50,6 +119,32 @@
|
|
|
50
119
|
--color-warning: rgb(244, 190, 102); /* #F4BE66 */
|
|
51
120
|
--color-inactive: rgb(216, 247, 245); /* #d6e4f7 */
|
|
52
121
|
--color-error: rgb(208, 64, 20); /* #D04014 */
|
|
122
|
+
|
|
123
|
+
/* Full Tailwind UI compatible color scale - Primary (Blue palette) */
|
|
124
|
+
--color-primary-50: rgb(240, 249, 255); /* Very light blue */
|
|
125
|
+
--color-primary-100: rgb(224, 242, 254); /* Lighter blue */
|
|
126
|
+
--color-primary-200: rgb(186, 230, 253); /* Light blue */
|
|
127
|
+
--color-primary-300: rgb(125, 211, 252); /* Medium light blue */
|
|
128
|
+
--color-primary-400: rgb(56, 189, 248); /* Medium blue */
|
|
129
|
+
--color-primary-500: rgb(42, 102, 159); /* Main brand color (mid) */
|
|
130
|
+
--color-primary-600: rgb(35, 85, 132); /* Darker blue */
|
|
131
|
+
--color-primary-700: rgb(28, 68, 106); /* Dark blue */
|
|
132
|
+
--color-primary-800: rgb(22, 52, 82); /* Very dark blue */
|
|
133
|
+
--color-primary-900: rgb(20, 32, 74); /* Darkest (dark) */
|
|
134
|
+
--color-primary-950: rgb(15, 24, 55); /* Ultra dark */
|
|
135
|
+
|
|
136
|
+
/* Gray scale for UI elements (same across themes) */
|
|
137
|
+
--color-gray-50: rgb(249, 250, 251);
|
|
138
|
+
--color-gray-100: rgb(243, 244, 246);
|
|
139
|
+
--color-gray-200: rgb(229, 231, 235);
|
|
140
|
+
--color-gray-300: rgb(209, 213, 219);
|
|
141
|
+
--color-gray-400: rgb(156, 163, 175);
|
|
142
|
+
--color-gray-500: rgb(107, 114, 128);
|
|
143
|
+
--color-gray-600: rgb(75, 85, 99);
|
|
144
|
+
--color-gray-700: rgb(55, 65, 81);
|
|
145
|
+
--color-gray-800: rgb(31, 41, 55);
|
|
146
|
+
--color-gray-900: rgb(17, 24, 39);
|
|
147
|
+
--color-gray-950: rgb(3, 7, 18);
|
|
53
148
|
}
|
|
54
149
|
|
|
55
150
|
a.block-link:after {
|
|
@@ -5,17 +5,24 @@ module Panda
|
|
|
5
5
|
module UI
|
|
6
6
|
# Modern Phlex-based button component with type-safe props.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# Supports both <button> and <a> elements based on whether an href is provided.
|
|
9
|
+
# Follows Tailwind UI Plus styling patterns with dark mode support.
|
|
10
10
|
#
|
|
11
|
-
# @example Basic
|
|
11
|
+
# @example Basic button
|
|
12
12
|
# render Panda::Core::UI::Button.new(text: "Click me")
|
|
13
13
|
#
|
|
14
|
-
# @example
|
|
14
|
+
# @example Button as link
|
|
15
15
|
# render Panda::Core::UI::Button.new(
|
|
16
|
-
# text: "
|
|
17
|
-
# variant: :
|
|
18
|
-
#
|
|
16
|
+
# text: "Edit",
|
|
17
|
+
# variant: :secondary,
|
|
18
|
+
# href: "/admin/posts/1/edit"
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example Primary action button
|
|
22
|
+
# render Panda::Core::UI::Button.new(
|
|
23
|
+
# text: "Publish",
|
|
24
|
+
# variant: :primary,
|
|
25
|
+
# href: "/admin/posts/1/publish"
|
|
19
26
|
# )
|
|
20
27
|
#
|
|
21
28
|
# @example With custom attributes
|
|
@@ -33,54 +40,68 @@ module Panda
|
|
|
33
40
|
prop :size, Symbol, default: :medium
|
|
34
41
|
prop :disabled, _Boolean, default: false
|
|
35
42
|
prop :type, String, default: "button"
|
|
43
|
+
prop :href, _Nilable(String), default: -> {}
|
|
36
44
|
|
|
37
45
|
def view_template
|
|
38
|
-
|
|
46
|
+
if @href
|
|
47
|
+
a(**@attrs) { @text }
|
|
48
|
+
else
|
|
49
|
+
button(**@attrs) { @text }
|
|
50
|
+
end
|
|
39
51
|
end
|
|
40
52
|
|
|
41
53
|
def default_attrs
|
|
42
|
-
{
|
|
43
|
-
type: type,
|
|
44
|
-
disabled: disabled,
|
|
54
|
+
base = {
|
|
45
55
|
class: button_classes
|
|
46
56
|
}
|
|
57
|
+
|
|
58
|
+
if @href
|
|
59
|
+
base[:href] = @href
|
|
60
|
+
else
|
|
61
|
+
base[:type] = @type
|
|
62
|
+
base[:disabled] = @disabled if @disabled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
base
|
|
47
66
|
end
|
|
48
67
|
|
|
49
68
|
private
|
|
50
69
|
|
|
51
70
|
def button_classes
|
|
52
|
-
base = "inline-flex items-center rounded-md font-
|
|
71
|
+
base = "inline-flex items-center rounded-md font-semibold"
|
|
53
72
|
base += " #{size_classes}"
|
|
54
73
|
base += " #{variant_classes}"
|
|
55
|
-
base += " disabled:opacity-50 disabled:cursor-not-allowed" if disabled
|
|
74
|
+
base += " disabled:opacity-50 disabled:cursor-not-allowed" if @disabled
|
|
56
75
|
base
|
|
57
76
|
end
|
|
58
77
|
|
|
59
78
|
def size_classes
|
|
60
|
-
case size
|
|
79
|
+
case @size
|
|
61
80
|
when :small, :sm
|
|
62
|
-
"
|
|
81
|
+
"px-2.5 py-1.5 text-sm"
|
|
63
82
|
when :large, :lg
|
|
64
|
-
"
|
|
83
|
+
"px-3.5 py-2.5 text-lg"
|
|
65
84
|
else # :medium, :md
|
|
66
|
-
"
|
|
85
|
+
"px-3 py-2 text-sm"
|
|
67
86
|
end
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
def variant_classes
|
|
71
|
-
case variant
|
|
90
|
+
case @variant
|
|
72
91
|
when :primary
|
|
73
|
-
|
|
92
|
+
# Blue primary button with dark mode support
|
|
93
|
+
"bg-blue-600 text-white shadow-xs hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:bg-blue-500 dark:shadow-none dark:hover:bg-blue-400 dark:focus-visible:outline-blue-500"
|
|
74
94
|
when :secondary
|
|
75
|
-
|
|
95
|
+
# White/gray secondary button with ring and dark mode support
|
|
96
|
+
"bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
|
|
76
97
|
when :success
|
|
77
|
-
"bg-green-600 text-white hover:bg-green-
|
|
98
|
+
"bg-green-600 text-white shadow-xs hover:bg-green-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600 dark:bg-green-500 dark:shadow-none dark:hover:bg-green-400 dark:focus-visible:outline-green-500"
|
|
78
99
|
when :danger
|
|
79
|
-
"bg-red-600 text-white hover:bg-red-
|
|
100
|
+
"bg-red-600 text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400 dark:focus-visible:outline-red-500"
|
|
80
101
|
when :ghost
|
|
81
|
-
"bg-transparent text-gray-700 hover:bg-gray-100"
|
|
102
|
+
"bg-transparent text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
82
103
|
else # :default
|
|
83
|
-
"bg-gray-700 text-white hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-
|
|
104
|
+
"bg-gray-700 text-white shadow-xs hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700 dark:bg-gray-600 dark:shadow-none dark:hover:bg-gray-500 dark:focus-visible:outline-gray-600"
|
|
84
105
|
end
|
|
85
106
|
end
|
|
86
107
|
end
|
|
@@ -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
|
|
@@ -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,29 @@ 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
|
+
end
|
|
30
|
+
|
|
19
31
|
main(class: "overflow-auto flex-1 h-full min-h-full max-h-full") do
|
|
20
32
|
div(class: "overflow-auto px-2 pt-4 mx-auto sm:px-6 lg:px-6") do
|
|
21
33
|
@heading_content&.call
|
|
22
34
|
@tab_bar_content&.call
|
|
23
35
|
|
|
24
|
-
section(class:
|
|
25
|
-
div(class: "flex-1 mt-4 w-full") do
|
|
36
|
+
section(class: section_classes) do
|
|
37
|
+
div(class: "flex-1 mt-4 w-full h-full") do
|
|
26
38
|
if @main_content
|
|
27
39
|
@main_content.call
|
|
28
40
|
elsif @body_html
|
|
29
41
|
raw(@body_html)
|
|
30
42
|
end
|
|
31
43
|
end
|
|
32
|
-
@slideover_content&.call
|
|
33
44
|
end
|
|
34
45
|
end
|
|
35
46
|
end
|
|
@@ -53,7 +64,19 @@ module Panda
|
|
|
53
64
|
end
|
|
54
65
|
|
|
55
66
|
def slideover(**props, &block)
|
|
56
|
-
@
|
|
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(" ")
|
|
57
80
|
end
|
|
58
81
|
end
|
|
59
82
|
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
|