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.
Files changed (42) 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/components/panda/core/UI/button.rb +45 -24
  5. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  6. data/app/components/panda/core/admin/button_component.rb +1 -1
  7. data/app/components/panda/core/admin/container_component.rb +27 -4
  8. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  9. data/app/components/panda/core/admin/flash_message_component.rb +54 -36
  10. data/app/components/panda/core/admin/heading_component.rb +8 -7
  11. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  12. data/app/components/panda/core/admin/panel_component.rb +1 -1
  13. data/app/components/panda/core/admin/slideover_component.rb +62 -4
  14. data/app/components/panda/core/admin/table_component.rb +11 -11
  15. data/app/components/panda/core/admin/tag_component.rb +39 -2
  16. data/app/components/panda/core/admin/user_display_component.rb +4 -5
  17. data/app/controllers/panda/core/admin/my_profile_controller.rb +2 -2
  18. data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
  19. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  20. data/app/helpers/panda/core/asset_helper.rb +31 -5
  21. data/app/helpers/panda/core/sessions_helper.rb +26 -1
  22. data/app/javascript/panda/core/application.js +8 -1
  23. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  24. data/app/javascript/panda/core/controllers/index.js +3 -3
  25. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  26. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  27. data/app/models/panda/core/user.rb +49 -6
  28. data/app/services/panda/core/attach_avatar_service.rb +67 -0
  29. data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
  30. data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
  31. data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
  32. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +3 -3
  33. data/app/views/panda/core/admin/shared/_sidebar.html.erb +17 -12
  34. data/config/importmap.rb +15 -7
  35. data/config/routes.rb +9 -0
  36. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  37. data/lib/panda/core/engine.rb +12 -3
  38. data/lib/panda/core/services/base_service.rb +19 -4
  39. data/lib/panda/core/version.rb +1 -1
  40. data/lib/panda/core.rb +1 -0
  41. data/lib/tasks/panda_core_users.rake +158 -0
  42. 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 :admin, -> { where(is_admin: true) }
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
- create!(
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
@@ -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>
@@ -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>
@@ -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 fa-regular fa-chevron-right font-light <%= breadcrumb_styles %> py-5 px-2"></i>
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" data-controller="toggle">
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
- <% 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
+ # 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
- # 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
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 !current_user.image_url.to_s.empty? %>
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-regular fa-circle-user fa-fw"></i></span>
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
- pin "panda/core/application", to: "panda/core/application.js"
5
- pin "panda/core/controllers/index", to: "panda/core/controllers/index.js"
6
- pin_all_from Panda::Core::Engine.root.join("app/javascript/panda/core/controllers"), under: "panda/core/controllers"
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddOauthAvatarUrlToPandaCoreUsers < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :panda_core_users, :oauth_avatar_url, :string
6
+ end
7
+ end
@@ -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.app_middleware.use(
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
- def self.call(*args)
6
- new(*args).call
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
- OpenStruct.new(success?: true, payload: payload)
27
+ Result.new(success: true, payload: payload)
13
28
  end
14
29
 
15
30
  def failure(errors)
16
- OpenStruct.new(success?: false, errors: errors)
31
+ Result.new(success: false, errors: errors)
17
32
  end
18
33
  end
19
34
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Panda
4
4
  module Core
5
- VERSION = "0.4.1"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
data/lib/panda/core.rb CHANGED
@@ -14,4 +14,5 @@ require_relative "core/version"
14
14
  require_relative "core/configuration"
15
15
  require_relative "core/asset_loader"
16
16
  require_relative "core/debug"
17
+ require_relative "core/services/base_service"
17
18
  require_relative "core/engine" if defined?(Rails)