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.
Files changed (42) 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/components/panda/core/UI/button.rb +45 -24
  5. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  6. data/app/components/panda/core/admin/button_component.rb +1 -1
  7. data/app/components/panda/core/admin/container_component.rb +27 -4
  8. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  9. data/app/components/panda/core/admin/flash_message_component.rb +54 -36
  10. data/app/components/panda/core/admin/heading_component.rb +8 -7
  11. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  12. data/app/components/panda/core/admin/panel_component.rb +1 -1
  13. data/app/components/panda/core/admin/slideover_component.rb +62 -4
  14. data/app/components/panda/core/admin/table_component.rb +11 -11
  15. data/app/components/panda/core/admin/tag_component.rb +39 -2
  16. data/app/components/panda/core/admin/user_display_component.rb +4 -5
  17. data/app/controllers/panda/core/admin/my_profile_controller.rb +2 -2
  18. data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
  19. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  20. data/app/helpers/panda/core/asset_helper.rb +31 -5
  21. data/app/helpers/panda/core/sessions_helper.rb +26 -1
  22. data/app/javascript/panda/core/application.js +8 -1
  23. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  24. data/app/javascript/panda/core/controllers/index.js +3 -3
  25. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  26. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  27. data/app/models/panda/core/user.rb +49 -6
  28. data/app/services/panda/core/attach_avatar_service.rb +67 -0
  29. data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
  30. data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
  31. data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
  32. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +3 -3
  33. data/app/views/panda/core/admin/shared/_sidebar.html.erb +17 -12
  34. data/config/importmap.rb +15 -7
  35. data/config/routes.rb +9 -0
  36. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  37. data/lib/panda/core/engine.rb +12 -3
  38. data/lib/panda/core/services/base_service.rb +19 -4
  39. data/lib/panda/core/version.rb +1 -1
  40. data/lib/panda/core.rb +1 -0
  41. data/lib/tasks/panda_core_users.rake +158 -0
  42. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1571548de89ff0ad16010f4500ed47a1c53dda70c730945fa439272a33567999
4
- data.tar.gz: 02cc839e0831b886a37729b43a5d185d1bd7a13eb3ff59dd2e3afc0f7996e4cd
3
+ metadata.gz: aafa859a0f30c0eac8666376380b18608a5429f760a1f30f2db283504458533d
4
+ data.tar.gz: 8e63ccaa5fd464c9a7e403a6fa2d3f789bfb0106ce824c31e59f30ffc0bc3dae
5
5
  SHA512:
6
- metadata.gz: acaebae64c9e8b970edda07e35f0b3820e988d3c738c3cf0c4d9c647be828d98773992d6a1ab70a4dc74cc01edaa04582a43c0fa285222a2e2d8169afaba7ac0
7
- data.tar.gz: 927db64e6429b4687667e8e47b185abea96e1f50668b5e6abe2d36050a1606aac880610f94d2f08205fb4a1280e3c95f05f8c45a8afc1d03ca10a38fad0abdd5
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 {
@@ -18,4 +18,12 @@ module.exports = {
18
18
  "../../../cms/vendor/javascript/**/*.js",
19
19
  ],
20
20
  },
21
+ safelist: [
22
+ // Tree indentation classes used in pages/index
23
+ 'ml-4',
24
+ 'ml-8',
25
+ 'ml-12',
26
+ 'ml-16',
27
+ 'ml-24',
28
+ ],
21
29
  };
@@ -5,17 +5,24 @@ module Panda
5
5
  module UI
6
6
  # Modern Phlex-based button component with type-safe props.
7
7
  #
8
- # This component demonstrates the recommended pattern for building
9
- # Phlex components in the Panda ecosystem using the shared base class.
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 usage
11
+ # @example Basic button
12
12
  # render Panda::Core::UI::Button.new(text: "Click me")
13
13
  #
14
- # @example With variant
14
+ # @example Button as link
15
15
  # render Panda::Core::UI::Button.new(
16
- # text: "Delete",
17
- # variant: :danger,
18
- # size: :large
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
- button(**@attrs) { text }
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-medium shadow-sm transition-colors"
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
- "gap-x-1.5 px-2.5 py-1.5 text-sm"
81
+ "px-2.5 py-1.5 text-sm"
63
82
  when :large, :lg
64
- "gap-x-2 px-3.5 py-2.5 text-lg"
83
+ "px-3.5 py-2.5 text-lg"
65
84
  else # :medium, :md
66
- "gap-x-1.5 px-3 py-2 text-base"
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
- "bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
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
- "bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
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-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
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-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
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-offset-2 focus-visible:outline-gray-700"
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
@@ -14,7 +14,7 @@ module Panda
14
14
  def view_template
15
15
  a(**@attrs) do
16
16
  if computed_icon
17
- i(class: "mr-2 fa-regular fa-#{computed_icon}")
17
+ i(class: "mr-2 fa-solid fa-#{computed_icon}")
18
18
  plain " "
19
19
  end
20
20
  plain @text.titleize
@@ -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: "flex-auto") do
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
- @slideover_content = -> { render(Panda::Core::Admin::SlideoverComponent.new(**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(" ")
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