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
@@ -0,0 +1,158 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Cropper from "cropperjs"
3
+
4
+ // Connects to data-controller="image-cropper"
5
+ export default class extends Controller {
6
+ static targets = ["input", "preview", "cropperContainer", "croppedInput"]
7
+ static values = {
8
+ aspectRatio: Number,
9
+ minWidth: { type: Number, default: 0 },
10
+ minHeight: { type: Number, default: 0 }
11
+ }
12
+
13
+ connect() {
14
+ this.cropper = null
15
+ this.originalFile = null
16
+ }
17
+
18
+ disconnect() {
19
+ if (this.cropper) {
20
+ this.cropper.destroy()
21
+ this.cropper = null
22
+ }
23
+ }
24
+
25
+ handleFileSelect(event) {
26
+ const file = event.target.files[0]
27
+ if (file && file.type.startsWith('image/')) {
28
+ this.originalFile = file
29
+ this.showCropper(file)
30
+ }
31
+ }
32
+
33
+ showCropper(file) {
34
+ const reader = new FileReader()
35
+ reader.onload = (e) => {
36
+ // Show the cropper container
37
+ this.cropperContainerTarget.classList.remove('hidden')
38
+
39
+ // Set the image source
40
+ this.previewTarget.src = e.target.result
41
+
42
+ // Initialize cropper after a short delay to ensure image is loaded
43
+ setTimeout(() => this.initializeCropper(), 100)
44
+ }
45
+ reader.readAsDataURL(file)
46
+ }
47
+
48
+ initializeCropper() {
49
+ // Destroy existing cropper if any
50
+ if (this.cropper) {
51
+ this.cropper.destroy()
52
+ }
53
+
54
+ const options = {
55
+ viewMode: 1,
56
+ dragMode: 'move',
57
+ aspectRatio: this.aspectRatioValue || NaN,
58
+ autoCropArea: 1,
59
+ restore: false,
60
+ guides: true,
61
+ center: true,
62
+ highlight: true,
63
+ cropBoxMovable: true,
64
+ cropBoxResizable: true,
65
+ toggleDragModeOnDblclick: false,
66
+ responsive: true,
67
+ checkOrientation: true,
68
+ minContainerWidth: 200,
69
+ minContainerHeight: 200
70
+ }
71
+
72
+ this.cropper = new Cropper(this.previewTarget, options)
73
+ }
74
+
75
+ crop() {
76
+ if (!this.cropper) return
77
+
78
+ const canvas = this.cropper.getCroppedCanvas({
79
+ minWidth: this.minWidthValue,
80
+ minHeight: this.minHeightValue,
81
+ maxWidth: 4096,
82
+ maxHeight: 4096,
83
+ fillColor: '#fff',
84
+ imageSmoothingEnabled: true,
85
+ imageSmoothingQuality: 'high'
86
+ })
87
+
88
+ canvas.toBlob((blob) => {
89
+ // Create a new File object with the cropped image
90
+ const fileName = this.originalFile.name
91
+ const croppedFile = new File([blob], fileName, { type: this.originalFile.type })
92
+
93
+ // Create a DataTransfer to set the file input value
94
+ const dataTransfer = new DataTransfer()
95
+ dataTransfer.items.add(croppedFile)
96
+ this.inputTarget.files = dataTransfer.files
97
+
98
+ // Hide the cropper
99
+ this.cropperContainerTarget.classList.add('hidden')
100
+
101
+ // Destroy the cropper instance
102
+ if (this.cropper) {
103
+ this.cropper.destroy()
104
+ this.cropper = null
105
+ }
106
+
107
+ // Dispatch event to notify form of change
108
+ this.inputTarget.dispatchEvent(new Event('change', { bubbles: true }))
109
+ }, this.originalFile.type)
110
+ }
111
+
112
+ cancel() {
113
+ // Clear the file input
114
+ this.inputTarget.value = ''
115
+
116
+ // Hide the cropper
117
+ this.cropperContainerTarget.classList.add('hidden')
118
+
119
+ // Destroy the cropper instance
120
+ if (this.cropper) {
121
+ this.cropper.destroy()
122
+ this.cropper = null
123
+ }
124
+ }
125
+
126
+ reset() {
127
+ if (this.cropper) {
128
+ this.cropper.reset()
129
+ }
130
+ }
131
+
132
+ rotate(event) {
133
+ const degrees = parseInt(event.currentTarget.dataset.degrees) || 90
134
+ if (this.cropper) {
135
+ this.cropper.rotate(degrees)
136
+ }
137
+ }
138
+
139
+ flip(event) {
140
+ const direction = event.currentTarget.dataset.direction || 'horizontal'
141
+ if (this.cropper) {
142
+ if (direction === 'horizontal') {
143
+ const scaleX = this.cropper.getData().scaleX || 1
144
+ this.cropper.scaleX(-scaleX)
145
+ } else {
146
+ const scaleY = this.cropper.getData().scaleY || 1
147
+ this.cropper.scaleY(-scaleY)
148
+ }
149
+ }
150
+ }
151
+
152
+ zoom(event) {
153
+ const ratio = parseFloat(event.currentTarget.dataset.ratio) || 0.1
154
+ if (this.cropper) {
155
+ this.cropper.zoom(ratio)
156
+ }
157
+ }
158
+ }
@@ -4,6 +4,12 @@ import { application } from "../application.js"
4
4
  import ThemeFormController from "./theme_form_controller.js"
5
5
  application.register("theme-form", ThemeFormController)
6
6
 
7
+ import ImageCropperController from "./image_cropper_controller.js"
8
+ application.register("image-cropper", ImageCropperController)
9
+
10
+ import NavigationToggleController from "./navigation_toggle_controller.js"
11
+ application.register("navigation-toggle", NavigationToggleController)
12
+
7
13
  // Import and register TailwindCSS Stimulus Components
8
14
  // These are needed for UI components like slideover, modals, alerts, etc.
9
15
  import { Alert, Autosave, ColorPreview, Dropdown, Modal, Tabs, Popover, Toggle, Slideover } from "../tailwindcss-stimulus-components.js"
@@ -0,0 +1,60 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Navigation toggle controller for expandable menu items
4
+ // Usage:
5
+ // <div data-controller="navigation-toggle">
6
+ // <button data-navigation-toggle-target="button"
7
+ // data-action="click->navigation-toggle#toggle"
8
+ // aria-controls="sub-menu-1"
9
+ // aria-expanded="false">
10
+ // <span>Menu Item</span>
11
+ // <i data-navigation-toggle-target="icon" class="fa-solid fa-chevron-right"></i>
12
+ // </button>
13
+ // <div id="sub-menu-1" data-navigation-toggle-target="menu" class="hidden">
14
+ // <a href="#">Sub Item 1</a>
15
+ // <a href="#">Sub Item 2</a>
16
+ // </div>
17
+ // </div>
18
+ export default class extends Controller {
19
+ static targets = ["button", "menu", "icon"]
20
+
21
+ connect() {
22
+ // Check if this menu should be expanded by default (if a child is active)
23
+ const hasActiveChild = this.menuTarget.querySelector(".bg-mid")
24
+ if (hasActiveChild) {
25
+ this.expand()
26
+ }
27
+ }
28
+
29
+ toggle(event) {
30
+ if (event) {
31
+ event.preventDefault()
32
+ }
33
+
34
+ const isExpanded = this.buttonTarget.getAttribute("aria-expanded") === "true"
35
+
36
+ if (isExpanded) {
37
+ this.collapse()
38
+ } else {
39
+ this.expand()
40
+ }
41
+ }
42
+
43
+ expand() {
44
+ this.menuTarget.classList.remove("hidden")
45
+ this.buttonTarget.setAttribute("aria-expanded", "true")
46
+
47
+ if (this.hasIconTarget) {
48
+ this.iconTarget.classList.add("rotate-90")
49
+ }
50
+ }
51
+
52
+ collapse() {
53
+ this.menuTarget.classList.add("hidden")
54
+ this.buttonTarget.setAttribute("aria-expanded", "false")
55
+
56
+ if (this.hasIconTarget) {
57
+ this.iconTarget.classList.remove("rotate-90")
58
+ }
59
+ }
60
+ }
@@ -13,7 +13,16 @@ module Panda
13
13
  before_save :downcase_email
14
14
 
15
15
  # Scopes
16
- scope :admins, -> { where(is_admin: true) }
16
+ # Support both 'admin' (newer) and 'is_admin' (older) column names
17
+ scope :admins, -> {
18
+ if column_names.include?("admin")
19
+ where(admin: true)
20
+ elsif column_names.include?("is_admin")
21
+ where(is_admin: true)
22
+ else
23
+ none
24
+ end
25
+ }
17
26
 
18
27
  def self.find_or_create_from_auth_hash(auth_hash)
19
28
  user = find_by(email: auth_hash.info.email.downcase)
@@ -56,8 +65,10 @@ module Panda
56
65
  user
57
66
  end
58
67
 
68
+ # Admin status check
69
+ # Note: Column is named 'admin' in newer schemas, 'is_admin' in older ones
59
70
  def admin?
60
- is_admin
71
+ self[:admin] || self[:is_admin] || false
61
72
  end
62
73
 
63
74
  def active_for_authentication?
@@ -28,7 +28,10 @@ module Panda
28
28
 
29
29
  def download_and_attach_avatar
30
30
  # Open the URL with a timeout and size limit
31
+ # standard:disable Security/Open
32
+ # Safe in this context as URL comes from trusted OAuth providers (Microsoft, Google, GitHub)
31
33
  URI.open(@avatar_url, read_timeout: 10, open_timeout: 10, redirect: true) do |downloaded_file|
34
+ # standard:enable Security/Open
32
35
  # Validate file size (max 5MB)
33
36
  if downloaded_file.size > 5.megabytes
34
37
  raise "Avatar file too large (#{downloaded_file.size} bytes)"
@@ -46,6 +49,7 @@ module Panda
46
49
  content_type: content_type
47
50
  )
48
51
  end
52
+ # standard:enable Security/Open
49
53
  end
50
54
 
51
55
  def determine_extension(content_type)
@@ -13,26 +13,51 @@
13
13
  <%= yield %>
14
14
  </div>
15
15
  <% if content_for :sidebar %>
16
- <div data-toggle-target="toggleable" class="hidden flex absolute right-0 flex-col h-full bg-white divide-y divide-gray-200 shadow-xl basis-3/12"
17
- data-transition-enter="transform transition ease-in-out duration-500"
16
+ <!-- Backdrop overlay -->
17
+ <div data-toggle-target="toggleable" class="hidden fixed inset-0 bg-gray-500/75 transition-opacity duration-500 ease-in-out dark:bg-gray-900/50 z-40"
18
+ data-transition-enter="transition-opacity duration-500 ease-in-out"
19
+ data-transition-enter-from="opacity-0"
20
+ data-transition-enter-to="opacity-100"
21
+ data-transition-leave="transition-opacity duration-500 ease-in-out"
22
+ data-transition-leave-from="opacity-100"
23
+ data-transition-leave-to="opacity-0"></div>
24
+
25
+ <!-- Slideover panel -->
26
+ <div data-toggle-target="toggleable" class="hidden ml-auto block size-full max-w-md transform fixed right-0 top-0 h-full z-50"
27
+ data-transition-enter="transform transition ease-in-out duration-500 sm:duration-700"
18
28
  data-transition-enter-from="translate-x-full"
19
29
  data-transition-enter-to="translate-x-0"
20
- data-transition-leave="transform transition ease-in-out duration-500"
21
- data-transition-leave-from="translate-x-full"
22
- data-transition-leave-to="translate-x-0"
30
+ data-transition-leave="transform transition ease-in-out duration-500 sm:duration-700"
31
+ data-transition-leave-from="translate-x-0"
32
+ data-transition-leave-to="translate-x-full"
23
33
  id="slideover">
24
- <div class="overflow-y-auto flex-1 h-0">
25
- <div class="py-3 px-4 mb-4 bg-black">
26
- <div class="flex justify-between items-center">
27
- <h2 class="text-base font-semibold leading-6 text-white" id="slideover-title"><i class="mr-2 fa-solid fa-gear"></i> <%= yield :sidebar_title %> </h2>
28
- <button type="button" data-action="click->toggle#toggle touch->toggle#toggle"><i class="font-bold text-white fa-solid fa-xmark right"></i></button>
34
+ <div class="relative flex h-full flex-col bg-white shadow-xl dark:bg-gray-800 dark:after:absolute dark:after:inset-y-0 dark:after:left-0 dark:after:w-px dark:after:bg-white/10">
35
+ <!-- Header -->
36
+ <div class="bg-gradient-admin px-4 py-3 sm:px-6">
37
+ <div class="flex items-center justify-between">
38
+ <h2 class="text-base font-semibold text-white" id="slideover-title"><%= yield :sidebar_title %></h2>
39
+ <div class="ml-3 flex items-center">
40
+ <button type="button" data-action="click->toggle#toggle touch->toggle#toggle" class="flex items-center gap-x-2 rounded-md px-3 py-1.5 text-white bg-white/10 hover:bg-white/20 transition-colors border border-white/20">
41
+ <span class="text-sm font-semibold uppercase tracking-wide">Close</span>
42
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="size-5">
43
+ <path d="M6 18 18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
44
+ </svg>
45
+ </button>
46
+ </div>
29
47
  </div>
30
48
  </div>
31
- <div class="flex flex-col flex-1 justify-between">
32
- <div class="px-4 space-y-6">
33
- <%= yield :sidebar %>
34
- </div>
49
+
50
+ <!-- Content -->
51
+ <div class="flex-1 overflow-y-auto">
52
+ <%= yield :sidebar %>
35
53
  </div>
54
+
55
+ <!-- Footer (if present) -->
56
+ <% if content_for?(:sidebar_footer) %>
57
+ <div class="flex shrink-0 justify-end gap-x-3 border-t border-gray-200 px-4 py-4 dark:border-white/10">
58
+ <%= yield :sidebar_footer %>
59
+ </div>
60
+ <% end %>
36
61
  </div>
37
62
  </div>
38
63
  <% end %>
@@ -45,7 +45,7 @@
45
45
  </div>
46
46
  </div>
47
47
 
48
- <% # Hook for additional dashboard cards %>
48
+ <%# Hook for additional dashboard cards %>
49
49
  <% if Panda::Core.config.respond_to?(:admin_dashboard_cards) %>
50
50
  <% cards = Panda::Core.config.admin_dashboard_cards&.call(current_user) %>
51
51
  <% cards&.each do |card| %>
@@ -1,8 +1,8 @@
1
1
  <div class="" data-controller="dashboard">
2
2
  <%= render Panda::Core::Admin::ContainerComponent.new do |container| %>
3
- <% container.with_heading(text: "Dashboard", level: 1) %>
4
-
5
- <% # Hook for dashboard widgets %>
3
+ <% container.heading(text: "Dashboard", level: 1) %>
4
+
5
+ <%# Hook for dashboard widgets %>
6
6
  <% if Panda::Core.config.admin_dashboard_widgets %>
7
7
  <% widgets = Panda::Core.config.admin_dashboard_widgets.call(current_user) %>
8
8
  <% if widgets && widgets.any? %>
@@ -35,4 +35,4 @@
35
35
  </div>
36
36
  </div>
37
37
  <% end %>
38
- </div>
38
+ </div>
@@ -1,28 +1,18 @@
1
1
  <% breadcrumb_styles = "text-black/60 hover:text-black/80" %>
2
2
 
3
- <div class="flex mt-1 -mb-1">
4
- <nav aria-label="Breadcrumb" id="panda-breadcrumbs" class="grow">
5
- <ol role="list" class="px-4 w-full sm:px-6">
3
+ <nav aria-label="Breadcrumb" id="panda-breadcrumbs" class="px-4 w-full sm:px-6 py-2.5">
4
+ <ol role="list">
5
+ <li class="inline-block">
6
+ <a href="<%= Panda::Core.config.admin_path %>" class="<%= breadcrumb_styles %>">
7
+ <i class="fa fa-solid fa-house fa-fw inline-block w-4 text-center"></i>
8
+ <span class="sr-only">Home</span>
9
+ </a>
10
+ </li>
11
+ <% breadcrumbs.each do |crumb| %>
6
12
  <li class="inline-block">
7
- <a href="<%= Panda::Core.config.admin_path %>" class="<%= breadcrumb_styles %>">
8
- <i class="fa fa-solid fa-house fa-fw inline-block w-4 text-center"></i>
9
- <span class="sr-only">Home</span>
10
- </a>
13
+ <i class="fa-solid fa-chevron-right fa-fw inline-block w-4 text-center font-light <%= breadcrumb_styles %> px-2"></i>
14
+ <a href="<%= crumb.path %>" class="text-sm font-normal <%= breadcrumb_styles %>"><%= crumb.name %></a>
11
15
  </li>
12
- <% breadcrumbs.each do |crumb| %>
13
- <li class="inline-block">
14
- <i class="fa-solid fa-chevron-right fa-fw inline-block w-4 text-center font-light <%= breadcrumb_styles %> px-2"></i>
15
- <a href="<%= crumb.path %>" class="text-sm font-normal <%= breadcrumb_styles %>"><%= crumb.name %></a>
16
- </li>
17
- <% end %>
18
- </ol>
19
- </nav>
20
-
21
- <% if content_for :sidebar %>
22
- <div class="pt-4 pr-8 text-black/80" tabindex="-1">
23
- <button type="button" id="slideover-toggle" data-action="click->toggle#toggle touch->toggle#toggle" class="text-sm font-light">
24
- <i class="mr-1 fa-light fa-gear"></i> <%= yield :sidebar_title %>
25
- </button>
26
- </div>
27
- <% end %>
28
- </div>
16
+ <% end %>
17
+ </ol>
18
+ </nav>
@@ -5,21 +5,67 @@
5
5
  # Get navigation items in original order for display
6
6
  nav_items = Panda::Core.config.admin_navigation_items&.call(current_user) || []
7
7
 
8
+ # Helper to recursively collect all paths from nav items
9
+ def collect_paths(items)
10
+ items.flat_map do |item|
11
+ paths = item[:path] ? [item[:path]] : []
12
+ paths += collect_paths(item[:children] || [])
13
+ paths
14
+ end
15
+ end
16
+
8
17
  # Find the most specific matching path by checking longest paths first
9
- active_path = nav_items
10
- .sort_by { |item| -item[:path].length }
11
- .find { |item| request.path == item[:path] || request.path.starts_with?(item[:path] + "/") }
12
- &.dig(:path)
18
+ all_paths = collect_paths(nav_items)
19
+ active_path = all_paths
20
+ .sort_by { |path| -path.length }
21
+ .find { |path| request.path == path || request.path.starts_with?(path + "/") }
13
22
  %>
14
- <% nav_items.each do |item| %>
23
+ <% nav_items.each_with_index do |item, index| %>
15
24
  <li>
16
25
  <%
17
- # Check if this item is the active one
18
- is_active = item[:path] == active_path
26
+ has_children = item[:children].present?
27
+
28
+ # For items with children, check if any child is active
29
+ if has_children
30
+ is_active = item[:children].any? do |child|
31
+ child[:path] && (request.path == child[:path] || request.path.starts_with?(child[:path] + "/"))
32
+ end
33
+ else
34
+ is_active = item[:path] == active_path
35
+ end
19
36
  %>
20
- <%= link_to item[:path], class: "#{is_active ? "bg-mid text-white relative flex items-center transition-all py-3 px-2 mb-2 rounded-md group gap-x-3 text-base leading-6 font-normal" : "text-white hover:bg-mid/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal"}" do %>
21
- <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
22
- <span><%= item[:label] %></span>
37
+
38
+ <% if has_children %>
39
+ <div class="space-y-1" data-controller="navigation-toggle">
40
+ <button type="button"
41
+ data-navigation-toggle-target="button"
42
+ data-action="click->navigation-toggle#toggle"
43
+ aria-controls="sub-menu-<%= index %>"
44
+ aria-expanded="false"
45
+ class="<%= is_active ? 'bg-mid text-white' : 'text-white hover:bg-mid/60' %> transition-all group flex items-center w-full gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal">
46
+ <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
47
+ <span class="flex-1 text-left"><%= item[:label] %></span>
48
+ <i class="fa-solid fa-chevron-right text-xs transition-transform duration-150 ease-in-out"
49
+ data-navigation-toggle-target="icon"></i>
50
+ </button>
51
+ <div id="sub-menu-<%= index %>"
52
+ class="space-y-1 hidden"
53
+ data-navigation-toggle-target="menu">
54
+ <% item[:children].each do |child| %>
55
+ <%
56
+ child_is_active = child[:path] && (request.path == child[:path] || request.path.starts_with?(child[:path] + "/"))
57
+ %>
58
+ <%= link_to child[:path], class: "#{child_is_active ? 'bg-mid text-white' : 'text-white hover:bg-mid/60'} group flex items-center w-full py-2 pr-2 pl-11 rounded-md text-sm font-normal transition-all" do %>
59
+ <%= child[:label] %>
60
+ <% end %>
61
+ <% end %>
62
+ </div>
63
+ </div>
64
+ <% else %>
65
+ <%= link_to item[:path], class: "#{is_active ? "bg-mid text-white relative flex items-center transition-all py-3 px-2 mb-2 rounded-md group gap-x-3 text-base leading-6 font-normal" : "text-white hover:bg-mid/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal"}" do %>
66
+ <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
67
+ <span><%= item[:label] %></span>
68
+ <% end %>
23
69
  <% end %>
24
70
  </li>
25
71
  <% end %>
@@ -30,7 +76,11 @@
30
76
  <% end %>
31
77
  </li>
32
78
  <li class="mt-auto">
33
- <%= link_to panda_core.edit_admin_my_profile_path, class: "text-white hover:bg-mid/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal w-full", title: "Edit my Profile" do %>
79
+ <%
80
+ # Check if we're in the my_profile section
81
+ is_my_profile_active = request.path.starts_with?("#{Panda::Core.config.admin_path}/my_profile")
82
+ %>
83
+ <%= link_to panda_core.admin_my_profile_path, class: "#{is_my_profile_active ? 'bg-mid text-white' : 'text-white hover:bg-mid/60'} transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal w-full", title: "My Profile" do %>
34
84
  <% if current_user.avatar.attached? %>
35
85
  <span class="text-center w-6"><%= image_tag main_app.url_for(current_user.avatar), alt: current_user.name, class: "w-auto h-7 rounded-full object-cover" %></span>
36
86
  <% elsif !current_user.image_url.to_s.empty? %>
@@ -30,4 +30,4 @@
30
30
  </div>
31
31
  </div>
32
32
  </div>
33
- </div>
33
+ </div>
data/config/importmap.rb CHANGED
@@ -6,6 +6,7 @@ pin "panda/core/application", to: "/panda/core/application.js"
6
6
  pin "panda/core/controllers/index", to: "/panda/core/controllers/index.js"
7
7
  pin "panda/core/controllers/toggle_controller", to: "/panda/core/controllers/toggle_controller.js"
8
8
  pin "panda/core/controllers/theme_form_controller", to: "/panda/core/controllers/theme_form_controller.js"
9
+ pin "panda/core/controllers/image_cropper_controller", to: "/panda/core/controllers/image_cropper_controller.js"
9
10
  pin "panda/core/tailwindplus-elements", to: "/panda/core/tailwindplus-elements.js"
10
11
 
11
12
  # Base JavaScript dependencies for Panda Core (vendored for reliability)
@@ -21,3 +22,7 @@ pin "@fortawesome/fontawesome-free", to: "https://ga.jspm.io/npm:@fortawesome/fo
21
22
  # Provides: Autocomplete, Command palette, Dialog, Disclosure, Dropdown menu, Popover, Select, Tabs
22
23
  # Note: Using esm.sh instead of jsdelivr for better ES module support
23
24
  pin "@tailwindplus/elements", to: "https://esm.sh/@tailwindplus/elements@1", preload: false
25
+
26
+ # Cropper.js - Image cropping library (via esm.sh for reliable ES module support)
27
+ pin "cropperjs", to: "https://esm.sh/cropperjs@1.6.2"
28
+ # Note: Cropper.css is loaded separately via stylesheet_link_tag in views that use cropper
data/config/routes.rb CHANGED
@@ -17,7 +17,7 @@ Panda::Core::Engine.routes.draw do
17
17
  get "/", to: "admin/dashboard#show", as: :root
18
18
 
19
19
  # Profile management
20
- resource :my_profile, only: %i[edit update], controller: "admin/my_profile", path: "my_profile"
20
+ resource :my_profile, only: %i[show edit update], controller: "admin/my_profile", path: "my_profile"
21
21
 
22
22
  # Test-only authentication endpoint (available in development and test environments)
23
23
  # This bypasses OAuth for faster, more reliable test execution
@@ -39,11 +39,14 @@ module Panda
39
39
 
40
40
  # Check if GitHub-hosted assets should be used
41
41
  def use_github_assets?
42
+ # In test, never use GitHub assets (use local engine assets instead)
43
+ # This allows system tests to load CSS/JS from the engine's public directory
44
+ return false if Rails.env.test? || in_test_environment?
45
+
42
46
  # Use GitHub assets in production or when explicitly enabled
43
47
  Rails.env.production? ||
44
48
  ENV["PANDA_CORE_USE_GITHUB_ASSETS"] == "true" ||
45
- !development_assets_available? ||
46
- ((Rails.env.test? || in_test_environment?) && compiled_assets_available?)
49
+ !development_assets_available?
47
50
  end
48
51
 
49
52
  private
@@ -1,15 +1,35 @@
1
1
  require "rubygems"
2
+ require "stringio"
2
3
 
3
4
  require "rails/engine"
4
5
  require "omniauth"
5
- require "omniauth/rails_csrf_protection"
6
- require "view_component"
6
+
7
+ # Silence ActiveSupport::Configurable deprecation from omniauth-rails_csrf_protection
8
+ # This gem uses the deprecated module but hasn't been updated yet
9
+ # Issue: https://github.com/cookpad/omniauth-rails_csrf_protection/issues/23
10
+ # This can be removed once the gem is updated or Rails 8.2 is released
11
+ #
12
+ # We suppress the warning by temporarily redirecting stderr since
13
+ # ActiveSupport::Deprecation.silence was removed in Rails 8.1
14
+ original_stderr = $stderr
15
+ $stderr = StringIO.new
16
+ begin
17
+ require "omniauth/rails_csrf_protection"
18
+ ensure
19
+ $stderr = original_stderr
20
+ end
7
21
 
8
22
  module Panda
9
23
  module Core
10
24
  class Engine < ::Rails::Engine
11
25
  isolate_namespace Panda::Core
12
26
 
27
+ # For testing: Don't expose engine migrations since we use "copy to host app" strategy
28
+ # In test environment, migrations should be copied to the host app
29
+ if Rails.env.test?
30
+ config.paths["db/migrate"] = []
31
+ end
32
+
13
33
  config.eager_load_namespaces << Panda::Core::Engine
14
34
 
15
35
  # Add engine's app directories to autoload paths
@@ -46,14 +66,6 @@ module Panda
46
66
  g.factory_bot dir: "spec/factories"
47
67
  end
48
68
 
49
- initializer "panda_core.append_migrations" do |app|
50
- unless app.root.to_s.match?(root.to_s)
51
- config.paths["db/migrate"].expanded.each do |expanded_path|
52
- app.config.paths["db/migrate"] << expanded_path
53
- end
54
- end
55
- end
56
-
57
69
  initializer "panda_core.config" do |app|
58
70
  # Configuration is already initialized with defaults in Configuration class
59
71
  end
@@ -76,6 +88,10 @@ module Panda
76
88
  end
77
89
 
78
90
  initializer "panda_core.omniauth" do |app|
91
+ # Load OAuth provider gems
92
+ require_relative "oauth_providers"
93
+ Panda::Core::OAuthProviders.setup
94
+
79
95
  # Mount OmniAuth at configurable admin path
80
96
  app.middleware.use OmniAuth::Builder do
81
97
  # Configure OmniAuth to use the configured admin path
@@ -121,21 +137,6 @@ module Panda
121
137
  require root.join("app/components/panda/core/base")
122
138
  end
123
139
 
124
- # Set up ViewComponent and Lookbook previews
125
- initializer "panda_core.view_component" do |app|
126
- app.config.view_component.preview_paths ||= []
127
- app.config.view_component.preview_paths << root.join("spec/components/previews")
128
-
129
- # Add preview directories to autoload paths in development
130
- if Rails.env.development?
131
- # Handle frozen autoload_paths array
132
- if app.config.autoload_paths.frozen?
133
- app.config.autoload_paths = app.config.autoload_paths.dup
134
- end
135
- app.config.autoload_paths << root.join("spec/components/previews")
136
- end
137
- end
138
-
139
140
  # Create AdminController alias after controllers are loaded
140
141
  # This allows other gems to inherit from Panda::Core::AdminController
141
142
  initializer "panda_core.admin_controller_alias", after: :load_config_initializers do