panda-core 0.1.15 → 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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -16
  3. data/Rakefile +3 -0
  4. data/app/builders/panda/core/form_builder.rb +225 -0
  5. data/app/components/panda/core/admin/button_component.rb +70 -0
  6. data/app/components/panda/core/admin/container_component.html.erb +12 -0
  7. data/app/components/panda/core/admin/container_component.rb +13 -0
  8. data/app/components/panda/core/admin/flash_message_component.html.erb +31 -0
  9. data/app/components/panda/core/admin/flash_message_component.rb +47 -0
  10. data/app/components/panda/core/admin/heading_component.rb +46 -0
  11. data/app/components/panda/core/admin/panel_component.html.erb +7 -0
  12. data/app/components/panda/core/admin/panel_component.rb +13 -0
  13. data/app/components/panda/core/admin/slideover_component.html.erb +9 -0
  14. data/app/components/panda/core/admin/slideover_component.rb +15 -0
  15. data/app/components/panda/core/admin/table_component.html.erb +29 -0
  16. data/app/components/panda/core/admin/table_component.rb +46 -0
  17. data/app/components/panda/core/admin/tag_component.rb +35 -0
  18. data/app/constraints/panda/core/admin_constraint.rb +14 -0
  19. data/app/controllers/panda/core/admin/dashboard_controller.rb +22 -0
  20. data/app/controllers/panda/core/admin/my_profile_controller.rb +49 -0
  21. data/app/controllers/panda/core/admin/sessions_controller.rb +69 -0
  22. data/app/controllers/panda/core/admin_controller.rb +28 -0
  23. data/app/controllers/panda/core/application_controller.rb +59 -0
  24. data/app/helpers/panda/core/asset_helper.rb +32 -0
  25. data/app/javascript/panda/core/application.js +9 -0
  26. data/app/javascript/panda/core/controllers/index.js +20 -0
  27. data/app/javascript/panda/core/controllers/theme_form_controller.js +25 -0
  28. data/app/javascript/panda/core/tailwindcss-stimulus-components.js +3 -0
  29. data/app/models/panda/core/application_record.rb +9 -0
  30. data/app/models/panda/core/breadcrumb.rb +17 -0
  31. data/app/models/panda/core/current.rb +16 -0
  32. data/app/models/panda/core/user.rb +51 -0
  33. data/app/views/layouts/panda/core/admin.html.erb +59 -0
  34. data/app/views/panda/core/admin/dashboard/show.html.erb +27 -0
  35. data/app/views/panda/core/admin/my_profile/edit.html.erb +49 -0
  36. data/app/views/panda/core/admin/sessions/new.html.erb +38 -0
  37. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +35 -0
  38. data/app/views/panda/core/admin/shared/_flash.html.erb +31 -0
  39. data/app/views/panda/core/admin/shared/_sidebar.html.erb +27 -0
  40. data/app/views/panda/core/admin/shared/_slideover.html.erb +33 -0
  41. data/config/routes.rb +22 -0
  42. data/db/migrate/20241210000003_add_current_theme_to_panda_core_users.rb +7 -0
  43. data/db/migrate/20250809000001_create_panda_core_users.rb +16 -0
  44. data/lib/generators/panda/core/dev_tools/USAGE +24 -0
  45. data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +13 -0
  46. data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +18 -0
  47. data/lib/generators/panda/core/dev_tools_generator.rb +143 -0
  48. data/lib/panda/core/asset_loader.rb +221 -0
  49. data/lib/panda/core/authentication.rb +36 -0
  50. data/lib/panda/core/component_registry.rb +37 -0
  51. data/lib/panda/core/configuration.rb +31 -1
  52. data/lib/panda/core/engine.rb +43 -7
  53. data/lib/panda/core/notifications.rb +40 -0
  54. data/lib/panda/core/rake_tasks.rb +16 -0
  55. data/lib/panda/core/subscribers/authentication_subscriber.rb +61 -0
  56. data/lib/panda/core/testing/capybara_config.rb +70 -0
  57. data/lib/panda/core/testing/omniauth_helpers.rb +52 -0
  58. data/lib/panda/core/testing/rspec_config.rb +72 -0
  59. data/lib/panda/core/version.rb +1 -1
  60. data/lib/panda/core.rb +2 -8
  61. data/lib/tasks/assets.rake +423 -0
  62. data/lib/tasks/panda/core/migrations.rake +13 -0
  63. data/lib/tasks/panda_core.rake +52 -0
  64. metadata +375 -10
  65. data/db/migrate/20250121012333_logidze_install.rb +0 -577
  66. 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,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
9
+ end
@@ -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>