panda-core 0.6.0 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/app/builders/panda/core/form_builder.rb +163 -11
  3. data/app/components/panda/core/admin/button_component.rb +27 -12
  4. data/app/components/panda/core/admin/container_component.rb +13 -1
  5. data/app/components/panda/core/admin/heading_component.rb +22 -14
  6. data/app/components/panda/core/admin/slideover_component.rb +52 -22
  7. data/app/controllers/panda/core/admin/my_profile_controller.rb +8 -1
  8. data/app/helpers/panda/core/asset_helper.rb +2 -0
  9. data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
  10. data/app/javascript/panda/core/controllers/index.js +6 -0
  11. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
  12. data/app/models/panda/core/user.rb +13 -2
  13. data/app/services/panda/core/attach_avatar_service.rb +4 -0
  14. data/app/views/layouts/panda/core/admin.html.erb +39 -14
  15. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -1
  16. data/app/views/panda/core/admin/dashboard/show.html.erb +3 -3
  17. data/app/views/panda/core/admin/sessions/new.html.erb +1 -1
  18. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
  19. data/app/views/panda/core/admin/shared/_sidebar.html.erb +61 -11
  20. data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
  21. data/config/importmap.rb +5 -0
  22. data/config/routes.rb +1 -1
  23. data/lib/panda/core/asset_loader.rb +5 -2
  24. data/lib/panda/core/engine.rb +26 -25
  25. data/lib/panda/core/oauth_providers.rb +3 -3
  26. data/lib/panda/core/version.rb +1 -1
  27. metadata +3 -69
  28. data/lib/generators/panda/core/authentication/templates/reek_spec.rb +0 -43
  29. data/lib/generators/panda/core/dev_tools/USAGE +0 -24
  30. data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +0 -13
  31. data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +0 -18
  32. data/lib/generators/panda/core/dev_tools_generator.rb +0 -143
  33. data/lib/generators/panda/core/install_generator.rb +0 -41
  34. data/lib/generators/panda/core/templates/README +0 -25
  35. data/lib/generators/panda/core/templates/initializer.rb +0 -44
  36. data/lib/generators/panda/core/templates_generator.rb +0 -27
  37. data/lib/panda/core/testing/capybara_config.rb +0 -70
  38. data/lib/panda/core/testing/omniauth_helpers.rb +0 -52
  39. 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: aafa859a0f30c0eac8666376380b18608a5429f760a1f30f2db283504458533d
4
- data.tar.gz: 8e63ccaa5fd464c9a7e403a6fa2d3f789bfb0106ce824c31e59f30ffc0bc3dae
3
+ metadata.gz: 4aa145ee85e5b0c497754d7d218ceb3cc7c4de6065e3357f85e0676186df37f1
4
+ data.tar.gz: 9425c978a3208aca75045e4cf1e36408088829a73c19d10746a91cefc320db6f
5
5
  SHA512:
6
- metadata.gz: 8508b00f4c356dfceb223952d34dec3d38eec94078043ab40f08ebcdf2f1e484c2eefbe8534f3c1b86de5b62b71b0cc62dc0c63168e6053f68cf7ed9bf879198
7
- data.tar.gz: 8c2c61ff18769f7ee23defaaae9f6d24703328a7e8269079af53508a811ce71dedd8948a0c0906f5927ec8f66e65e04319f4649541e3b928291712f49559088c
6
+ metadata.gz: ef76d583b1a36ecef8d13788338a847457bcd36a8efddf9582dcb1a7fc7ee484cb689f5dea6f9628d1fc46e2e7e2f513c84b6531116059496bb5e2e862d82f32
7
+ data.tar.gz: 982d52f5927bddf30b2c6ab1d3981e78e542fba07717628d73f4375c8819ebf29a151eaf08f0f89fea48d06830c52315d3ff9a996d19472696c061597d529bd4
@@ -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
@@ -6,37 +6,52 @@ module Panda
6
6
  class ButtonComponent < Panda::Core::Base
7
7
  prop :text, String, default: "Button"
8
8
  prop :action, _Nilable(Symbol), default: -> {}
9
- prop :href, String, default: "#"
9
+ prop :href, _Nilable(String), default: -> { "#" }
10
10
  prop :icon, _Nilable(String), default: -> {}
11
11
  prop :size, Symbol, default: :regular
12
12
  prop :id, _Nilable(String), default: -> {}
13
+ prop :as_button, _Boolean, default: -> { false }
13
14
 
14
15
  def view_template
15
- a(**@attrs) do
16
- if computed_icon
17
- i(class: "mr-2 fa-solid fa-#{computed_icon}")
18
- plain " "
16
+ if @as_button
17
+ button(**@attrs) do
18
+ render_content
19
+ end
20
+ else
21
+ a(**@attrs) do
22
+ render_content
19
23
  end
20
- plain @text.titleize
21
24
  end
22
25
  end
23
26
 
24
27
  def default_attrs
25
- {
26
- href: @href,
28
+ base = {
27
29
  class: button_classes,
28
30
  id: @id
29
31
  }
32
+
33
+ if @as_button
34
+ base.merge(type: "button")
35
+ else
36
+ base.merge(href: @href)
37
+ end
30
38
  end
31
39
 
32
40
  private
33
41
 
42
+ def render_content
43
+ if computed_icon
44
+ i(class: "fa-solid fa-#{computed_icon}")
45
+ end
46
+ plain @text.titleize
47
+ end
48
+
34
49
  def computed_icon
35
50
  @computed_icon ||= @icon || icon_from_action(@action)
36
51
  end
37
52
 
38
53
  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 "
54
+ base = "inline-flex items-center rounded-md font-medium shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 cursor-pointer "
40
55
  base + size_classes + action_classes
41
56
  end
42
57
 
@@ -56,15 +71,15 @@ module Panda
56
71
  def action_classes
57
72
  case @action
58
73
  when :save, :create
59
- "text-white bg-green-600 hover:bg-green-700"
74
+ "text-white bg-mid hover:bg-mid/80"
60
75
  when :save_inactive
61
76
  "text-white bg-gray-400"
62
77
  when :secondary
63
- "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all"
78
+ "text-gray-700 border-2 border-gray-500 bg-white hover:bg-gray-100 active:bg-gray-200 transition-colors"
64
79
  when :delete, :destroy, :danger
65
80
  "text-red-600 border border-red-600 bg-red-100 hover:bg-red-200 hover:text-red-700 focus-visible:outline-red-300"
66
81
  else
67
- "text-gray-700 border-2 border-gray-700 bg-transparent hover:bg-gray-100 transition-all"
82
+ "text-gray-700 border-2 border-gray-500 bg-white hover:bg-gray-100 active:bg-gray-200 transition-colors"
68
83
  end
69
84
  end
70
85
 
@@ -26,10 +26,17 @@ module Panda
26
26
  view_context.capture(&@slideover_block)
27
27
  end
28
28
  view_context.content_for(:sidebar_title, @slideover_title)
29
+
30
+ # Set footer content if present
31
+ if @footer_block
32
+ view_context.content_for(:sidebar_footer) do
33
+ view_context.capture(&@footer_block)
34
+ end
35
+ end
29
36
  end
30
37
 
31
38
  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
39
+ div(class: "overflow-auto px-2 pt-2 mx-auto sm:px-6 lg:px-6") do
33
40
  @heading_content&.call
34
41
  @tab_bar_content&.call
35
42
 
@@ -68,8 +75,13 @@ module Panda
68
75
  @slideover_block = block # Save the block for content_for
69
76
  end
70
77
 
78
+ def footer(&block)
79
+ @footer_block = block
80
+ end
81
+
71
82
  # Alias for ViewComponent-style API compatibility
72
83
  alias_method :with_slideover, :slideover
84
+ alias_method :with_footer, :footer
73
85
 
74
86
  private
75
87
 
@@ -7,23 +7,30 @@ module Panda
7
7
  prop :text, String
8
8
  prop :level, _Nilable(_Union(Integer, Symbol)), default: -> { 2 }
9
9
  prop :icon, String, default: ""
10
+ prop :meta, _Nilable(String), default: -> {}
10
11
  prop :additional_styles, _Nilable(_Union(String, Array)), default: -> { "" }
11
12
 
12
13
  def view_template(&block)
13
14
  # Capture any buttons defined via block
14
15
  instance_eval(&block) if block_given?
15
16
 
16
- case @level
17
- when 1
18
- h1(class: heading_classes) { render_content }
19
- when 2
20
- h2(class: heading_classes) { render_content }
21
- when 3
22
- h3(class: heading_classes) { render_content }
23
- when :panel
24
- h3(class: panel_heading_classes) { @text }
25
- else
26
- h2(class: heading_classes) { render_content }
17
+ div(class: "heading-wrapper") do
18
+ case @level
19
+ when 1
20
+ h1(class: heading_classes(@meta.present?)) { render_content }
21
+ when 2
22
+ h2(class: heading_classes(@meta.present?)) { render_content }
23
+ when 3
24
+ h3(class: heading_classes(@meta.present?)) { render_content }
25
+ when :panel
26
+ h3(class: panel_heading_classes) { @text }
27
+ else
28
+ h2(class: heading_classes(@meta.present?)) { render_content }
29
+ end
30
+
31
+ if @meta
32
+ p(class: "text-sm text-black/60 -mt-1 mb-5") { raw(@meta) }
33
+ end
27
34
  end
28
35
  end
29
36
 
@@ -40,13 +47,14 @@ module Panda
40
47
  span { @text }
41
48
  end
42
49
 
43
- span(class: "actions flex gap-x-2 -mt-1 min-h-[2.5rem]") do
50
+ span(class: "actions flex gap-x-2 mt-1 min-h-[2.5rem]") do
44
51
  @buttons&.each { |btn| render(btn) }
45
52
  end
46
53
  end
47
54
 
48
- def heading_classes
49
- base = "flex pt-1 text-black mb-5 -mt-1"
55
+ def heading_classes(has_meta = false)
56
+ margin_bottom = has_meta ? "mb-0.5" : "mb-5"
57
+ base = "flex text-black #{margin_bottom} -mt-2"
50
58
  styles = case @level
51
59
  when 1
52
60
  "text-2xl font-medium"
@@ -21,42 +21,72 @@ module Panda
21
21
  **default_attrs,
22
22
  data: {
23
23
  toggle_target: "toggleable",
24
- transition_enter: "transform transition ease-in-out duration-500",
24
+ transition_enter: "transform transition ease-in-out duration-500 sm:duration-700",
25
25
  transition_enter_from: "translate-x-full",
26
26
  transition_enter_to: "translate-x-0",
27
- transition_leave: "transform transition ease-in-out duration-500",
27
+ transition_leave: "transform transition ease-in-out duration-500 sm:duration-700",
28
28
  transition_leave_from: "translate-x-0",
29
29
  transition_leave_to: "translate-x-full"
30
30
  }
31
31
  ) do
32
- # Header with title and close button
33
- div(class: "py-3 px-4 mb-4 bg-black") do
34
- div(class: "flex justify-between items-center") do
35
- h2(class: "text-base font-semibold leading-6 text-white", id: "slideover-title") do
36
- i(class: "mr-2 fa-light fa-gear")
37
- plain " #{@title}"
32
+ # Main container
33
+ div(class: "relative flex h-full flex-col bg-white shadow-xl dark:bg-gray-800") do
34
+ # Header with title and close button
35
+ div(class: "bg-gradient-admin px-4 py-6 sm:px-6") do
36
+ div(class: "flex items-center justify-between") do
37
+ h2(class: "text-base font-semibold text-white", id: "slideover-title") do
38
+ plain @title
39
+ end
40
+ div(class: "ml-3 flex h-7 items-center") do
41
+ button(
42
+ type: "button",
43
+ data: {action: "click->toggle#toggle touch->toggle#toggle"},
44
+ class: "relative rounded-md text-white/80 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
45
+ ) do
46
+ span(class: "absolute -inset-2.5")
47
+ span(class: "sr-only") { "Close panel" }
48
+ # SVG close icon
49
+ svg(viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "1.5", aria_hidden: "true", class: "size-6") do
50
+ path(d: "M6 18 18 6M6 6l12 12", stroke_linecap: "round", stroke_linejoin: "round")
51
+ end
52
+ end
53
+ end
38
54
  end
39
- button(
40
- type: "button",
41
- data: {action: "click->toggle#toggle touch->toggle#toggle"},
42
- class: "text-white hover:text-gray-300 transition"
43
- ) do
44
- i(class: "font-bold fa-regular fa-xmark right")
55
+ end
56
+
57
+ # Scrollable content area
58
+ div(class: "flex-1 overflow-y-auto") do
59
+ if @content_html
60
+ raw(@content_html)
61
+ elsif @content_block
62
+ instance_eval(@content_block)
45
63
  end
46
64
  end
47
- end
48
65
 
49
- # Content area
50
- div(class: "overflow-y-auto px-4 pb-6 space-y-6") do
51
- if @content_html
52
- raw(@content_html)
53
- elsif @content_block
54
- instance_eval(&@content_block)
66
+ # Sticky footer (if footer content exists)
67
+ if @footer_html || @footer_block
68
+ div(class: "flex shrink-0 justify-end gap-x-3 border-t border-gray-200 px-4 py-4 dark:border-white/10") do
69
+ if @footer_html
70
+ raw(@footer_html)
71
+ elsif @footer_block
72
+ instance_eval(&@footer_block)
73
+ end
74
+ end
55
75
  end
56
76
  end
57
77
  end
58
78
  end
59
79
 
80
+ def footer(&block)
81
+ if defined?(view_context) && view_context
82
+ @footer_html = view_context.capture(&block)
83
+ else
84
+ @footer_block = block
85
+ end
86
+ end
87
+
88
+ alias_method :with_footer, :footer
89
+
60
90
  private
61
91
 
62
92
  def default_attrs
@@ -67,7 +97,7 @@ module Panda
67
97
  end
68
98
 
69
99
  def slideover_classes
70
- base = "flex absolute right-0 flex-col h-full bg-white divide-y divide-gray-200 shadow-xl basis-3/12 z-50"
100
+ base = "ml-auto block size-full max-w-md transform absolute right-0 h-full z-50"
71
101
  visibility = @open ? "" : "hidden"
72
102
  [base, visibility].compact.join(" ")
73
103
  end
@@ -4,7 +4,14 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class MyProfileController < BaseController
7
- before_action :set_initial_breadcrumb, only: %i[edit update]
7
+ before_action :set_initial_breadcrumb, only: %i[show edit update]
8
+
9
+ # Redirects to the edit form
10
+ # @type GET
11
+ # @return void
12
+ def show
13
+ redirect_to edit_admin_my_profile_path
14
+ end
8
15
 
9
16
  # Shows the edit form for the current user's profile
10
17
  # @type GET
@@ -33,6 +33,8 @@ module Panda
33
33
  "@rails/actioncable/src": "/panda/core/vendor/@rails--actioncable--src.js",
34
34
  "tailwindcss-stimulus-components": "/panda/core/tailwindcss-stimulus-components.js",
35
35
  "@fortawesome/fontawesome-free": "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@7.1.0/js/all.js",
36
+ "@tailwindplus/elements": "https://esm.sh/@tailwindplus/elements@1",
37
+ "cropperjs": "https://esm.sh/cropperjs@1.6.2",
36
38
  "panda/core/application": "/panda/core/application.js",
37
39
  "panda/core/controllers/toggle_controller": "/panda/core/controllers/toggle_controller.js",
38
40
  "panda/core/controllers/theme_form_controller": "/panda/core/controllers/theme_form_controller.js"