panda-core 0.4.1 → 0.6.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/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 +1 -1
- data/app/components/panda/core/admin/container_component.rb +27 -4
- 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 +8 -7
- 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 +62 -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 +2 -2
- 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 +31 -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/index.js +3 -3
- 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 +49 -6
- data/app/services/panda/core/attach_avatar_service.rb +67 -0
- data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
- data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
- data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +3 -3
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +17 -12
- data/config/importmap.rb +15 -7
- data/config/routes.rb +9 -0
- data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
- data/lib/panda/core/engine.rb +12 -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 +11 -1
|
@@ -10,11 +10,36 @@ module Panda
|
|
|
10
10
|
github: "github"
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
|
+
# Map of providers that don't use fa-brands (use fa-solid instead)
|
|
14
|
+
PROVIDER_NON_BRAND_ICONS = {
|
|
15
|
+
developer: "code"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Map OAuth provider names to their display names
|
|
19
|
+
PROVIDER_NAME_MAP = {
|
|
20
|
+
google_oauth2: "Google",
|
|
21
|
+
microsoft_graph: "Microsoft",
|
|
22
|
+
github: "GitHub",
|
|
23
|
+
developer: "Developer"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
13
26
|
# Returns the FontAwesome icon name for a given provider
|
|
14
27
|
# Checks provider config first, then falls back to the mapping, then uses the provider name as-is
|
|
15
28
|
def oauth_provider_icon(provider)
|
|
16
29
|
provider_config = Panda::Core.config.authentication_providers[provider]
|
|
17
|
-
provider_config&.dig(:icon) || PROVIDER_ICON_MAP[provider.to_sym] || provider.to_s
|
|
30
|
+
provider_config&.dig(:icon) || PROVIDER_ICON_MAP[provider.to_sym] || PROVIDER_NON_BRAND_ICONS[provider.to_sym] || provider.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns true if the provider uses a non-brand icon (fa-solid, fa-regular, etc.)
|
|
34
|
+
def oauth_provider_non_brand?(provider)
|
|
35
|
+
PROVIDER_NON_BRAND_ICONS.key?(provider.to_sym)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the display name for a given provider
|
|
39
|
+
# Checks provider config first, then falls back to the mapping, then humanizes the provider name
|
|
40
|
+
def oauth_provider_name(provider, provider_config = nil)
|
|
41
|
+
provider_config ||= Panda::Core.config.authentication_providers[provider]
|
|
42
|
+
provider_config&.dig(:name) || PROVIDER_NAME_MAP[provider.to_sym] || provider.to_s.humanize
|
|
18
43
|
end
|
|
19
44
|
end
|
|
20
45
|
end
|
|
@@ -7,4 +7,11 @@ const application = Application.start()
|
|
|
7
7
|
application.debug = false
|
|
8
8
|
window.Stimulus = application
|
|
9
9
|
|
|
10
|
-
export { application }
|
|
10
|
+
export { application }
|
|
11
|
+
|
|
12
|
+
// Note: controllers/index.js must be loaded separately in the HTML to avoid circular dependency
|
|
13
|
+
// It will import this application and register all controllers
|
|
14
|
+
|
|
15
|
+
// Tailwind Plus Elements can be loaded by importing "panda/core/tailwindplus-elements"
|
|
16
|
+
// or by adding the script tag directly to your HTML:
|
|
17
|
+
// <script src="https://cdn.jsdelivr.net/npm/@tailwindplus/elements@1" type="module"></script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = {
|
|
5
|
+
dismissAfter: Number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
// Auto-dismiss if dismissAfter value is set
|
|
10
|
+
if (this.hasDismissAfterValue && this.dismissAfterValue > 0) {
|
|
11
|
+
this.timeout = setTimeout(() => {
|
|
12
|
+
this.close()
|
|
13
|
+
}, this.dismissAfterValue)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
// Clean up timeout if controller is disconnected
|
|
19
|
+
if (this.timeout) {
|
|
20
|
+
clearTimeout(this.timeout)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
close() {
|
|
25
|
+
// Clear any pending timeout
|
|
26
|
+
if (this.timeout) {
|
|
27
|
+
clearTimeout(this.timeout)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Remove the element with a fade-out animation
|
|
31
|
+
this.element.style.transition = "opacity 0.3s ease-out"
|
|
32
|
+
this.element.style.opacity = "0"
|
|
33
|
+
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
this.element.remove()
|
|
36
|
+
}, 300)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
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
7
|
// Import and register TailwindCSS Stimulus Components
|
|
8
8
|
// 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"
|
|
9
|
+
import { Alert, Autosave, ColorPreview, Dropdown, Modal, Tabs, Popover, Toggle, Slideover } from "../tailwindcss-stimulus-components.js"
|
|
10
10
|
application.register('alert', Alert)
|
|
11
11
|
application.register('autosave', Autosave)
|
|
12
12
|
application.register('color-preview', ColorPreview)
|
|
@@ -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,59 @@ 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 :
|
|
16
|
+
scope :admins, -> { where(is_admin: true) }
|
|
14
17
|
|
|
15
18
|
def self.find_or_create_from_auth_hash(auth_hash)
|
|
16
19
|
user = find_by(email: auth_hash.info.email.downcase)
|
|
17
|
-
return user if user
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
# Handle avatar for both new and existing users
|
|
22
|
+
avatar_url = auth_hash.info.image
|
|
23
|
+
if user
|
|
24
|
+
# Update avatar if URL has changed or no avatar is attached
|
|
25
|
+
if avatar_url.present? && (avatar_url != user.oauth_avatar_url || !user.avatar.attached?)
|
|
26
|
+
AttachAvatarService.call(user: user, avatar_url: avatar_url)
|
|
27
|
+
end
|
|
28
|
+
return user
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Support both schema versions: 'name' column or 'firstname'/'lastname' columns
|
|
32
|
+
attributes = {
|
|
20
33
|
email: auth_hash.info.email.downcase,
|
|
21
|
-
name: auth_hash.info.name || "Unknown User",
|
|
22
34
|
image_url: auth_hash.info.image,
|
|
23
35
|
is_admin: User.count.zero? # First user is admin
|
|
24
|
-
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Add name attributes based on schema
|
|
39
|
+
if column_names.include?("name")
|
|
40
|
+
attributes[:name] = auth_hash.info.name || "Unknown User"
|
|
41
|
+
elsif column_names.include?("firstname") && column_names.include?("lastname")
|
|
42
|
+
# Split name into firstname/lastname if provided
|
|
43
|
+
full_name = auth_hash.info.name || "Unknown User"
|
|
44
|
+
name_parts = full_name.split(" ", 2)
|
|
45
|
+
attributes[:firstname] = name_parts[0] || "Unknown"
|
|
46
|
+
attributes[:lastname] = name_parts[1] || "User"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
user = create!(attributes)
|
|
50
|
+
|
|
51
|
+
# Attach avatar for new user
|
|
52
|
+
if avatar_url.present?
|
|
53
|
+
AttachAvatarService.call(user: user, avatar_url: avatar_url)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
user
|
|
25
57
|
end
|
|
26
58
|
|
|
27
59
|
def admin?
|
|
28
|
-
is_admin
|
|
60
|
+
is_admin
|
|
29
61
|
end
|
|
30
62
|
|
|
31
63
|
def active_for_authentication?
|
|
@@ -45,6 +77,17 @@ module Panda
|
|
|
45
77
|
end
|
|
46
78
|
end
|
|
47
79
|
|
|
80
|
+
# Returns the URL for the user's avatar
|
|
81
|
+
# Prefers Active Storage attachment over OAuth provider URL
|
|
82
|
+
def avatar_url
|
|
83
|
+
if avatar.attached?
|
|
84
|
+
Rails.application.routes.url_helpers.rails_blob_path(avatar, only_path: true)
|
|
85
|
+
elsif self[:image_url].present?
|
|
86
|
+
# Fallback to OAuth provider URL if no avatar is attached yet
|
|
87
|
+
self[:image_url]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
48
91
|
private
|
|
49
92
|
|
|
50
93
|
def downcase_email
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
URI.open(@avatar_url, read_timeout: 10, open_timeout: 10, redirect: true) do |downloaded_file|
|
|
32
|
+
# Validate file size (max 5MB)
|
|
33
|
+
if downloaded_file.size > 5.megabytes
|
|
34
|
+
raise "Avatar file too large (#{downloaded_file.size} bytes)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Determine content type and filename
|
|
38
|
+
content_type = downloaded_file.content_type || "image/jpeg"
|
|
39
|
+
extension = determine_extension(content_type)
|
|
40
|
+
filename = "avatar_#{@user.id}_#{Time.current.to_i}#{extension}"
|
|
41
|
+
|
|
42
|
+
# Attach the avatar
|
|
43
|
+
@user.avatar.attach(
|
|
44
|
+
io: downloaded_file,
|
|
45
|
+
filename: filename,
|
|
46
|
+
content_type: content_type
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def determine_extension(content_type)
|
|
52
|
+
case content_type
|
|
53
|
+
when /jpeg|jpg/
|
|
54
|
+
".jpg"
|
|
55
|
+
when /png/
|
|
56
|
+
".png"
|
|
57
|
+
when /gif/
|
|
58
|
+
".gif"
|
|
59
|
+
when /webp/
|
|
60
|
+
".webp"
|
|
61
|
+
else
|
|
62
|
+
".jpg"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -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>
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
<ol role="list" class="px-4 w-full sm:px-6">
|
|
6
6
|
<li class="inline-block">
|
|
7
7
|
<a href="<%= Panda::Core.config.admin_path %>" class="<%= breadcrumb_styles %>">
|
|
8
|
-
<i class="fa fa-solid fa-house"></i>
|
|
8
|
+
<i class="fa fa-solid fa-house fa-fw inline-block w-4 text-center"></i>
|
|
9
9
|
<span class="sr-only">Home</span>
|
|
10
10
|
</a>
|
|
11
11
|
</li>
|
|
12
12
|
<% breadcrumbs.each do |crumb| %>
|
|
13
13
|
<li class="inline-block">
|
|
14
|
-
<i class="fa
|
|
14
|
+
<i class="fa-solid fa-chevron-right fa-fw inline-block w-4 text-center font-light <%= breadcrumb_styles %> px-2"></i>
|
|
15
15
|
<a href="<%= crumb.path %>" class="text-sm font-normal <%= breadcrumb_styles %>"><%= crumb.name %></a>
|
|
16
16
|
</li>
|
|
17
17
|
<% end %>
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
</nav>
|
|
20
20
|
|
|
21
21
|
<% if content_for :sidebar %>
|
|
22
|
-
<div class="pt-4 pr-8 text-black/80" tabindex="-1"
|
|
22
|
+
<div class="pt-4 pr-8 text-black/80" tabindex="-1">
|
|
23
23
|
<button type="button" id="slideover-toggle" data-action="click->toggle#toggle touch->toggle#toggle" class="text-sm font-light">
|
|
24
24
|
<i class="mr-1 fa-light fa-gear"></i> <%= yield :sidebar_title %>
|
|
25
25
|
</button>
|
|
@@ -1,18 +1,21 @@
|
|
|
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
|
+
# 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)
|
|
13
|
+
%>
|
|
14
|
+
<% nav_items.each do |item| %>
|
|
5
15
|
<li>
|
|
6
16
|
<%
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
is_active = if request.path == item[:path]
|
|
10
|
-
true
|
|
11
|
-
elsif request.path.starts_with?(item[:path] + "/")
|
|
12
|
-
true
|
|
13
|
-
else
|
|
14
|
-
false
|
|
15
|
-
end
|
|
17
|
+
# Check if this item is the active one
|
|
18
|
+
is_active = item[:path] == active_path
|
|
16
19
|
%>
|
|
17
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 %>
|
|
18
21
|
<span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
|
|
@@ -28,10 +31,12 @@
|
|
|
28
31
|
</li>
|
|
29
32
|
<li class="mt-auto">
|
|
30
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 %>
|
|
31
|
-
<% if
|
|
34
|
+
<% if current_user.avatar.attached? %>
|
|
35
|
+
<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
|
+
<% elsif !current_user.image_url.to_s.empty? %>
|
|
32
37
|
<span class="text-center w-6"><img src="<%= current_user.image_url %>" class="w-auto h-7 rounded-full"></span>
|
|
33
38
|
<% else %>
|
|
34
|
-
<span class="text-center w-6"><i class="text-xl fa-
|
|
39
|
+
<span class="text-center w-6"><i class="text-xl fa-solid fa-circle-user fa-fw"></i></span>
|
|
35
40
|
<% end %>
|
|
36
41
|
<span><%= current_user.name %></span>
|
|
37
42
|
<% end %>
|
data/config/importmap.rb
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Panda Core application and controllers
|
|
4
|
-
|
|
5
|
-
pin "panda/core/
|
|
6
|
-
|
|
4
|
+
# Served via Rack::Static middleware from app/javascript
|
|
5
|
+
pin "panda/core/application", to: "/panda/core/application.js"
|
|
6
|
+
pin "panda/core/controllers/index", to: "/panda/core/controllers/index.js"
|
|
7
|
+
pin "panda/core/controllers/toggle_controller", to: "/panda/core/controllers/toggle_controller.js"
|
|
8
|
+
pin "panda/core/controllers/theme_form_controller", to: "/panda/core/controllers/theme_form_controller.js"
|
|
9
|
+
pin "panda/core/tailwindplus-elements", to: "/panda/core/tailwindplus-elements.js"
|
|
7
10
|
|
|
8
11
|
# Base JavaScript dependencies for Panda Core (vendored for reliability)
|
|
9
|
-
pin "@hotwired/stimulus", to: "panda/core/vendor/@hotwired--stimulus.js", preload: true # @3.2.2
|
|
10
|
-
pin "@hotwired/turbo", to: "panda/core/vendor/@hotwired--turbo.js", preload: true # @8.0.18
|
|
11
|
-
pin "@rails/actioncable/src", to: "panda/core/vendor/@rails--actioncable--src.js", preload: true # @8.0.201
|
|
12
|
-
pin "tailwindcss-stimulus-components", to: "panda/core/tailwindcss-stimulus-components.js" # @6.1.3
|
|
12
|
+
pin "@hotwired/stimulus", to: "/panda/core/vendor/@hotwired--stimulus.js", preload: true # @3.2.2
|
|
13
|
+
pin "@hotwired/turbo", to: "/panda/core/vendor/@hotwired--turbo.js", preload: true # @8.0.18
|
|
14
|
+
pin "@rails/actioncable/src", to: "/panda/core/vendor/@rails--actioncable--src.js", preload: true # @8.0.201
|
|
15
|
+
pin "tailwindcss-stimulus-components", to: "/panda/core/tailwindcss-stimulus-components.js" # @6.1.3
|
|
13
16
|
|
|
14
17
|
# Font Awesome icons (from CDN)
|
|
15
18
|
pin "@fortawesome/fontawesome-free", to: "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@7.1.0/js/all.js"
|
|
19
|
+
|
|
20
|
+
# Tailwind Plus Elements - Vanilla JS interactive components (from CDN)
|
|
21
|
+
# Provides: Autocomplete, Command palette, Dialog, Disclosure, Dropdown menu, Popover, Select, Tabs
|
|
22
|
+
# Note: Using esm.sh instead of jsdelivr for better ES module support
|
|
23
|
+
pin "@tailwindplus/elements", to: "https://esm.sh/@tailwindplus/elements@1", preload: false
|
data/config/routes.rb
CHANGED
|
@@ -18,5 +18,14 @@ Panda::Core::Engine.routes.draw do
|
|
|
18
18
|
|
|
19
19
|
# Profile management
|
|
20
20
|
resource :my_profile, only: %i[edit update], controller: "admin/my_profile", path: "my_profile"
|
|
21
|
+
|
|
22
|
+
# Test-only authentication endpoint (available in development and test environments)
|
|
23
|
+
# This bypasses OAuth for faster, more reliable test execution
|
|
24
|
+
# Development: Used by Capybara system tests which run Rails server in development mode
|
|
25
|
+
# Test: Used by controller/request tests
|
|
26
|
+
unless Rails.env.production?
|
|
27
|
+
get "/test_login/:user_id", to: "admin/test_sessions#create", as: :test_login
|
|
28
|
+
post "/test_sessions", to: "admin/test_sessions#create", as: :test_sessions
|
|
29
|
+
end
|
|
21
30
|
end
|
|
22
31
|
end
|
data/lib/panda/core/engine.rb
CHANGED
|
@@ -19,17 +19,26 @@ module Panda
|
|
|
19
19
|
config.autoload_paths += Dir[root.join("app", "controllers")]
|
|
20
20
|
config.autoload_paths += Dir[root.join("app", "builders")]
|
|
21
21
|
config.autoload_paths += Dir[root.join("app", "components")]
|
|
22
|
+
config.autoload_paths += Dir[root.join("app", "services")]
|
|
22
23
|
|
|
23
24
|
# Make files in public available to the main app (e.g. /panda-core-assets/panda-logo.png)
|
|
24
|
-
config.
|
|
25
|
-
Rack::Static,
|
|
25
|
+
config.middleware.use Rack::Static,
|
|
26
26
|
urls: ["/panda-core-assets"],
|
|
27
27
|
root: Panda::Core::Engine.root.join("public"),
|
|
28
28
|
header_rules: [
|
|
29
29
|
# Disable caching in development for instant CSS updates
|
|
30
30
|
[:all, {"Cache-Control" => Rails.env.development? ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000"}]
|
|
31
31
|
]
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
# Make JavaScript files available for importmap
|
|
34
|
+
# Serve from app/javascript with proper MIME types
|
|
35
|
+
config.middleware.use Rack::Static,
|
|
36
|
+
urls: ["/panda", "/panda/core"],
|
|
37
|
+
root: Panda::Core::Engine.root.join("app/javascript"),
|
|
38
|
+
header_rules: [
|
|
39
|
+
[:all, {"Cache-Control" => Rails.env.development? ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000",
|
|
40
|
+
"Content-Type" => "text/javascript; charset=utf-8"}]
|
|
41
|
+
]
|
|
33
42
|
|
|
34
43
|
config.generators do |g|
|
|
35
44
|
g.test_framework :rspec
|
|
@@ -2,18 +2,33 @@ module Panda
|
|
|
2
2
|
module Core
|
|
3
3
|
module Services
|
|
4
4
|
class BaseService
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# Simple result object for service responses
|
|
6
|
+
class Result
|
|
7
|
+
attr_reader :payload, :errors
|
|
8
|
+
|
|
9
|
+
def initialize(success:, payload: {}, errors: nil)
|
|
10
|
+
@success = success
|
|
11
|
+
@payload = payload
|
|
12
|
+
@errors = errors
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def success?
|
|
16
|
+
@success
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.call(**kwargs)
|
|
21
|
+
new(**kwargs).call
|
|
7
22
|
end
|
|
8
23
|
|
|
9
24
|
private
|
|
10
25
|
|
|
11
26
|
def success(payload = {})
|
|
12
|
-
|
|
27
|
+
Result.new(success: true, payload: payload)
|
|
13
28
|
end
|
|
14
29
|
|
|
15
30
|
def failure(errors)
|
|
16
|
-
|
|
31
|
+
Result.new(success: false, errors: errors)
|
|
17
32
|
end
|
|
18
33
|
end
|
|
19
34
|
end
|
data/lib/panda/core/version.rb
CHANGED
data/lib/panda/core.rb
CHANGED