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
@@ -1,12 +1,18 @@
1
1
  // Import and register Core Stimulus controllers
2
- import { application } from "../application"
2
+ import { application } from "../application.js"
3
3
 
4
- import ThemeFormController from "./theme_form_controller"
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
- import { Alert, Autosave, ColorPreview, Dropdown, Modal, Tabs, Popover, Toggle, Slideover } from "../tailwindcss-stimulus-components"
15
+ import { Alert, Autosave, ColorPreview, Dropdown, Modal, Tabs, Popover, Toggle, Slideover } from "../tailwindcss-stimulus-components.js"
10
16
  application.register('alert', Alert)
11
17
  application.register('autosave', Autosave)
12
18
  application.register('color-preview', ColorPreview)
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Toggle controller for showing/hiding elements
4
+ // Usage:
5
+ // <div data-controller="toggle">
6
+ // <button data-action="click->toggle#toggle">Toggle</button>
7
+ // <div data-toggle-target="toggleable" class="hidden">Content</div>
8
+ // </div>
9
+ export default class extends Controller {
10
+ static targets = ["toggleable"]
11
+
12
+ toggle(event) {
13
+ if (event) {
14
+ event.preventDefault()
15
+ }
16
+
17
+ this.toggleableTargets.forEach(target => {
18
+ target.classList.toggle("hidden")
19
+ })
20
+ }
21
+
22
+ show(event) {
23
+ if (event) {
24
+ event.preventDefault()
25
+ }
26
+
27
+ this.toggleableTargets.forEach(target => {
28
+ target.classList.remove("hidden")
29
+ })
30
+ }
31
+
32
+ hide(event) {
33
+ if (event) {
34
+ event.preventDefault()
35
+ }
36
+
37
+ this.toggleableTargets.forEach(target => {
38
+ target.classList.add("hidden")
39
+ })
40
+ }
41
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Tailwind Plus Elements Loader
3
+ *
4
+ * This module loads the Tailwind Plus Elements library for vanilla JS interactive components.
5
+ * It provides eight foundational primitives:
6
+ * - Autocomplete: enables custom combobox implementations
7
+ * - Command palette: builds searchable command interfaces
8
+ * - Dialog: creates modals, drawers, and overlays
9
+ * - Disclosure: handles collapsible sections and mobile menus
10
+ * - Dropdown menu: constructs option menus
11
+ * - Popover: manages floating UI elements
12
+ * - Select: builds custom dropdown selects
13
+ * - Tabs: creates tabbed interfaces
14
+ *
15
+ * Usage:
16
+ * import "@tailwindplus/elements"
17
+ *
18
+ * Or lazy-load when needed:
19
+ * import("@tailwindplus/elements").then(() => {
20
+ * // Elements are now available
21
+ * })
22
+ *
23
+ * Documentation: https://tailwindcss.com/blog/vanilla-js-support-for-tailwind-plus
24
+ */
25
+
26
+ // Auto-import Tailwind Plus Elements
27
+ // This makes custom elements like <el-dropdown>, <el-dialog>, etc. available
28
+ import "@tailwindplus/elements"
29
+
30
+ // Export for module consumers
31
+ export default "@tailwindplus/elements"
@@ -5,27 +5,70 @@ module Panda
5
5
  class User < ApplicationRecord
6
6
  self.table_name = "panda_core_users"
7
7
 
8
+ # Active Storage attachment for avatar
9
+ has_one_attached :avatar
10
+
8
11
  validates :email, presence: true, uniqueness: {case_sensitive: false}
9
12
 
10
13
  before_save :downcase_email
11
14
 
12
15
  # Scopes
13
- scope :admin, -> { 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
+ }
14
26
 
15
27
  def self.find_or_create_from_auth_hash(auth_hash)
16
28
  user = find_by(email: auth_hash.info.email.downcase)
17
- return user if user
18
29
 
19
- create!(
30
+ # Handle avatar for both new and existing users
31
+ avatar_url = auth_hash.info.image
32
+ if user
33
+ # Update avatar if URL has changed or no avatar is attached
34
+ if avatar_url.present? && (avatar_url != user.oauth_avatar_url || !user.avatar.attached?)
35
+ AttachAvatarService.call(user: user, avatar_url: avatar_url)
36
+ end
37
+ return user
38
+ end
39
+
40
+ # Support both schema versions: 'name' column or 'firstname'/'lastname' columns
41
+ attributes = {
20
42
  email: auth_hash.info.email.downcase,
21
- name: auth_hash.info.name || "Unknown User",
22
43
  image_url: auth_hash.info.image,
23
44
  is_admin: User.count.zero? # First user is admin
24
- )
45
+ }
46
+
47
+ # Add name attributes based on schema
48
+ if column_names.include?("name")
49
+ attributes[:name] = auth_hash.info.name || "Unknown User"
50
+ elsif column_names.include?("firstname") && column_names.include?("lastname")
51
+ # Split name into firstname/lastname if provided
52
+ full_name = auth_hash.info.name || "Unknown User"
53
+ name_parts = full_name.split(" ", 2)
54
+ attributes[:firstname] = name_parts[0] || "Unknown"
55
+ attributes[:lastname] = name_parts[1] || "User"
56
+ end
57
+
58
+ user = create!(attributes)
59
+
60
+ # Attach avatar for new user
61
+ if avatar_url.present?
62
+ AttachAvatarService.call(user: user, avatar_url: avatar_url)
63
+ end
64
+
65
+ user
25
66
  end
26
67
 
68
+ # Admin status check
69
+ # Note: Column is named 'admin' in newer schemas, 'is_admin' in older ones
27
70
  def admin?
28
- is_admin?
71
+ self[:admin] || self[:is_admin] || false
29
72
  end
30
73
 
31
74
  def active_for_authentication?
@@ -45,6 +88,17 @@ module Panda
45
88
  end
46
89
  end
47
90
 
91
+ # Returns the URL for the user's avatar
92
+ # Prefers Active Storage attachment over OAuth provider URL
93
+ def avatar_url
94
+ if avatar.attached?
95
+ Rails.application.routes.url_helpers.rails_blob_path(avatar, only_path: true)
96
+ elsif self[:image_url].present?
97
+ # Fallback to OAuth provider URL if no avatar is attached yet
98
+ self[:image_url]
99
+ end
100
+ end
101
+
48
102
  private
49
103
 
50
104
  def downcase_email
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+
5
+ module Panda
6
+ module Core
7
+ class AttachAvatarService < Services::BaseService
8
+ def initialize(user:, avatar_url:)
9
+ @user = user
10
+ @avatar_url = avatar_url
11
+ end
12
+
13
+ def call
14
+ return success if @avatar_url.blank?
15
+ return success if @avatar_url == @user.oauth_avatar_url && @user.avatar.attached?
16
+
17
+ begin
18
+ download_and_attach_avatar
19
+ @user.update_column(:oauth_avatar_url, @avatar_url)
20
+ success(avatar_attached: true)
21
+ rescue => e
22
+ Rails.logger.error("Failed to attach avatar for user #{@user.id}: #{e.message}")
23
+ failure(["Failed to attach avatar: #{e.message}"])
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def download_and_attach_avatar
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)
33
+ URI.open(@avatar_url, read_timeout: 10, open_timeout: 10, redirect: true) do |downloaded_file|
34
+ # standard:enable Security/Open
35
+ # Validate file size (max 5MB)
36
+ if downloaded_file.size > 5.megabytes
37
+ raise "Avatar file too large (#{downloaded_file.size} bytes)"
38
+ end
39
+
40
+ # Determine content type and filename
41
+ content_type = downloaded_file.content_type || "image/jpeg"
42
+ extension = determine_extension(content_type)
43
+ filename = "avatar_#{@user.id}_#{Time.current.to_i}#{extension}"
44
+
45
+ # Attach the avatar
46
+ @user.avatar.attach(
47
+ io: downloaded_file,
48
+ filename: filename,
49
+ content_type: content_type
50
+ )
51
+ end
52
+ # standard:enable Security/Open
53
+ end
54
+
55
+ def determine_extension(content_type)
56
+ case content_type
57
+ when /jpeg|jpg/
58
+ ".jpg"
59
+ when /png/
60
+ ".png"
61
+ when /gif/
62
+ ".gif"
63
+ when /webp/
64
+ ".webp"
65
+ else
66
+ ".jpg"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -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 %>
@@ -1,4 +1,5 @@
1
1
  <%= render "panda/core/shared/header", html_class: "h-full", body_class: "bg-gradient-admin" %>
2
+ <%= render "panda/core/admin/shared/flash" %>
2
3
  <div class="flex flex-col items-center justify-center min-h-screen px-4">
3
4
  <%= yield %>
4
5
  </div>
@@ -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? %>
@@ -8,7 +8,32 @@
8
8
  data: { controller: "theme-form" } do |f| %>
9
9
  <%= render Panda::Core::Admin::FormErrorComponent.new(model: user) %>
10
10
 
11
- <div class="space-y-4">
11
+ <div class="space-y-6">
12
+ <!-- Avatar Section -->
13
+ <div class="col-span-full">
14
+ <%= f.label :avatar, "Profile Picture", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
15
+ <div class="mt-2 flex items-center gap-x-3">
16
+ <% if user.avatar.attached? %>
17
+ <%= image_tag main_app.url_for(user.avatar), alt: user.name, class: "w-48 h-48 max-w-48 rounded-full object-cover" %>
18
+ <% else %>
19
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class="w-48 h-48 text-gray-300 dark:text-gray-500">
20
+ <path d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" clip-rule="evenodd" fill-rule="evenodd" />
21
+ </svg>
22
+ <% end %>
23
+ <label for="<%= f.field_id(:avatar) %>" class="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 cursor-pointer dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20">
24
+ Change
25
+ </label>
26
+ <%= f.file_field :avatar,
27
+ accept: "image/png,image/jpeg,image/jpg,image/gif,image/webp",
28
+ class: "sr-only" %>
29
+ </div>
30
+ <% if user.avatar.attached? %>
31
+ <p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
32
+ Current: <%= user.avatar.filename %> (<%= number_to_human_size(user.avatar.byte_size) %>)
33
+ </p>
34
+ <% end %>
35
+ </div>
36
+
12
37
  <div class="field">
13
38
  <%= f.label :name %>
14
39
  <%= f.text_field :name %>
@@ -11,13 +11,12 @@
11
11
  <% providers = @providers || Panda::Core.config.authentication_providers.keys %>
12
12
  <% providers.each do |provider| %>
13
13
  <% provider_config = Panda::Core.config.authentication_providers[provider] %>
14
- <% provider_name = provider_config&.dig(:name) || provider.to_s.humanize %>
15
14
  <% provider_path = provider_config&.dig(:path_name) || provider %>
16
15
  <div class="mt-4 text-center sm:mx-auto sm:w-full sm:max-w-sm">
17
16
  <%= form_tag "#{Panda::Core.config.admin_path}/auth/#{provider_path}", method: "post", data: {turbo: false} do %>
18
17
  <button type="submit" id="button-sign-in-<%= provider_path %>" class="inline-flex gap-x-2 items-center py-2.5 px-3.5 mx-auto mb-4 bg-white text-gray-900 rounded-md border min-w-56 border-neutral-400 hover:bg-gray-50">
19
- <i class="fa-brands fa-<%= oauth_provider_icon(provider) %> text-xl mr-1"></i>
20
- Sign in with <%= provider_name %>
18
+ <i class="<%= oauth_provider_non_brand?(provider) ? 'fa-solid' : 'fa-brands' %> fa-<%= oauth_provider_icon(provider) %> text-xl mr-1"></i>
19
+ Sign in with <%= oauth_provider_name(provider, provider_config) %>
21
20
  </button>
22
21
  <% end %>
23
22
  </div>
@@ -36,4 +35,4 @@
36
35
  </div>
37
36
  </div>
38
37
  <% end %>
39
- </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"></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 fa-regular fa-chevron-right font-light <%= breadcrumb_styles %> py-5 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" data-controller="toggle">
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>
@@ -1,22 +1,71 @@
1
1
  <nav class="flex flex-col flex-1">
2
2
  <ul role="list" class="flex flex-col flex-1">
3
3
  <a class="block p-0 mt-4 mb-4 ml-2 text-xl font-medium text-white"><%= Panda::Core.config.admin_title || "Panda Admin" %></a>
4
- <% Panda::Core.config.admin_navigation_items&.call(current_user)&.each do |item| %>
4
+ <%
5
+ # Get navigation items in original order for display
6
+ nav_items = Panda::Core.config.admin_navigation_items&.call(current_user) || []
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
+
17
+ # Find the most specific matching path by checking longest paths first
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 + "/") }
22
+ %>
23
+ <% nav_items.each_with_index do |item, index| %>
5
24
  <li>
6
25
  <%
7
- # Exact match for dashboard, starts_with for others
8
- # Check if current path matches this nav item
9
- is_active = if request.path == item[:path]
10
- true
11
- elsif request.path.starts_with?(item[:path] + "/")
12
- true
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
13
33
  else
14
- false
34
+ is_active = item[:path] == active_path
15
35
  end
16
36
  %>
17
- <%= 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 %>
18
- <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
19
- <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 %>
20
69
  <% end %>
21
70
  </li>
22
71
  <% end %>
@@ -27,11 +76,17 @@
27
76
  <% end %>
28
77
  </li>
29
78
  <li class="mt-auto">
30
- <%= 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 %>
31
- <% if !current_user.image_url.to_s.empty? %>
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 %>
84
+ <% if current_user.avatar.attached? %>
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>
86
+ <% elsif !current_user.image_url.to_s.empty? %>
32
87
  <span class="text-center w-6"><img src="<%= current_user.image_url %>" class="w-auto h-7 rounded-full"></span>
33
88
  <% else %>
34
- <span class="text-center w-6"><i class="text-xl fa-regular fa-circle-user fa-fw"></i></span>
89
+ <span class="text-center w-6"><i class="text-xl fa-solid fa-circle-user fa-fw"></i></span>
35
90
  <% end %>
36
91
  <span><%= current_user.name %></span>
37
92
  <% end %>
@@ -30,4 +30,4 @@
30
30
  </div>
31
31
  </div>
32
32
  </div>
33
- </div>
33
+ </div>