panda-core 0.1.16 → 0.2.1
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/README.md +9 -16
- data/Rakefile +3 -0
- data/app/builders/panda/core/form_builder.rb +225 -0
- data/app/components/panda/core/admin/button_component.rb +70 -0
- data/app/components/panda/core/admin/container_component.html.erb +12 -0
- data/app/components/panda/core/admin/container_component.rb +13 -0
- data/app/components/panda/core/admin/flash_message_component.html.erb +31 -0
- data/app/components/panda/core/admin/flash_message_component.rb +47 -0
- data/app/components/panda/core/admin/heading_component.rb +46 -0
- data/app/components/panda/core/admin/panel_component.html.erb +7 -0
- data/app/components/panda/core/admin/panel_component.rb +13 -0
- data/app/components/panda/core/admin/slideover_component.html.erb +9 -0
- data/app/components/panda/core/admin/slideover_component.rb +15 -0
- data/app/components/panda/core/admin/table_component.html.erb +29 -0
- data/app/components/panda/core/admin/table_component.rb +46 -0
- data/app/components/panda/core/admin/tag_component.rb +35 -0
- data/app/constraints/panda/core/admin_constraint.rb +14 -0
- data/app/controllers/panda/core/admin/dashboard_controller.rb +22 -0
- data/app/controllers/panda/core/admin/my_profile_controller.rb +49 -0
- data/app/controllers/panda/core/admin/sessions_controller.rb +69 -0
- data/app/controllers/panda/core/admin_controller.rb +28 -0
- data/app/controllers/panda/core/application_controller.rb +59 -0
- data/app/helpers/panda/core/asset_helper.rb +32 -0
- data/app/javascript/panda/core/application.js +9 -0
- data/app/javascript/panda/core/controllers/index.js +20 -0
- data/app/javascript/panda/core/controllers/theme_form_controller.js +25 -0
- data/app/javascript/panda/core/tailwindcss-stimulus-components.js +3 -0
- data/app/models/panda/core/application_record.rb +9 -0
- data/app/models/panda/core/breadcrumb.rb +17 -0
- data/app/models/panda/core/current.rb +16 -0
- data/app/models/panda/core/user.rb +51 -0
- data/app/views/layouts/panda/core/admin.html.erb +59 -0
- data/app/views/panda/core/admin/dashboard/show.html.erb +27 -0
- data/app/views/panda/core/admin/my_profile/edit.html.erb +49 -0
- data/app/views/panda/core/admin/sessions/new.html.erb +38 -0
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +35 -0
- data/app/views/panda/core/admin/shared/_flash.html.erb +31 -0
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +27 -0
- data/app/views/panda/core/admin/shared/_slideover.html.erb +33 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20241210000003_add_current_theme_to_panda_core_users.rb +7 -0
- data/db/migrate/20250809000001_create_panda_core_users.rb +16 -0
- data/lib/generators/panda/core/dev_tools/USAGE +24 -0
- data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +13 -0
- data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +18 -0
- data/lib/generators/panda/core/dev_tools_generator.rb +143 -0
- data/lib/panda/core/asset_loader.rb +221 -0
- data/lib/panda/core/authentication.rb +36 -0
- data/lib/panda/core/component_registry.rb +37 -0
- data/lib/panda/core/configuration.rb +31 -1
- data/lib/panda/core/engine.rb +43 -7
- data/lib/panda/core/notifications.rb +40 -0
- data/lib/panda/core/rake_tasks.rb +16 -0
- data/lib/panda/core/subscribers/authentication_subscriber.rb +61 -0
- data/lib/panda/core/testing/capybara_config.rb +70 -0
- data/lib/panda/core/testing/omniauth_helpers.rb +52 -0
- data/lib/panda/core/testing/rspec_config.rb +72 -0
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core.rb +2 -8
- data/lib/tasks/assets.rake +423 -0
- data/lib/tasks/panda/core/migrations.rake +13 -0
- data/lib/tasks/panda_core.rake +52 -0
- metadata +320 -11
- data/db/migrate/20250121012333_logidze_install.rb +0 -577
- data/db/migrate/20250121012334_enable_hstore.rb +0 -5
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
class AdminConstraint
|
6
|
+
def matches?(request)
|
7
|
+
return false unless request.session[:user_id].present?
|
8
|
+
|
9
|
+
user = User.find_by(id: request.session[:user_id])
|
10
|
+
user&.admin?
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
module Admin
|
6
|
+
class DashboardController < AdminController
|
7
|
+
# Authentication is automatically enforced by AdminController
|
8
|
+
|
9
|
+
def show
|
10
|
+
# If a custom dashboard path is configured, redirect there
|
11
|
+
if Panda::Core.configuration.dashboard_redirect_path
|
12
|
+
redirect_to Panda::Core.configuration.dashboard_redirect_path
|
13
|
+
else
|
14
|
+
# This can be overridden by applications using panda-core
|
15
|
+
# For now, just render a basic view
|
16
|
+
render plain: "Welcome to Panda Admin"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
module Admin
|
6
|
+
class MyProfileController < ::Panda::Core::AdminController
|
7
|
+
before_action :set_initial_breadcrumb, only: %i[edit update]
|
8
|
+
|
9
|
+
# Shows the edit form for the current user's profile
|
10
|
+
# @type GET
|
11
|
+
# @return void
|
12
|
+
def edit
|
13
|
+
render :edit, locals: {user: current_user}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Updates the current user's profile
|
17
|
+
# @type PATCH/PUT
|
18
|
+
# @return void
|
19
|
+
def update
|
20
|
+
if current_user.update(user_params)
|
21
|
+
flash[:success] = "Your profile has been updated successfully."
|
22
|
+
redirect_to edit_admin_my_profile_path
|
23
|
+
else
|
24
|
+
render :edit, locals: {user: current_user}, status: :unprocessable_entity
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def set_initial_breadcrumb
|
31
|
+
add_breadcrumb "My Profile", edit_admin_my_profile_path
|
32
|
+
end
|
33
|
+
|
34
|
+
# Only allow a list of trusted parameters through
|
35
|
+
# @type private
|
36
|
+
# @return ActionController::StrongParameters
|
37
|
+
def user_params
|
38
|
+
# Base parameters that Core always allows
|
39
|
+
base_params = [:firstname, :lastname, :email, :current_theme]
|
40
|
+
|
41
|
+
# Allow additional params from configuration
|
42
|
+
additional_params = Core.configuration.additional_user_params || []
|
43
|
+
|
44
|
+
params.require(:user).permit(*(base_params + additional_params))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
module Admin
|
6
|
+
class SessionsController < AdminController
|
7
|
+
# Skip authentication for login/logout actions
|
8
|
+
skip_before_action :authenticate_admin_user!, only: [:new, :create, :destroy, :failure]
|
9
|
+
|
10
|
+
def new
|
11
|
+
@providers = Core.configuration.authentication_providers.keys
|
12
|
+
end
|
13
|
+
|
14
|
+
def create
|
15
|
+
auth = request.env["omniauth.auth"]
|
16
|
+
provider = params[:provider]&.to_sym
|
17
|
+
|
18
|
+
unless Core.configuration.authentication_providers.key?(provider)
|
19
|
+
redirect_to admin_login_path, flash: {error: "Authentication provider not enabled"}
|
20
|
+
return
|
21
|
+
end
|
22
|
+
|
23
|
+
user = User.find_or_create_from_auth_hash(auth)
|
24
|
+
|
25
|
+
if user.persisted?
|
26
|
+
# Check if user is admin before allowing access
|
27
|
+
unless user.admin?
|
28
|
+
redirect_to admin_login_path, flash: {error: "You do not have permission to access the admin area"}
|
29
|
+
return
|
30
|
+
end
|
31
|
+
|
32
|
+
session[:user_id] = user.id
|
33
|
+
Panda::Core::Current.user = user
|
34
|
+
|
35
|
+
ActiveSupport::Notifications.instrument("panda.core.user_login",
|
36
|
+
user: user,
|
37
|
+
provider: provider)
|
38
|
+
|
39
|
+
# Use configured dashboard path or default to admin_root_path
|
40
|
+
redirect_path = Panda::Core.configuration.dashboard_redirect_path || admin_root_path
|
41
|
+
redirect_to redirect_path, flash: {success: "Successfully logged in as #{user.name}"}
|
42
|
+
else
|
43
|
+
redirect_to admin_login_path, flash: {error: "Unable to create account: #{user.errors.full_messages.join(", ")}"}
|
44
|
+
end
|
45
|
+
rescue => e
|
46
|
+
Rails.logger.error "Authentication error: #{e.message}"
|
47
|
+
redirect_to admin_login_path, flash: {error: "Authentication failed: #{e.message}"}
|
48
|
+
end
|
49
|
+
|
50
|
+
def failure
|
51
|
+
message = params[:message] || "Authentication failed"
|
52
|
+
strategy = params[:strategy] || "unknown"
|
53
|
+
|
54
|
+
Rails.logger.error "OmniAuth failure: strategy=#{strategy}, message=#{message}"
|
55
|
+
redirect_to admin_login_path, flash: {error: "Authentication failed: #{message}"}
|
56
|
+
end
|
57
|
+
|
58
|
+
def destroy
|
59
|
+
session.delete(:user_id)
|
60
|
+
Panda::Core::Current.user = nil
|
61
|
+
|
62
|
+
ActiveSupport::Notifications.instrument("panda.core.user_logout")
|
63
|
+
|
64
|
+
redirect_to admin_login_path, flash: {success: "Successfully logged out"}
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
class AdminController < ApplicationController
|
6
|
+
# Automatically require admin authentication for all admin controllers
|
7
|
+
before_action :authenticate_admin_user!
|
8
|
+
before_action :set_initial_breadcrumb
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def set_initial_breadcrumb
|
13
|
+
# Use configured breadcrumb or default
|
14
|
+
if Core.configuration.initial_admin_breadcrumb
|
15
|
+
label, path = Core.configuration.initial_admin_breadcrumb.call(self)
|
16
|
+
add_breadcrumb label, path
|
17
|
+
else
|
18
|
+
add_breadcrumb "Admin", admin_root_path
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Legacy method for compatibility
|
23
|
+
def set_admin_breadcrumb
|
24
|
+
set_initial_breadcrumb
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
class ApplicationController < ActionController::Base
|
6
|
+
protect_from_forgery with: :exception
|
7
|
+
|
8
|
+
before_action :set_current_request_details
|
9
|
+
before_action :initialize_breadcrumbs
|
10
|
+
|
11
|
+
helper_method :current_user, :user_signed_in?, :breadcrumbs
|
12
|
+
|
13
|
+
add_flash_types :success, :error, :warning, :info
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def set_current_request_details
|
18
|
+
Current.request_id = request.uuid
|
19
|
+
Current.user_agent = request.user_agent
|
20
|
+
Current.ip_address = request.ip
|
21
|
+
Current.root = "#{request.protocol}#{request.host_with_port}"
|
22
|
+
Current.user = User.find_by(id: session[:user_id]) if session[:user_id]
|
23
|
+
end
|
24
|
+
|
25
|
+
def authenticate_user!
|
26
|
+
redirect_to admin_login_path unless user_signed_in?
|
27
|
+
end
|
28
|
+
|
29
|
+
def authenticate_admin_user!
|
30
|
+
if !user_signed_in?
|
31
|
+
redirect_to admin_login_path
|
32
|
+
elsif !current_user.admin?
|
33
|
+
redirect_to admin_login_path, flash: {error: "You are not authorized to access this page."}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def current_user
|
38
|
+
Current.user
|
39
|
+
end
|
40
|
+
|
41
|
+
def user_signed_in?
|
42
|
+
current_user.present?
|
43
|
+
end
|
44
|
+
|
45
|
+
def initialize_breadcrumbs
|
46
|
+
@breadcrumbs = []
|
47
|
+
end
|
48
|
+
|
49
|
+
def add_breadcrumb(label, path = nil)
|
50
|
+
@breadcrumbs ||= []
|
51
|
+
@breadcrumbs << Breadcrumb.new(label, path)
|
52
|
+
end
|
53
|
+
|
54
|
+
def breadcrumbs
|
55
|
+
@breadcrumbs || []
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
module AssetHelper
|
6
|
+
# Include Panda Core JavaScript and CSS assets
|
7
|
+
def panda_core_assets
|
8
|
+
Panda::Core::AssetLoader.asset_tags.html_safe
|
9
|
+
end
|
10
|
+
|
11
|
+
# Include only Core JavaScript
|
12
|
+
def panda_core_javascript
|
13
|
+
js_url = Panda::Core::AssetLoader.javascript_url
|
14
|
+
return "" unless js_url
|
15
|
+
|
16
|
+
if js_url.start_with?("/panda-core-assets/")
|
17
|
+
javascript_include_tag(js_url)
|
18
|
+
else
|
19
|
+
javascript_include_tag(js_url, type: "module")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Include only Core CSS
|
24
|
+
def panda_core_stylesheet
|
25
|
+
css_url = Panda::Core::AssetLoader.css_url
|
26
|
+
return "" unless css_url
|
27
|
+
|
28
|
+
stylesheet_link_tag(css_url)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
// Import and register Core Stimulus controllers
|
2
|
+
import { application } from "../application"
|
3
|
+
|
4
|
+
import ThemeFormController from "./theme_form_controller"
|
5
|
+
application.register("theme-form", ThemeFormController)
|
6
|
+
|
7
|
+
// Import and register TailwindCSS Stimulus Components
|
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"
|
10
|
+
application.register('alert', Alert)
|
11
|
+
application.register('autosave', Autosave)
|
12
|
+
application.register('color-preview', ColorPreview)
|
13
|
+
application.register('dropdown', Dropdown)
|
14
|
+
application.register('modal', Modal)
|
15
|
+
application.register('popover', Popover)
|
16
|
+
application.register('slideover', Slideover)
|
17
|
+
application.register('tabs', Tabs)
|
18
|
+
application.register('toggle', Toggle)
|
19
|
+
|
20
|
+
console.debug("[Panda Core] Registered TailwindCSS Stimulus components")
|
@@ -0,0 +1,25 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
// Connects to data-controller="theme-form"
|
4
|
+
export default class extends Controller {
|
5
|
+
connect() {
|
6
|
+
// Ensure submit button is enabled on connect
|
7
|
+
this.enableSubmitButton();
|
8
|
+
}
|
9
|
+
|
10
|
+
updateTheme(event) {
|
11
|
+
const newTheme = event.target.value;
|
12
|
+
document.documentElement.dataset.theme = newTheme;
|
13
|
+
}
|
14
|
+
|
15
|
+
enableSubmitButton() {
|
16
|
+
// Find the submit button in the form and ensure it's enabled
|
17
|
+
const form = this.element;
|
18
|
+
if (form) {
|
19
|
+
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
|
20
|
+
if (submitButton) {
|
21
|
+
submitButton.disabled = false;
|
22
|
+
}
|
23
|
+
}
|
24
|
+
}
|
25
|
+
}
|
@@ -0,0 +1,3 @@
|
|
1
|
+
// tailwindcss-stimulus-components@6.1.2 downloaded from https://ga.jspm.io/npm:tailwindcss-stimulus-components@6.1.2/dist/tailwindcss-stimulus-components.module.js
|
2
|
+
|
3
|
+
import{Controller as e}from"@hotwired/stimulus";var t=Object.defineProperty;var V=(e,s,a)=>s in e?t(e,s,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[s]=a;var i=(e,t,s)=>V(e,typeof t!="symbol"?t+"":t,s);async function n(e,t,s={}){t?await T(e,s):await b(e,s)}async function T(e,t={}){let{transitionClasses:s,fromClasses:a,toClasses:o,toggleClass:r}=C("Enter",e,t);return v(e,{firstFrame(){e.classList.add(...s.split(" ")),e.classList.add(...a.split(" ")),e.classList.remove(...o.split(" ")),e.classList.remove(...r.split(" "))},secondFrame(){e.classList.remove(...a.split(" ")),e.classList.add(...o.split(" "))},ending(){e.classList.remove(...s.split(" "))}})}async function b(e,t={}){let{transitionClasses:s,fromClasses:a,toClasses:o,toggleClass:r}=C("Leave",e,t);return v(e,{firstFrame(){e.classList.add(...a.split(" ")),e.classList.remove(...o.split(" ")),e.classList.add(...s.split(" "))},secondFrame(){e.classList.remove(...a.split(" ")),e.classList.add(...o.split(" "))},ending(){e.classList.remove(...s.split(" ")),e.classList.add(...r.split(" "))}})}function C(e,t,s){return{transitionClasses:t.dataset[`transition${e}`]||s[e.toLowerCase()]||e.toLowerCase(),fromClasses:t.dataset[`transition${e}From`]||s[`${e.toLowerCase()}From`]||`${e.toLowerCase()}-from`,toClasses:t.dataset[`transition${e}To`]||s[`${e.toLowerCase()}To`]||`${e.toLowerCase()}-to`,toggleClass:t.dataset.toggleClass||s.toggleClass||s.toggle||"hidden"}}function L(e){e._stimulus_transition={timeout:null,interrupted:!1}}function I(e){e._stimulus_transition&&e._stimulus_transition.interrupt&&e._stimulus_transition.interrupt()}function v(e,t){e._stimulus_transition&&I(e);let s,a,o;return L(e),e._stimulus_transition.cleanup=()=>{a||t.firstFrame(),o||t.secondFrame(),t.ending(),e._stimulus_transition=null},e._stimulus_transition.interrupt=()=>{s=!0,e._stimulus_transition.timeout&&clearTimeout(e._stimulus_transition.timeout),e._stimulus_transition.cleanup()},new Promise((r=>{s||requestAnimationFrame((()=>{s||(t.firstFrame(),a=!0,requestAnimationFrame((()=>{s||(t.secondFrame(),o=!0,e._stimulus_transition&&(e._stimulus_transition.timeout=setTimeout((()=>{s||e._stimulus_transition.cleanup(),r()}),w(e))))})))}))}))}function w(e){let t=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,s=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;return t===0&&(t=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),t+s}var s=class extends e{connect(){setTimeout((()=>{T(this.element)}),this.showDelayValue),this.hasDismissAfterValue&&setTimeout((()=>{this.close()}),this.dismissAfterValue)}close(){b(this.element).then((()=>{this.element.remove()}))}};i(s,"values",{dismissAfter:Number,showDelay:{type:Number,default:0}});var a=class extends e{connect(){this.timeout=null}save(){clearTimeout(this.timeout),this.timeout=setTimeout((()=>{this.statusTarget.textContent=this.submittingTextValue,this.formTarget.requestSubmit()}),this.submitDurationValue)}success(){this.setStatus(this.successTextValue)}error(){this.setStatus(this.errorTextValue)}setStatus(e){this.statusTarget.textContent=e,this.timeout=setTimeout((()=>{this.statusTarget.textContent=""}),this.statusDurationValue)}};i(a,"targets",["form","status"]),i(a,"values",{submitDuration:{type:Number,default:1e3},statusDuration:{type:Number,default:2e3},submittingText:{type:String,default:"Saving..."},successText:{type:String,default:"Saved!"},errorText:{type:String,default:"Unable to save."}});var o=class extends e{update(){this.preview=this.colorTarget.value}set preview(e){this.previewTarget.style[this.styleValue]=e;let t=this._getContrastYIQ(e);this.styleValue==="color"?this.previewTarget.style.backgroundColor=t:this.previewTarget.style.color=t}_getContrastYIQ(e){e=e.replace("#","");let t=128,s=parseInt(e.substr(0,2),16),a=parseInt(e.substr(2,2),16),o=parseInt(e.substr(4,2),16);return(s*299+a*587+o*114)/1e3>=t?"#000":"#fff"}};i(o,"targets",["preview","color"]),i(o,"values",{style:{type:String,default:"backgroundColor"}});var r=class extends e{connect(){this.boundBeforeCache=this.beforeCache.bind(this),document.addEventListener("turbo:before-cache",this.boundBeforeCache)}disconnect(){document.removeEventListener("turbo:before-cache",this.boundBeforeCache)}openValueChanged(){n(this.menuTarget,this.openValue,this.transitionOptions),this.openValue===!0&&this.hasMenuItemTarget&&this.menuItemTargets[0].focus()}show(){this.openValue=!0}close(){this.openValue=!1}hide(e){this.closeOnClickOutsideValue&&e.target.nodeType&&this.element.contains(e.target)===!1&&this.openValue&&(this.openValue=!1),this.closeOnEscapeValue&&e.key==="Escape"&&this.openValue&&(this.openValue=!1)}toggle(){this.openValue=!this.openValue}nextItem(e){e.preventDefault(),this.menuItemTargets[this.nextIndex].focus()}previousItem(e){e.preventDefault(),this.menuItemTargets[this.previousIndex].focus()}get currentItemIndex(){return this.menuItemTargets.indexOf(document.activeElement)}get nextIndex(){return Math.min(this.currentItemIndex+1,this.menuItemTargets.length-1)}get previousIndex(){return Math.max(this.currentItemIndex-1,0)}get transitionOptions(){return{enter:this.hasEnterClass?this.enterClass:"transition ease-out duration-100",enterFrom:this.hasEnterFromClass?this.enterFromClass:"transform opacity-0 scale-95",enterTo:this.hasEnterToClass?this.enterToClass:"transform opacity-100 scale-100",leave:this.hasLeaveClass?this.leaveClass:"transition ease-in duration-75",leaveFrom:this.hasLeaveFromClass?this.leaveFromClass:"transform opacity-100 scale-100",leaveTo:this.hasLeaveToClass?this.leaveToClass:"transform opacity-0 scale-95",toggleClass:this.hasToggleClass?this.toggleClass:"hidden"}}beforeCache(){this.openValue=!1,this.menuTarget.classList.add("hidden")}};i(r,"targets",["menu","button","menuItem"]),i(r,"values",{open:{type:Boolean,default:!1},closeOnEscape:{type:Boolean,default:!0},closeOnClickOutside:{type:Boolean,default:!0}}),i(r,"classes",["enter","enterFrom","enterTo","leave","leaveFrom","leaveTo","toggle"]);var l=class extends e{connect(){this.openValue&&this.open(),this.boundBeforeCache=this.beforeCache.bind(this),document.addEventListener("turbo:before-cache",this.boundBeforeCache)}disconnect(){document.removeEventListener("turbo:before-cache",this.boundBeforeCache)}open(){this.dialogTarget.showModal()}close(){this.dialogTarget.setAttribute("closing",""),Promise.all(this.dialogTarget.getAnimations().map((e=>e.finished))).then((()=>{this.dialogTarget.removeAttribute("closing"),this.dialogTarget.close()}))}backdropClose(e){e.target.nodeName=="DIALOG"&&this.close()}show(){this.dialogTarget.show()}hide(){this.close()}beforeCache(){this.close()}};i(l,"targets",["dialog"]),i(l,"values",{open:Boolean});var u=class extends e{openValueChanged(){n(this.contentTarget,this.openValue),this.shouldAutoDismiss&&this.scheduleDismissal()}show(e){this.shouldAutoDismiss&&this.scheduleDismissal(),this.openValue=!0}hide(){this.openValue=!1}toggle(){this.openValue=!this.openValue}get shouldAutoDismiss(){return this.openValue&&this.hasDismissAfterValue}scheduleDismissal(){this.hasDismissAfterValue&&(this.cancelDismissal(),this.timeoutId=setTimeout((()=>{this.hide(),this.timeoutId=void 0}),this.dismissAfterValue))}cancelDismissal(){typeof this.timeoutId=="number"&&(clearTimeout(this.timeoutId),this.timeoutId=void 0)}};i(u,"targets",["content"]),i(u,"values",{dismissAfter:Number,open:{type:Boolean,default:!1}});var h=class extends e{connect(){this.openValue&&this.open(),this.boundBeforeCache=this.beforeCache,document.addEventListener("turbo:before-cache",this.boundBeforeCache)}disconnect(){document.removeEventListener("turbo:before-cache",this.boundBeforeCache)}open(){this.dialogTarget.showModal()}close(){this.dialogTarget.setAttribute("closing",""),Promise.all(this.dialogTarget.getAnimations().map((e=>e.finished))).then((()=>{this.dialogTarget.removeAttribute("closing"),this.dialogTarget.close()}))}backdropClose(e){e.target.nodeName=="DIALOG"&&this.close()}show(){this.open()}hide(){this.close()}beforeCache(){this.close()}};i(h,"targets",["dialog"]),i(h,"values",{open:Boolean});var c=class extends e{initialize(){this.updateAnchorValue&&this.anchor&&(this.indexValue=this.tabTargets.findIndex((e=>e.id===this.anchor)))}connect(){this.showTab()}change(e){e.currentTarget.tagName==="SELECT"?this.indexValue=e.currentTarget.selectedIndex:e.currentTarget.dataset.index?this.indexValue=e.currentTarget.dataset.index:e.currentTarget.dataset.id?this.indexValue=this.tabTargets.findIndex((t=>t.id==e.currentTarget.dataset.id)):this.indexValue=this.tabTargets.indexOf(e.currentTarget)}nextTab(){this.indexValue=Math.min(this.indexValue+1,this.tabsCount-1)}previousTab(){this.indexValue=Math.max(this.indexValue-1,0)}firstTab(){this.indexValue=0}lastTab(){this.indexValue=this.tabsCount-1}indexValueChanged(){if(this.showTab(),this.dispatch("tab-change",{target:this.tabTargets[this.indexValue],detail:{activeIndex:this.indexValue}}),this.updateAnchorValue){let e=this.tabTargets[this.indexValue].id;if(this.scrollToAnchorValue)location.hash=e;else{let t=window.location.href.split("#")[0]+"#"+e;typeof Turbo<"u"?Turbo.navigator.history.replace(new URL(t)):history.replaceState({},document.title,t)}}}showTab(){this.panelTargets.forEach(((e,t)=>{let s=this.tabTargets[t];t===this.indexValue?(e.classList.remove("hidden"),s.ariaSelected="true",s.dataset.active=!0,this.hasInactiveTabClass&&s?.classList?.remove(...this.inactiveTabClasses),this.hasActiveTabClass&&s?.classList?.add(...this.activeTabClasses)):(e.classList.add("hidden"),s.ariaSelected=null,delete s.dataset.active,this.hasActiveTabClass&&s?.classList?.remove(...this.activeTabClasses),this.hasInactiveTabClass&&s?.classList?.add(...this.inactiveTabClasses))})),this.hasSelectTarget&&(this.selectTarget.selectedIndex=this.indexValue),this.scrollActiveTabIntoViewValue&&this.scrollToActiveTab()}scrollToActiveTab(){let e=this.element.querySelector("[aria-selected]");e&&e.scrollIntoView({inline:"center"})}get tabsCount(){return this.tabTargets.length}get anchor(){return document.URL.split("#").length>1?document.URL.split("#")[1]:null}};i(c,"classes",["activeTab","inactiveTab"]),i(c,"targets",["tab","panel","select"]),i(c,"values",{index:0,updateAnchor:Boolean,scrollToAnchor:Boolean,scrollActiveTabIntoView:Boolean});var d=class extends e{toggle(e){this.openValue=!this.openValue,this.animate()}toggleInput(e){this.openValue=e.target.checked,this.animate()}hide(){this.openValue=!1,this.animate()}show(){this.openValue=!0,this.animate()}animate(){this.toggleableTargets.forEach((e=>{n(e,this.openValue)}))}};i(d,"targets",["toggleable"]),i(d,"values",{open:{type:Boolean,default:!1}});export{s as Alert,a as Autosave,o as ColorPreview,r as Dropdown,l as Modal,u as Popover,h as Slideover,c as Tabs,d as Toggle,n as transition};
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
class Breadcrumb
|
6
|
+
attr_reader :name, :path
|
7
|
+
|
8
|
+
def initialize(name, path)
|
9
|
+
@name = name
|
10
|
+
@path = path
|
11
|
+
end
|
12
|
+
|
13
|
+
# Alias for compatibility
|
14
|
+
alias_method :label, :name
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
class Current < ActiveSupport::CurrentAttributes
|
6
|
+
attribute :user, :request_id, :user_agent, :ip_address, :root, :page
|
7
|
+
|
8
|
+
resets { Time.zone = nil }
|
9
|
+
|
10
|
+
def user=(user)
|
11
|
+
super
|
12
|
+
Time.zone = user.try(:time_zone)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Panda
|
4
|
+
module Core
|
5
|
+
class User < ApplicationRecord
|
6
|
+
self.table_name = "panda_core_users"
|
7
|
+
|
8
|
+
validates :email, presence: true, uniqueness: {case_sensitive: false}
|
9
|
+
|
10
|
+
before_save :downcase_email
|
11
|
+
|
12
|
+
# Scopes
|
13
|
+
scope :admin, -> { where(admin: true) }
|
14
|
+
|
15
|
+
def self.find_or_create_from_auth_hash(auth_hash)
|
16
|
+
user = find_by(email: auth_hash.info.email.downcase)
|
17
|
+
return user if user
|
18
|
+
|
19
|
+
# Parse name into first and last
|
20
|
+
full_name = auth_hash.info.name || "Unknown User"
|
21
|
+
name_parts = full_name.split(" ", 2)
|
22
|
+
|
23
|
+
create!(
|
24
|
+
email: auth_hash.info.email.downcase,
|
25
|
+
firstname: name_parts[0] || "Unknown",
|
26
|
+
lastname: name_parts[1] || "",
|
27
|
+
image_url: auth_hash.info.image,
|
28
|
+
admin: User.count.zero? # First user is admin
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def admin?
|
33
|
+
admin == true
|
34
|
+
end
|
35
|
+
|
36
|
+
def name
|
37
|
+
"#{firstname} #{lastname}".strip
|
38
|
+
end
|
39
|
+
|
40
|
+
def active_for_authentication?
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def downcase_email
|
47
|
+
self.email = email.downcase if email.present?
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en" class="h-full bg-white">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8">
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<%= csrf_meta_tags %>
|
7
|
+
<%= csp_meta_tag %>
|
8
|
+
|
9
|
+
<title><%= content_for(:title) || "Panda Admin" %></title>
|
10
|
+
|
11
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
12
|
+
|
13
|
+
<% if Rails.env.test? || ENV["CI"].present? %>
|
14
|
+
<!-- Load compiled Panda Core assets for test environment -->
|
15
|
+
<link rel="stylesheet" href="/panda-core-assets/panda-core-<%= Panda::Core::VERSION %>.css">
|
16
|
+
<script src="/panda-core-assets/panda-core-<%= Panda::Core::VERSION %>.js" defer></script>
|
17
|
+
|
18
|
+
<% if defined?(Panda::CMS) %>
|
19
|
+
<!-- Also load Panda CMS assets if CMS is present -->
|
20
|
+
<link rel="stylesheet" href="/panda-cms-assets/panda-cms-<%= Panda::CMS::VERSION %>.css">
|
21
|
+
<script src="/panda-cms-assets/panda-cms-<%= Panda::CMS::VERSION %>.js" defer></script>
|
22
|
+
<% end %>
|
23
|
+
<% else %>
|
24
|
+
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
|
25
|
+
<% end %>
|
26
|
+
|
27
|
+
<%= yield :admin_head_extra %>
|
28
|
+
</head>
|
29
|
+
|
30
|
+
<body class="h-full">
|
31
|
+
<div class="flex h-full" id="panda-container">
|
32
|
+
<div class="absolute top-0 w-full lg:flex lg:fixed lg:inset-y-0 lg:z-50 lg:flex-col lg:w-72">
|
33
|
+
<div class="flex overflow-y-auto flex-col gap-y-5 px-4 pb-4 max-h-16 bg-gradient-to-br lg:max-h-full grow from-gray-900 to-gray-700">
|
34
|
+
<%= render "panda/core/admin/shared/sidebar" %>
|
35
|
+
<%= yield :admin_sidebar_extra %>
|
36
|
+
</div>
|
37
|
+
</div>
|
38
|
+
|
39
|
+
<div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container">
|
40
|
+
<section id="panda-main" class="flex flex-row h-full">
|
41
|
+
<div class="flex-1 h-full" id="panda-core-primary-content">
|
42
|
+
<%= render "panda/core/admin/shared/breadcrumbs" if respond_to?(:breadcrumbs) %>
|
43
|
+
<%= render "panda/core/admin/shared/flash" %>
|
44
|
+
|
45
|
+
<%= yield %>
|
46
|
+
|
47
|
+
<% if content_for?(:sidebar) %>
|
48
|
+
<%= render "panda/core/admin/shared/slideover" do %>
|
49
|
+
<%= yield :sidebar %>
|
50
|
+
<% end %>
|
51
|
+
<% end %>
|
52
|
+
</div>
|
53
|
+
</section>
|
54
|
+
|
55
|
+
<%= yield :admin_footer_extra %>
|
56
|
+
</div>
|
57
|
+
</div>
|
58
|
+
</body>
|
59
|
+
</html>
|
@@ -0,0 +1,27 @@
|
|
1
|
+
<div class="" data-controller="dashboard">
|
2
|
+
<%= render Panda::Core::Admin::ContainerComponent.new do |container| %>
|
3
|
+
<% container.with_heading(text: "Dashboard", level: 1) %>
|
4
|
+
|
5
|
+
<% # Hook for dashboard widgets %>
|
6
|
+
<% if Panda::Core.configuration.admin_dashboard_widgets %>
|
7
|
+
<% widgets = Panda::Core.configuration.admin_dashboard_widgets.call(current_user) %>
|
8
|
+
<% if widgets && widgets.any? %>
|
9
|
+
<div class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
|
10
|
+
<% widgets.each do |widget| %>
|
11
|
+
<%= render widget %>
|
12
|
+
<% end %>
|
13
|
+
</div>
|
14
|
+
<% else %>
|
15
|
+
<div class="mt-5 p-6 bg-white shadow rounded-lg">
|
16
|
+
<h2 class="text-lg font-medium text-gray-900">Welcome to Panda Core Admin</h2>
|
17
|
+
<p class="mt-1 text-sm text-gray-500">Configure dashboard widgets to display custom content here.</p>
|
18
|
+
</div>
|
19
|
+
<% end %>
|
20
|
+
<% else %>
|
21
|
+
<div class="mt-5 p-6 bg-white shadow rounded-lg">
|
22
|
+
<h2 class="text-lg font-medium text-gray-900">Welcome to Panda Core Admin</h2>
|
23
|
+
<p class="mt-1 text-sm text-gray-500">No dashboard widgets configured.</p>
|
24
|
+
</div>
|
25
|
+
<% end %>
|
26
|
+
<% end %>
|
27
|
+
</div>
|
@@ -0,0 +1,49 @@
|
|
1
|
+
<%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
|
2
|
+
<% component.with_heading(text: "My Profile", level: 1) %>
|
3
|
+
|
4
|
+
<%= form_with model: user,
|
5
|
+
url: admin_my_profile_path,
|
6
|
+
method: :patch,
|
7
|
+
local: true,
|
8
|
+
data: { controller: "theme-form" } do |f| %>
|
9
|
+
<% if user.errors.any? %>
|
10
|
+
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
11
|
+
<div class="text-sm text-red-600">
|
12
|
+
<% user.errors.full_messages.each do |message| %>
|
13
|
+
<p><%= message %></p>
|
14
|
+
<% end %>
|
15
|
+
</div>
|
16
|
+
</div>
|
17
|
+
<% end %>
|
18
|
+
|
19
|
+
<div class="space-y-4">
|
20
|
+
<div class="field">
|
21
|
+
<%= f.label :firstname, "First Name", class: "block text-sm font-medium text-gray-700" %>
|
22
|
+
<%= f.text_field :firstname, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" %>
|
23
|
+
</div>
|
24
|
+
|
25
|
+
<div class="field">
|
26
|
+
<%= f.label :lastname, "Last Name", class: "block text-sm font-medium text-gray-700" %>
|
27
|
+
<%= f.text_field :lastname, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" %>
|
28
|
+
</div>
|
29
|
+
|
30
|
+
<div class="field">
|
31
|
+
<%= f.label :email, class: "block text-sm font-medium text-gray-700" %>
|
32
|
+
<%= f.email_field :email, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" %>
|
33
|
+
</div>
|
34
|
+
|
35
|
+
<div class="field">
|
36
|
+
<%= f.label :current_theme, "Theme", class: "block text-sm font-medium text-gray-700" %>
|
37
|
+
<%= f.select :current_theme,
|
38
|
+
options_for_select(Panda::Core.configuration.available_themes || [["Default", "default"], ["Sky", "sky"]], user.current_theme),
|
39
|
+
{},
|
40
|
+
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm",
|
41
|
+
data: { action: "change->theme-form#updateTheme" } %>
|
42
|
+
</div>
|
43
|
+
</div>
|
44
|
+
|
45
|
+
<%= f.submit "Update Profile",
|
46
|
+
class: "btn btn-primary mt-6",
|
47
|
+
data: { disable_with: "Saving..." } %>
|
48
|
+
<% end %>
|
49
|
+
<% end %>
|
@@ -0,0 +1,38 @@
|
|
1
|
+
<div class="flex flex-col justify-center py-12 px-6 min-h-full text-center lg:px-8">
|
2
|
+
<div class="text-center sm:mx-auto sm:w-full sm:max-w-sm">
|
3
|
+
<% if Panda::Core.configuration.login_logo_path %>
|
4
|
+
<img src="<%= Panda::Core.configuration.login_logo_path %>" class="py-2 mx-auto w-auto h-32">
|
5
|
+
<% end %>
|
6
|
+
<h2 class="mt-10 mb-6 text-2xl font-bold text-center text-gray-900">
|
7
|
+
<%= Panda::Core.configuration.login_page_title || "Sign in to your account" %>
|
8
|
+
</h2>
|
9
|
+
</div>
|
10
|
+
<% if @providers&.any? || Panda::Core.configuration.authentication_providers.any? %>
|
11
|
+
<% providers = @providers || Panda::Core.configuration.authentication_providers.keys %>
|
12
|
+
<% providers.each do |provider| %>
|
13
|
+
<div class="mt-4 text-center sm:mx-auto sm:w-full sm:max-w-sm">
|
14
|
+
<%= form_tag "#{Panda::Core.configuration.admin_path}/auth/#{provider}", method: "post", data: {turbo: false} do %>
|
15
|
+
<button type="submit" id="button-sign-in-<%= provider %>" class="inline-flex gap-x-2 items-center py-2.5 px-3.5 mx-auto mb-4 bg-white rounded-md border min-w-56 border-neutral-400">
|
16
|
+
<% if defined?(FontAwesome) %>
|
17
|
+
<i class="fa-brands fa-<%= provider %> text-xl mr-1"></i>
|
18
|
+
<% end %>
|
19
|
+
Sign in with <%= provider.to_s.humanize %>
|
20
|
+
</button>
|
21
|
+
<% end %>
|
22
|
+
</div>
|
23
|
+
<% end %>
|
24
|
+
<% else %>
|
25
|
+
<div class="rounded-md bg-yellow-50 p-4 sm:mx-auto sm:w-full sm:max-w-sm">
|
26
|
+
<div class="flex">
|
27
|
+
<div class="ml-3">
|
28
|
+
<h3 class="text-sm font-medium text-yellow-800">
|
29
|
+
No authentication providers configured
|
30
|
+
</h3>
|
31
|
+
<div class="mt-2 text-sm text-yellow-700">
|
32
|
+
<p>Please configure at least one authentication provider in your Panda Core configuration.</p>
|
33
|
+
</div>
|
34
|
+
</div>
|
35
|
+
</div>
|
36
|
+
</div>
|
37
|
+
<% end %>
|
38
|
+
</div>
|