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.
- checksums.yaml +4 -4
- data/app/assets/tailwind/application.css +95 -0
- data/app/assets/tailwind/tailwind.config.js +8 -0
- data/app/builders/panda/core/form_builder.rb +163 -11
- data/app/components/panda/core/UI/button.rb +45 -24
- data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
- data/app/components/panda/core/admin/button_component.rb +27 -12
- data/app/components/panda/core/admin/container_component.rb +40 -5
- data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
- data/app/components/panda/core/admin/flash_message_component.rb +54 -36
- data/app/components/panda/core/admin/heading_component.rb +28 -19
- data/app/components/panda/core/admin/page_header_component.rb +107 -0
- data/app/components/panda/core/admin/panel_component.rb +1 -1
- data/app/components/panda/core/admin/slideover_component.rb +92 -4
- data/app/components/panda/core/admin/table_component.rb +11 -11
- data/app/components/panda/core/admin/tag_component.rb +39 -2
- data/app/components/panda/core/admin/user_display_component.rb +4 -5
- data/app/controllers/panda/core/admin/my_profile_controller.rb +10 -3
- data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
- data/app/helpers/panda/core/asset_helper.rb +33 -5
- data/app/helpers/panda/core/sessions_helper.rb +26 -1
- data/app/javascript/panda/core/application.js +8 -1
- data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
- data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
- data/app/javascript/panda/core/controllers/index.js +9 -3
- data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
- data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
- data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
- data/app/models/panda/core/user.rb +60 -6
- data/app/services/panda/core/attach_avatar_service.rb +71 -0
- data/app/views/layouts/panda/core/admin.html.erb +39 -14
- data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -1
- data/app/views/panda/core/admin/dashboard/show.html.erb +3 -3
- data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
- data/app/views/panda/core/admin/sessions/new.html.erb +3 -4
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +69 -14
- data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
- data/config/importmap.rb +20 -7
- data/config/routes.rb +10 -1
- data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
- data/lib/panda/core/asset_loader.rb +5 -2
- data/lib/panda/core/engine.rb +38 -28
- data/lib/panda/core/oauth_providers.rb +3 -3
- data/lib/panda/core/services/base_service.rb +19 -4
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +1 -0
- data/lib/tasks/panda_core_users.rake +158 -0
- metadata +13 -69
- data/lib/generators/panda/core/authentication/templates/reek_spec.rb +0 -43
- data/lib/generators/panda/core/dev_tools/USAGE +0 -24
- data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +0 -13
- data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +0 -18
- data/lib/generators/panda/core/dev_tools_generator.rb +0 -143
- data/lib/generators/panda/core/install_generator.rb +0 -41
- data/lib/generators/panda/core/templates/README +0 -25
- data/lib/generators/panda/core/templates/initializer.rb +0 -44
- data/lib/generators/panda/core/templates_generator.rb +0 -27
- data/lib/panda/core/testing/capybara_config.rb +0 -70
- data/lib/panda/core/testing/omniauth_helpers.rb +0 -52
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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-
|
|
22
|
-
data-transition-leave-to="translate-x-
|
|
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="
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
|
|
5
|
-
|
|
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-
|
|
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 <%=
|
|
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
|
-
<
|
|
4
|
-
<
|
|
5
|
-
<
|
|
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
|
-
<
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
<%
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
34
|
+
is_active = item[:path] == active_path
|
|
15
35
|
end
|
|
16
36
|
%>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<
|
|
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
|
-
|
|
31
|
-
|
|
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-
|
|
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 %>
|