panda-core 0.4.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/tailwind/application.css +95 -0
  3. data/app/assets/tailwind/tailwind.config.js +8 -0
  4. data/app/builders/panda/core/form_builder.rb +163 -11
  5. data/app/components/panda/core/UI/button.rb +45 -24
  6. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  7. data/app/components/panda/core/admin/button_component.rb +27 -12
  8. data/app/components/panda/core/admin/container_component.rb +40 -5
  9. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  10. data/app/components/panda/core/admin/flash_message_component.rb +54 -36
  11. data/app/components/panda/core/admin/heading_component.rb +28 -19
  12. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  13. data/app/components/panda/core/admin/panel_component.rb +1 -1
  14. data/app/components/panda/core/admin/slideover_component.rb +92 -4
  15. data/app/components/panda/core/admin/table_component.rb +11 -11
  16. data/app/components/panda/core/admin/tag_component.rb +39 -2
  17. data/app/components/panda/core/admin/user_display_component.rb +4 -5
  18. data/app/controllers/panda/core/admin/my_profile_controller.rb +10 -3
  19. data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
  20. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  21. data/app/helpers/panda/core/asset_helper.rb +33 -5
  22. data/app/helpers/panda/core/sessions_helper.rb +26 -1
  23. data/app/javascript/panda/core/application.js +8 -1
  24. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  25. data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
  26. data/app/javascript/panda/core/controllers/index.js +9 -3
  27. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
  28. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  29. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  30. data/app/models/panda/core/user.rb +60 -6
  31. data/app/services/panda/core/attach_avatar_service.rb +71 -0
  32. data/app/views/layouts/panda/core/admin.html.erb +39 -14
  33. data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
  34. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -1
  35. data/app/views/panda/core/admin/dashboard/show.html.erb +3 -3
  36. data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
  37. data/app/views/panda/core/admin/sessions/new.html.erb +3 -4
  38. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
  39. data/app/views/panda/core/admin/shared/_sidebar.html.erb +69 -14
  40. data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
  41. data/config/importmap.rb +20 -7
  42. data/config/routes.rb +10 -1
  43. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  44. data/lib/panda/core/asset_loader.rb +5 -2
  45. data/lib/panda/core/engine.rb +38 -28
  46. data/lib/panda/core/oauth_providers.rb +3 -3
  47. data/lib/panda/core/services/base_service.rb +19 -4
  48. data/lib/panda/core/version.rb +1 -1
  49. data/lib/panda/core.rb +1 -0
  50. data/lib/tasks/panda_core_users.rake +158 -0
  51. metadata +13 -69
  52. data/lib/generators/panda/core/authentication/templates/reek_spec.rb +0 -43
  53. data/lib/generators/panda/core/dev_tools/USAGE +0 -24
  54. data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +0 -13
  55. data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +0 -18
  56. data/lib/generators/panda/core/dev_tools_generator.rb +0 -143
  57. data/lib/generators/panda/core/install_generator.rb +0 -41
  58. data/lib/generators/panda/core/templates/README +0 -25
  59. data/lib/generators/panda/core/templates/initializer.rb +0 -44
  60. data/lib/generators/panda/core/templates_generator.rb +0 -27
  61. data/lib/panda/core/testing/capybara_config.rb +0 -70
  62. data/lib/panda/core/testing/omniauth_helpers.rb +0 -52
  63. data/lib/panda/core/testing/rspec_config.rb +0 -72
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1571548de89ff0ad16010f4500ed47a1c53dda70c730945fa439272a33567999
4
- data.tar.gz: 02cc839e0831b886a37729b43a5d185d1bd7a13eb3ff59dd2e3afc0f7996e4cd
3
+ metadata.gz: 4aa145ee85e5b0c497754d7d218ceb3cc7c4de6065e3357f85e0676186df37f1
4
+ data.tar.gz: 9425c978a3208aca75045e4cf1e36408088829a73c19d10746a91cefc320db6f
5
5
  SHA512:
6
- metadata.gz: acaebae64c9e8b970edda07e35f0b3820e988d3c738c3cf0c4d9c647be828d98773992d6a1ab70a4dc74cc01edaa04582a43c0fa285222a2e2d8169afaba7ac0
7
- data.tar.gz: 927db64e6429b4687667e8e47b185abea96e1f50668b5e6abe2d36050a1606aac880610f94d2f08205fb4a1280e3c95f05f8c45a8afc1d03ca10a38fad0abdd5
6
+ metadata.gz: ef76d583b1a36ecef8d13788338a847457bcd36a8efddf9582dcb1a7fc7ee484cb689f5dea6f9628d1fc46e2e7e2f513c84b6531116059496bb5e2e862d82f32
7
+ data.tar.gz: 982d52f5927bddf30b2c6ab1d3981e78e542fba07717628d73f4375c8819ebf29a151eaf08f0f89fea48d06830c52315d3ff9a996d19472696c061597d529bd4
@@ -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
  };
@@ -11,6 +11,13 @@ module Panda
11
11
  end
12
12
 
13
13
  def text_field(attribute, options = {})
14
+ # Add disabled/readonly styling
15
+ field_classes = if options[:readonly] || options[:disabled]
16
+ readonly_input_styles
17
+ else
18
+ input_styles
19
+ end
20
+
14
21
  if options.dig(:data, :prefix)
15
22
  content_tag :div, class: container_styles do
16
23
  label(attribute) + meta_text(options) +
@@ -19,12 +26,12 @@ module Panda
19
26
  class: "inline-flex items-center px-3 text-base border border-r-none rounded-s-md whitespace-nowrap break-keep") do
20
27
  options.dig(:data, :prefix)
21
28
  end +
22
- super(attribute, options.reverse_merge(class: "#{input_styles_prefix} input-prefix rounded-l-none border-l-none"))
29
+ super(attribute, options.reverse_merge(class: "#{field_classes} input-prefix rounded-l-none border-l-none"))
23
30
  end + error_message(attribute)
24
31
  end
25
32
  else
26
33
  content_tag :div, class: container_styles do
27
- label(attribute) + meta_text(options) + super(attribute, options.reverse_merge(class: input_styles)) + error_message(attribute)
34
+ label(attribute) + meta_text(options) + super(attribute, options.reverse_merge(class: field_classes)) + error_message(attribute)
28
35
  end
29
36
  end
30
37
  end
@@ -77,8 +84,121 @@ module Panda
77
84
  end
78
85
 
79
86
  def file_field(method, options = {})
80
- content_tag :div, class: container_styles do
81
- label(method) + meta_text(options) + super(method, options.reverse_merge(class: "file:rounded file:border-0 file:text-sm file:bg-white file:text-gray-500 hover:file:bg-gray-50 bg-white px-2.5 hover:bg-gray-50".concat(input_styles)))
87
+ # Check if cropper is requested
88
+ with_cropper = options.delete(:with_cropper)
89
+
90
+ # Check if simple mode is requested (no fancy upload UI)
91
+ simple_mode = options.delete(:simple)
92
+
93
+ if with_cropper
94
+ # Image upload with cropper
95
+ aspect_ratio = options.delete(:aspect_ratio) # e.g., 1.91 for OG images (1200x630)
96
+ min_width = options.delete(:min_width) || 0
97
+ min_height = options.delete(:min_height) || 0
98
+ accept_types = options.delete(:accept) || "image/*"
99
+ field_id = "#{object_name}_#{method}"
100
+
101
+ content_tag :div, class: container_styles do
102
+ label(method) +
103
+ meta_text(options) +
104
+ # Cropper stylesheet
105
+ @template.content_tag(:link, nil, rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css") +
106
+ # File input
107
+ content_tag(:div, class: "mt-2") do
108
+ super(method, options.reverse_merge(
109
+ id: field_id,
110
+ accept: accept_types,
111
+ class: "file:rounded file:border-0 file:text-sm file:bg-white file:text-gray-500 hover:file:bg-gray-50 bg-white px-2.5 hover:bg-gray-50 #{input_styles}",
112
+ data: {
113
+ controller: "image-cropper",
114
+ image_cropper_target: "input",
115
+ action: "change->image-cropper#handleFileSelect",
116
+ image_cropper_aspect_ratio_value: aspect_ratio,
117
+ image_cropper_min_width_value: min_width,
118
+ image_cropper_min_height_value: min_height
119
+ }
120
+ ))
121
+ end +
122
+ # Cropper container (hidden by default)
123
+ content_tag(:div, class: "hidden mt-4 bg-gray-100 dark:bg-gray-800 p-4 rounded-lg", data: {image_cropper_target: "cropperContainer"}) do
124
+ # Preview image
125
+ @template.image_tag("", alt: "Crop preview", data: {image_cropper_target: "preview"}, class: "max-w-full") +
126
+ # Cropper controls
127
+ content_tag(:div, class: "mt-4 flex gap-2 flex-wrap") do
128
+ @template.button_tag("Crop & Save", type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500", data: {action: "click->image-cropper#crop"}) +
129
+ @template.button_tag("Cancel", type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50", data: {action: "click->image-cropper#cancel"}) +
130
+ @template.button_tag(type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50", data: {action: "click->image-cropper#reset"}) do
131
+ @template.content_tag(:i, "", class: "fa-solid fa-rotate-left") +
132
+ @template.content_tag(:span, "Reset")
133
+ end +
134
+ @template.button_tag(type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50", data: {action: "click->image-cropper#rotate", degrees: "90"}) do
135
+ @template.content_tag(:i, "", class: "fa-solid fa-rotate-right") +
136
+ @template.content_tag(:span, "Rotate")
137
+ end +
138
+ @template.button_tag(type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50", data: {action: "click->image-cropper#flip", direction: "horizontal"}) do
139
+ @template.content_tag(:i, "", class: "fa-solid fa-arrows-left-right") +
140
+ @template.content_tag(:span, "Flip H")
141
+ end +
142
+ @template.button_tag(type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50", data: {action: "click->image-cropper#zoom", ratio: "0.1"}) do
143
+ @template.content_tag(:i, "", class: "fa-solid fa-magnifying-glass-plus") +
144
+ @template.content_tag(:span, "Zoom In")
145
+ end +
146
+ @template.button_tag(type: "button", class: "inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50", data: {action: "click->image-cropper#zoom", ratio: "-0.1"}) do
147
+ @template.content_tag(:i, "", class: "fa-solid fa-magnifying-glass-minus") +
148
+ @template.content_tag(:span, "Zoom Out")
149
+ end
150
+ end
151
+ end
152
+ end
153
+ elsif simple_mode
154
+ # Simple file input with basic styling
155
+ content_tag :div, class: container_styles do
156
+ label(method) +
157
+ meta_text(options) +
158
+ super(method, options.reverse_merge(class: "file:rounded file:border-0 file:text-sm file:bg-white file:text-gray-500 hover:file:bg-gray-50 bg-white px-2.5 hover:bg-gray-50".concat(input_styles)))
159
+ end
160
+ else
161
+ # Fancy drag-and-drop UI
162
+ accept_types = options.delete(:accept) || "image/*"
163
+ max_size = options.delete(:max_size) || "10MB"
164
+ file_types_display = options.delete(:file_types_display) || "PNG, JPG, GIF"
165
+
166
+ field_id = "#{object_name}_#{method}"
167
+
168
+ content_tag :div, class: "#{container_styles} col-span-full", data: {controller: "file-upload"} do
169
+ label(method) +
170
+ meta_text(options) +
171
+ content_tag(:div, class: "mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10 dark:border-white/25 transition-colors", data: {file_upload_target: "dropzone"}) do
172
+ content_tag(:div, class: "text-center") do
173
+ # Icon
174
+ @template.content_tag(:svg, viewBox: "0 0 24 24", fill: "currentColor", "data-slot": "icon", "aria-hidden": true, class: "mx-auto size-12 text-gray-300 dark:text-gray-600") do
175
+ @template.content_tag(:path, nil, d: "M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z", "clip-rule": "evenodd", "fill-rule": "evenodd")
176
+ end +
177
+ # Upload area
178
+ content_tag(:div, class: "mt-4 flex items-baseline justify-center text-sm leading-6 text-gray-600 dark:text-gray-400") do
179
+ content_tag(:label, for: field_id, class: "relative cursor-pointer rounded-md bg-transparent font-semibold text-indigo-600 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:focus-within:outline-indigo-500 dark:hover:text-indigo-300") do
180
+ content_tag(:span, "Upload a file") +
181
+ super(method, options.reverse_merge(
182
+ id: field_id,
183
+ accept: accept_types,
184
+ class: "sr-only",
185
+ data: {
186
+ file_upload_target: "input",
187
+ action: "change->file-upload#handleFileSelect"
188
+ }
189
+ ))
190
+ end +
191
+ content_tag(:span, "or drag and drop", class: "pl-1")
192
+ end +
193
+ # File type info
194
+ content_tag(:p, "#{file_types_display} up to #{max_size}", class: "text-xs/5 text-gray-600 dark:text-gray-400")
195
+ end
196
+ end +
197
+ # File info display (hidden by default)
198
+ content_tag(:div, "", class: "hidden mt-3", data: {file_upload_target: "fileInfo"}) +
199
+ # Preview display (hidden by default)
200
+ content_tag(:div, "", class: "hidden mt-3", data: {file_upload_target: "preview"})
201
+ end
82
202
  end
83
203
  end
84
204
 
@@ -117,11 +237,11 @@ module Panda
117
237
  def submit(value = nil, options = {})
118
238
  value ||= submit_default_value
119
239
 
120
- # Use the same style logic as ButtonComponent
240
+ # Use the primary mid color for save/create actions
121
241
  action = object.persisted? ? :save : :create
122
242
  button_classes = case action
123
243
  when :save, :create
124
- "text-white bg-green-600 hover:bg-green-700"
244
+ "text-white bg-mid hover:bg-mid/80"
125
245
  when :save_inactive
126
246
  "text-white bg-gray-400"
127
247
  when :secondary
@@ -150,32 +270,64 @@ module Panda
150
270
  end
151
271
  end
152
272
 
273
+ def radio_button_group(method, choices, options = {})
274
+ current_value = object.send(method)
275
+
276
+ content_tag :div, class: container_styles do
277
+ label(method) +
278
+ meta_text(options) +
279
+ content_tag(:div, class: "mt-2 space-y-2") do
280
+ choices.map do |choice|
281
+ choice_value = choice.is_a?(Array) ? choice.last : choice
282
+ choice_label = choice.is_a?(Array) ? choice.first : choice.to_s.humanize
283
+ choice_id = "#{object_name}_#{method}_#{choice_value}"
284
+ is_checked = (current_value.to_s == choice_value.to_s)
285
+
286
+ content_tag(:label, class: "flex items-center gap-x-3 rounded-lg border border-gray-300 px-3 py-3 text-sm/6 font-medium cursor-pointer hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5") do
287
+ radio_button(method, choice_value, {id: choice_id, checked: is_checked, class: "size-4 border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5"}) +
288
+ content_tag(:span, choice_label, class: "text-gray-900 dark:text-white")
289
+ end
290
+ end.join.html_safe
291
+ end
292
+ end
293
+ end
294
+
153
295
  def meta_text(options)
154
296
  return unless options[:meta]
155
297
 
156
298
  @template.content_tag(:p, options[:meta], class: "block text-black/60 text-sm mb-2")
157
299
  end
158
300
 
301
+ def section_heading(text, options = {})
302
+ @template.content_tag(:div, class: "-mx-4 sm:-mx-6 px-4 sm:px-6 py-4 bg-gray-200 dark:bg-gray-700 mb-6") do
303
+ @template.content_tag(:h3, text, class: "text-base font-semibold text-gray-900 dark:text-white")
304
+ end
305
+ end
306
+
159
307
  private
160
308
 
161
309
  def label_styles
162
- "font-light inline-block mb-1 text-base leading-6"
310
+ "block text-sm/6 font-medium text-gray-900 dark:text-gray-100"
163
311
  end
164
312
 
165
313
  def base_input_styles
166
- "bg-white block w-full rounded-md border border-gray-500 focus:border-gray-700 p-2 text-gray-900 outline-0 focus:outline-0 ring-0 focus:ring-0 focus:ring-gray-700 ring-offset-0 focus:ring-offset-0 shadow-none focus:shadow-none"
314
+ "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
167
315
  end
168
316
 
169
317
  def input_styles
170
318
  base_input_styles
171
319
  end
172
320
 
321
+ def readonly_input_styles
322
+ "block w-full rounded-md bg-gray-50 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-200 cursor-not-allowed sm:text-sm/6 dark:bg-white/10 dark:text-gray-500 dark:outline-white/5"
323
+ end
324
+
173
325
  def input_styles_prefix
174
- input_styles.concat(" prefix")
326
+ "#{input_styles} prefix"
175
327
  end
176
328
 
177
329
  def select_styles
178
- "col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pl-3 pr-8 text-gray-900 text-base outline-0 outline-gray-700 focus:outline focus:-outline-offset-2 focus:outline-gray-700"
330
+ "block w-full rounded-md bg-white px-3 py-1.5 pr-8 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 appearance-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
179
331
  end
180
332
 
181
333
  def select_svg
@@ -194,7 +346,7 @@ module Panda
194
346
  end
195
347
 
196
348
  def textarea_styles
197
- input_styles.concat(" min-h-32")
349
+ "#{input_styles} min-h-32"
198
350
  end
199
351
 
200
352
  def submit_default_value
@@ -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