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.
- checksums.yaml +4 -4
- data/app/builders/panda/core/form_builder.rb +163 -11
- data/app/components/panda/core/admin/button_component.rb +27 -12
- data/app/components/panda/core/admin/container_component.rb +13 -1
- data/app/components/panda/core/admin/heading_component.rb +22 -14
- data/app/components/panda/core/admin/slideover_component.rb +52 -22
- data/app/controllers/panda/core/admin/my_profile_controller.rb +8 -1
- data/app/helpers/panda/core/asset_helper.rb +2 -0
- data/app/javascript/panda/core/controllers/image_cropper_controller.js +158 -0
- data/app/javascript/panda/core/controllers/index.js +6 -0
- data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +60 -0
- data/app/models/panda/core/user.rb +13 -2
- data/app/services/panda/core/attach_avatar_service.rb +4 -0
- data/app/views/layouts/panda/core/admin.html.erb +39 -14
- 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/sessions/new.html.erb +1 -1
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +14 -24
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +61 -11
- data/app/views/panda/core/admin/shared/_slideover.html.erb +1 -1
- data/config/importmap.rb +5 -0
- data/config/routes.rb +1 -1
- data/lib/panda/core/asset_loader.rb +5 -2
- data/lib/panda/core/engine.rb +26 -25
- data/lib/panda/core/oauth_providers.rb +3 -3
- data/lib/panda/core/version.rb +1 -1
- metadata +3 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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? %>
|
|
@@ -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">
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
.
|
|
12
|
-
|
|
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.
|
|
23
|
+
<% nav_items.each_with_index do |item, index| %>
|
|
15
24
|
<li>
|
|
16
25
|
<%
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
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
|
-
|
|
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? %>
|
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
|
data/lib/panda/core/engine.rb
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
require "rubygems"
|
|
2
|
+
require "stringio"
|
|
2
3
|
|
|
3
4
|
require "rails/engine"
|
|
4
5
|
require "omniauth"
|
|
5
|
-
|
|
6
|
-
|
|
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
|