panda-core 0.2.3 → 0.4.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 +185 -0
  3. data/app/assets/tailwind/application.css +279 -0
  4. data/app/assets/tailwind/tailwind.config.js +21 -0
  5. data/app/components/panda/core/UI/badge.rb +107 -0
  6. data/app/components/panda/core/UI/button.rb +89 -0
  7. data/app/components/panda/core/UI/card.rb +88 -0
  8. data/app/components/panda/core/admin/button_component.rb +46 -28
  9. data/app/components/panda/core/admin/container_component.rb +52 -4
  10. data/app/components/panda/core/admin/flash_message_component.rb +74 -9
  11. data/app/components/panda/core/admin/form_error_component.rb +48 -0
  12. data/app/components/panda/core/admin/form_input_component.rb +50 -0
  13. data/app/components/panda/core/admin/form_select_component.rb +68 -0
  14. data/app/components/panda/core/admin/heading_component.rb +52 -24
  15. data/app/components/panda/core/admin/panel_component.rb +33 -4
  16. data/app/components/panda/core/admin/slideover_component.rb +8 -4
  17. data/app/components/panda/core/admin/statistics_component.rb +19 -0
  18. data/app/components/panda/core/admin/tab_bar_component.rb +101 -0
  19. data/app/components/panda/core/admin/table_component.rb +90 -9
  20. data/app/components/panda/core/admin/tag_component.rb +21 -16
  21. data/app/components/panda/core/admin/user_activity_component.rb +43 -0
  22. data/app/components/panda/core/admin/user_display_component.rb +78 -0
  23. data/app/components/panda/core/base.rb +122 -0
  24. data/app/controllers/panda/core/admin/base_controller.rb +68 -0
  25. data/app/controllers/panda/core/admin/dashboard_controller.rb +7 -6
  26. data/app/controllers/panda/core/admin/my_profile_controller.rb +3 -3
  27. data/app/controllers/panda/core/admin/sessions_controller.rb +26 -5
  28. data/app/helpers/panda/core/sessions_helper.rb +21 -0
  29. data/app/javascript/panda/core/application.js +1 -0
  30. data/app/javascript/panda/core/vendor/@hotwired--stimulus.js +4 -0
  31. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +160 -0
  32. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +4 -0
  33. data/app/models/panda/core/user.rb +17 -13
  34. data/app/views/layouts/panda/core/admin.html.erb +40 -57
  35. data/app/views/layouts/panda/core/admin_simple.html.erb +5 -0
  36. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +73 -0
  37. data/app/views/panda/core/admin/dashboard/show.html.erb +4 -10
  38. data/app/views/panda/core/admin/my_profile/edit.html.erb +13 -27
  39. data/app/views/panda/core/admin/sessions/new.html.erb +13 -12
  40. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +27 -34
  41. data/app/views/panda/core/admin/shared/_flash.html.erb +4 -30
  42. data/app/views/panda/core/admin/shared/_sidebar.html.erb +36 -20
  43. data/app/views/panda/core/shared/_footer.html.erb +2 -0
  44. data/app/views/panda/core/shared/_header.html.erb +19 -0
  45. data/config/importmap.rb +15 -0
  46. data/config/initializers/panda_core.rb +37 -1
  47. data/config/routes.rb +7 -7
  48. data/db/migrate/20250810120000_add_current_theme_to_panda_core_users.rb +7 -0
  49. data/lib/generators/panda/core/install_generator.rb +3 -9
  50. data/lib/generators/panda/core/templates/README +25 -0
  51. data/lib/generators/panda/core/templates/initializer.rb +28 -0
  52. data/lib/panda/core/asset_loader.rb +23 -8
  53. data/lib/panda/core/configuration.rb +41 -9
  54. data/lib/panda/core/debug.rb +47 -0
  55. data/lib/panda/core/engine.rb +82 -8
  56. data/lib/panda/core/version.rb +1 -1
  57. data/lib/panda/core.rb +1 -0
  58. data/lib/tasks/assets.rake +58 -392
  59. data/lib/tasks/panda_core_tasks.rake +16 -0
  60. metadata +102 -14
  61. data/app/components/panda/core/admin/container_component.html.erb +0 -12
  62. data/app/components/panda/core/admin/flash_message_component.html.erb +0 -31
  63. data/app/components/panda/core/admin/panel_component.html.erb +0 -7
  64. data/app/components/panda/core/admin/slideover_component.html.erb +0 -9
  65. data/app/components/panda/core/admin/table_component.html.erb +0 -29
  66. data/app/controllers/panda/core/admin_controller.rb +0 -28
@@ -0,0 +1,4 @@
1
+ // @rails/actioncable/src@7.2.101 downloaded from https://ga.jspm.io/npm:@rails/actioncable@7.2.101/src/index.js
2
+
3
+ var t={logger:typeof console!=="undefined"?console:void 0,WebSocket:typeof WebSocket!=="undefined"?WebSocket:void 0};var e={log(...e){if(this.enabled){e.push(Date.now());t.logger.log("[ActionCable]",...e)}}};const now=()=>(new Date).getTime();const secondsSince=t=>(now()-t)/1e3;class ConnectionMonitor{constructor(t){this.visibilityDidChange=this.visibilityDidChange.bind(this);this.connection=t;this.reconnectAttempts=0}start(){if(!this.isRunning()){this.startedAt=now();delete this.stoppedAt;this.startPolling();addEventListener("visibilitychange",this.visibilityDidChange);e.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`)}}stop(){if(this.isRunning()){this.stoppedAt=now();this.stopPolling();removeEventListener("visibilitychange",this.visibilityDidChange);e.log("ConnectionMonitor stopped")}}isRunning(){return this.startedAt&&!this.stoppedAt}recordMessage(){this.pingedAt=now()}recordConnect(){this.reconnectAttempts=0;delete this.disconnectedAt;e.log("ConnectionMonitor recorded connect")}recordDisconnect(){this.disconnectedAt=now();e.log("ConnectionMonitor recorded disconnect")}startPolling(){this.stopPolling();this.poll()}stopPolling(){clearTimeout(this.pollTimeout)}poll(){this.pollTimeout=setTimeout((()=>{this.reconnectIfStale();this.poll()}),this.getPollInterval())}getPollInterval(){const{staleThreshold:t,reconnectionBackoffRate:e}=this.constructor;const n=Math.pow(1+e,Math.min(this.reconnectAttempts,10));const i=this.reconnectAttempts===0?1:e;const o=i*Math.random();return t*1e3*n*(1+o)}reconnectIfStale(){if(this.connectionIsStale()){e.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);this.reconnectAttempts++;if(this.disconnectedRecently())e.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);else{e.log("ConnectionMonitor reopening");this.connection.reopen()}}}get refreshedAt(){return this.pingedAt?this.pingedAt:this.startedAt}connectionIsStale(){return secondsSince(this.refreshedAt)>this.constructor.staleThreshold}disconnectedRecently(){return this.disconnectedAt&&secondsSince(this.disconnectedAt)<this.constructor.staleThreshold}visibilityDidChange(){document.visibilityState==="visible"&&setTimeout((()=>{if(this.connectionIsStale()||!this.connection.isOpen()){e.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`);this.connection.reopen()}}),200)}}ConnectionMonitor.staleThreshold=6;ConnectionMonitor.reconnectionBackoffRate=.15;var n={message_types:{welcome:"welcome",disconnect:"disconnect",ping:"ping",confirmation:"confirm_subscription",rejection:"reject_subscription"},disconnect_reasons:{unauthorized:"unauthorized",invalid_request:"invalid_request",server_restart:"server_restart",remote:"remote"},default_mount_path:"/cable",protocols:["actioncable-v1-json","actioncable-unsupported"]};const{message_types:i,protocols:o}=n;const s=o.slice(0,o.length-1);const r=[].indexOf;class Connection{constructor(t){this.open=this.open.bind(this);this.consumer=t;this.subscriptions=this.consumer.subscriptions;this.monitor=new ConnectionMonitor(this);this.disconnected=true}send(t){if(this.isOpen()){this.webSocket.send(JSON.stringify(t));return true}return false}open(){if(this.isActive()){e.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`);return false}{const n=[...o,...this.consumer.subprotocols||[]];e.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${n}`);this.webSocket&&this.uninstallEventHandlers();this.webSocket=new t.WebSocket(this.consumer.url,n);this.installEventHandlers();this.monitor.start();return true}}close({allowReconnect:t}={allowReconnect:true}){t||this.monitor.stop();if(this.isOpen())return this.webSocket.close()}reopen(){e.log(`Reopening WebSocket, current state is ${this.getState()}`);if(!this.isActive())return this.open();try{return this.close()}catch(t){e.log("Failed to reopen WebSocket",t)}finally{e.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`);setTimeout(this.open,this.constructor.reopenDelay)}}getProtocol(){if(this.webSocket)return this.webSocket.protocol}isOpen(){return this.isState("open")}isActive(){return this.isState("open","connecting")}triedToReconnect(){return this.monitor.reconnectAttempts>0}isProtocolSupported(){return r.call(s,this.getProtocol())>=0}isState(...t){return r.call(t,this.getState())>=0}getState(){if(this.webSocket)for(let e in t.WebSocket)if(t.WebSocket[e]===this.webSocket.readyState)return e.toLowerCase();return null}installEventHandlers(){for(let t in this.events){const e=this.events[t].bind(this);this.webSocket[`on${t}`]=e}}uninstallEventHandlers(){for(let t in this.events)this.webSocket[`on${t}`]=function(){}}}Connection.reopenDelay=500;Connection.prototype.events={message(t){if(!this.isProtocolSupported())return;const{identifier:n,message:o,reason:s,reconnect:r,type:c}=JSON.parse(t.data);this.monitor.recordMessage();switch(c){case i.welcome:this.triedToReconnect()&&(this.reconnectAttempted=true);this.monitor.recordConnect();return this.subscriptions.reload();case i.disconnect:e.log(`Disconnecting. Reason: ${s}`);return this.close({allowReconnect:r});case i.ping:return null;case i.confirmation:this.subscriptions.confirmSubscription(n);if(this.reconnectAttempted){this.reconnectAttempted=false;return this.subscriptions.notify(n,"connected",{reconnected:true})}return this.subscriptions.notify(n,"connected",{reconnected:false});case i.rejection:return this.subscriptions.reject(n);default:return this.subscriptions.notify(n,"received",o)}},open(){e.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`);this.disconnected=false;if(!this.isProtocolSupported()){e.log("Protocol is unsupported. Stopping monitor and disconnecting.");return this.close({allowReconnect:false})}},close(t){e.log("WebSocket onclose event");if(!this.disconnected){this.disconnected=true;this.monitor.recordDisconnect();return this.subscriptions.notifyAll("disconnected",{willAttemptReconnect:this.monitor.isRunning()})}},error(){e.log("WebSocket onerror event")}};const extend=function(t,e){if(e!=null)for(let n in e){const i=e[n];t[n]=i}return t};class Subscription{constructor(t,e={},n){this.consumer=t;this.identifier=JSON.stringify(e);extend(this,n)}perform(t,e={}){e.action=t;return this.send(e)}send(t){return this.consumer.send({command:"message",identifier:this.identifier,data:JSON.stringify(t)})}unsubscribe(){return this.consumer.subscriptions.remove(this)}}class SubscriptionGuarantor{constructor(t){this.subscriptions=t;this.pendingSubscriptions=[]}guarantee(t){if(this.pendingSubscriptions.indexOf(t)==-1){e.log(`SubscriptionGuarantor guaranteeing ${t.identifier}`);this.pendingSubscriptions.push(t)}else e.log(`SubscriptionGuarantor already guaranteeing ${t.identifier}`);this.startGuaranteeing()}forget(t){e.log(`SubscriptionGuarantor forgetting ${t.identifier}`);this.pendingSubscriptions=this.pendingSubscriptions.filter((e=>e!==t))}startGuaranteeing(){this.stopGuaranteeing();this.retrySubscribing()}stopGuaranteeing(){clearTimeout(this.retryTimeout)}retrySubscribing(){this.retryTimeout=setTimeout((()=>{this.subscriptions&&typeof this.subscriptions.subscribe==="function"&&this.pendingSubscriptions.map((t=>{e.log(`SubscriptionGuarantor resubscribing ${t.identifier}`);this.subscriptions.subscribe(t)}))}),500)}}class Subscriptions{constructor(t){this.consumer=t;this.guarantor=new SubscriptionGuarantor(this);this.subscriptions=[]}create(t,e){const n=t;const i=typeof n==="object"?n:{channel:n};const o=new Subscription(this.consumer,i,e);return this.add(o)}add(t){this.subscriptions.push(t);this.consumer.ensureActiveConnection();this.notify(t,"initialized");this.subscribe(t);return t}remove(t){this.forget(t);this.findAll(t.identifier).length||this.sendCommand(t,"unsubscribe");return t}reject(t){return this.findAll(t).map((t=>{this.forget(t);this.notify(t,"rejected");return t}))}forget(t){this.guarantor.forget(t);this.subscriptions=this.subscriptions.filter((e=>e!==t));return t}findAll(t){return this.subscriptions.filter((e=>e.identifier===t))}reload(){return this.subscriptions.map((t=>this.subscribe(t)))}notifyAll(t,...e){return this.subscriptions.map((n=>this.notify(n,t,...e)))}notify(t,e,...n){let i;i=typeof t==="string"?this.findAll(t):[t];return i.map((t=>typeof t[e]==="function"?t[e](...n):void 0))}subscribe(t){this.sendCommand(t,"subscribe")&&this.guarantor.guarantee(t)}confirmSubscription(t){e.log(`Subscription confirmed ${t}`);this.findAll(t).map((t=>this.guarantor.forget(t)))}sendCommand(t,e){const{identifier:n}=t;return this.consumer.send({command:e,identifier:n})}}class Consumer{constructor(t){this._url=t;this.subscriptions=new Subscriptions(this);this.connection=new Connection(this);this.subprotocols=[]}get url(){return createWebSocketURL(this._url)}send(t){return this.connection.send(t)}connect(){return this.connection.open()}disconnect(){return this.connection.close({allowReconnect:false})}ensureActiveConnection(){if(!this.connection.isActive())return this.connection.open()}addSubProtocol(t){this.subprotocols=[...this.subprotocols,t]}}function createWebSocketURL(t){typeof t==="function"&&(t=t());if(t&&!/^wss?:/i.test(t)){const e=document.createElement("a");e.href=t;e.href=e.href;e.protocol=e.protocol.replace("http","ws");return e.href}return t}function createConsumer(t=getConfig("url")||n.default_mount_path){return new Consumer(t)}function getConfig(t){const e=document.head.querySelector(`meta[name='action-cable-${t}']`);if(e)return e.getAttribute("content")}export{Connection,ConnectionMonitor,Consumer,n as INTERNAL,Subscription,SubscriptionGuarantor,Subscriptions,t as adapters,createConsumer,createWebSocketURL,getConfig,e as logger};
4
+
@@ -10,37 +10,41 @@ module Panda
10
10
  before_save :downcase_email
11
11
 
12
12
  # Scopes
13
- scope :admin, -> { where(admin: true) }
13
+ scope :admin, -> { where(is_admin: true) }
14
14
 
15
15
  def self.find_or_create_from_auth_hash(auth_hash)
16
16
  user = find_by(email: auth_hash.info.email.downcase)
17
17
  return user if user
18
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
19
  create!(
24
20
  email: auth_hash.info.email.downcase,
25
- firstname: name_parts[0] || "Unknown",
26
- lastname: name_parts[1] || "",
21
+ name: auth_hash.info.name || "Unknown User",
27
22
  image_url: auth_hash.info.image,
28
- admin: User.count.zero? # First user is admin
23
+ is_admin: User.count.zero? # First user is admin
29
24
  )
30
25
  end
31
26
 
32
27
  def admin?
33
- admin == true
34
- end
35
-
36
- def name
37
- "#{firstname} #{lastname}".strip
28
+ is_admin?
38
29
  end
39
30
 
40
31
  def active_for_authentication?
41
32
  true
42
33
  end
43
34
 
35
+ def name
36
+ # Support both schema versions:
37
+ # - Main app: has 'name' column
38
+ # - Test app: has 'firstname' and 'lastname' columns
39
+ if respond_to?(:firstname) && respond_to?(:lastname)
40
+ "#{firstname} #{lastname}".strip
41
+ elsif self[:name].present?
42
+ self[:name]
43
+ else
44
+ email&.split("@")&.first || "Unknown User"
45
+ end
46
+ end
47
+
44
48
  private
45
49
 
46
50
  def downcase_email
@@ -1,59 +1,42 @@
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 %>
1
+ <%= render "panda/core/shared/header", html_class: "h-full bg-white" %>
2
+ <div class="flex h-full" id="panda-container">
3
+ <div class="absolute top-0 w-full lg:flex lg:fixed lg:inset-y-0 lg:z-50 lg:flex-col lg:w-72">
4
+ <div class="flex overflow-y-auto flex-col gap-y-5 px-4 pb-4 max-h-16 bg-gradient-admin lg:max-h-full grow" data-transition-enter="transition-all ease-in-out duration-300" data-transition-enter-from="m-h-16" data-transition-enter-to="max-h-full" data-transition-leave="transition-all ease-in-out duration-300" data-transition-leave-from="max-h-full" data-transition-leave-to="max-h-16">
5
+ <%= render "panda/core/admin/shared/sidebar" %>
6
+ </div>
7
+ </div>
8
+ <div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container" <% if content_for :sidebar %> data-controller="toggle" data-action="keydown.esc->modal#close" tabindex="-1"<% end %>>
9
+ <section id="panda-main" class="flex flex-row h-full">
10
+ <div class="flex-1 h-full" id="panda-primary-content">
11
+ <%= render "panda/core/admin/shared/breadcrumbs" %>
12
+ <%= render "panda/core/admin/shared/flash" %>
13
+ <%= yield %>
14
+ </div>
15
+ <% if content_for :sidebar %>
16
+ <div data-toggle-target="toggleable" class="hidden flex absolute right-0 flex-col h-full bg-white divide-y divide-gray-200 shadow-xl basis-3/12"
17
+ data-transition-enter="transform transition ease-in-out duration-500"
18
+ data-transition-enter-from="translate-x-full"
19
+ data-transition-enter-to="translate-x-0"
20
+ data-transition-leave="transform transition ease-in-out duration-500"
21
+ data-transition-leave-from="translate-x-full"
22
+ data-transition-leave-to="translate-x-0"
23
+ id="slideover">
24
+ <div class="overflow-y-auto flex-1 h-0">
25
+ <div class="py-3 px-4 mb-4 bg-black">
26
+ <div class="flex justify-between items-center">
27
+ <h2 class="text-base font-semibold leading-6 text-white" id="slideover-title"><i class="mr-2 fa-solid fa-gear"></i> <%= yield :sidebar_title %> </h2>
28
+ <button type="button" data-action="click->toggle#toggle touch->toggle#toggle"><i class="font-bold text-white fa-solid fa-xmark right"></i></button>
29
+ </div>
52
30
  </div>
53
- </section>
54
-
55
- <%= yield :admin_footer_extra %>
31
+ <div class="flex flex-col flex-1 justify-between">
32
+ <div class="px-4 space-y-6">
33
+ <%= yield :sidebar %>
34
+ </div>
35
+ </div>
36
+ </div>
56
37
  </div>
57
- </div>
58
- </body>
59
- </html>
38
+ <% end %>
39
+ </section>
40
+ </div>
41
+ </div>
42
+ <%= render "panda/core/shared/footer" %>
@@ -0,0 +1,5 @@
1
+ <%= render "panda/core/shared/header", html_class: "h-full", body_class: "bg-gradient-admin" %>
2
+ <div class="flex flex-col items-center justify-center min-h-screen px-4">
3
+ <%= yield %>
4
+ </div>
5
+ <%= render "panda/core/shared/footer" %>
@@ -0,0 +1,73 @@
1
+ <div class="mt-5">
2
+ <div class="p-6 bg-white shadow rounded-lg">
3
+ <h2 class="text-lg font-medium text-gray-900">Welcome to Panda Admin</h2>
4
+ <p class="mt-1 text-sm text-gray-500">Manage your application from this central dashboard.</p>
5
+ </div>
6
+
7
+ <div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
8
+ <% if defined?(Panda::CMS) %>
9
+ <div class="bg-white overflow-hidden shadow rounded-lg">
10
+ <div class="px-4 py-5 sm:p-6">
11
+ <div class="flex items-center">
12
+ <div class="flex-shrink-0">
13
+ <i class="fa-solid fa-file-lines text-3xl text-gray-400"></i>
14
+ </div>
15
+ <div class="ml-5 w-0 flex-1">
16
+ <dt class="text-sm font-medium text-gray-500 truncate">Content Management</dt>
17
+ <dd class="mt-1 text-lg font-semibold text-gray-900">
18
+ <%= link_to "Manage CMS", panda_cms.admin_cms_dashboard_path, class: "text-indigo-600 hover:text-indigo-900" %>
19
+ </dd>
20
+ </div>
21
+ </div>
22
+ <div class="mt-3">
23
+ <p class="text-sm text-gray-500">Pages, posts, menus, and content blocks</p>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ <% end %>
28
+
29
+ <div class="bg-white overflow-hidden shadow rounded-lg">
30
+ <div class="px-4 py-5 sm:p-6">
31
+ <div class="flex items-center">
32
+ <div class="flex-shrink-0">
33
+ <i class="fa-regular fa-user text-3xl text-gray-400"></i>
34
+ </div>
35
+ <div class="ml-5 w-0 flex-1">
36
+ <dt class="text-sm font-medium text-gray-500 truncate">My Profile</dt>
37
+ <dd class="mt-1 text-lg font-semibold text-gray-900">
38
+ <%= link_to "Edit Profile", edit_admin_my_profile_path, class: "text-indigo-600 hover:text-indigo-900" %>
39
+ </dd>
40
+ </div>
41
+ </div>
42
+ <div class="mt-3">
43
+ <p class="text-sm text-gray-500">Update your personal information</p>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <% # Hook for additional dashboard cards %>
49
+ <% if Panda::Core.config.respond_to?(:admin_dashboard_cards) %>
50
+ <% cards = Panda::Core.config.admin_dashboard_cards&.call(current_user) %>
51
+ <% cards&.each do |card| %>
52
+ <div class="bg-white overflow-hidden shadow rounded-lg">
53
+ <div class="px-4 py-5 sm:p-6">
54
+ <div class="flex items-center">
55
+ <div class="flex-shrink-0">
56
+ <i class="<%= card[:icon] %> text-3xl text-gray-400"></i>
57
+ </div>
58
+ <div class="ml-5 w-0 flex-1">
59
+ <dt class="text-sm font-medium text-gray-500 truncate"><%= card[:title] %></dt>
60
+ <dd class="mt-1 text-lg font-semibold text-gray-900">
61
+ <%= link_to card[:link_text], card[:path], class: "text-indigo-600 hover:text-indigo-900" %>
62
+ </dd>
63
+ </div>
64
+ </div>
65
+ <div class="mt-3">
66
+ <p class="text-sm text-gray-500"><%= card[:description] %></p>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <% end %>
71
+ <% end %>
72
+ </div>
73
+ </div>
@@ -3,8 +3,8 @@
3
3
  <% container.with_heading(text: "Dashboard", level: 1) %>
4
4
 
5
5
  <% # Hook for dashboard widgets %>
6
- <% if Panda::Core.configuration.admin_dashboard_widgets %>
7
- <% widgets = Panda::Core.configuration.admin_dashboard_widgets.call(current_user) %>
6
+ <% if Panda::Core.config.admin_dashboard_widgets %>
7
+ <% widgets = Panda::Core.config.admin_dashboard_widgets.call(current_user) %>
8
8
  <% if widgets && widgets.any? %>
9
9
  <div class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
10
10
  <% widgets.each do |widget| %>
@@ -12,16 +12,10 @@
12
12
  <% end %>
13
13
  </div>
14
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>
15
+ <%= render "panda/core/admin/dashboard/default_content" %>
19
16
  <% end %>
20
17
  <% 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>
18
+ <%= render "panda/core/admin/dashboard/default_content" %>
25
19
  <% end %>
26
20
  <% end %>
27
21
  </div>
@@ -1,49 +1,35 @@
1
1
  <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
- <% component.with_heading(text: "My Profile", level: 1) %>
2
+ <% component.heading(text: "My Profile", level: 1) %>
3
3
 
4
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 %>
5
+ url: admin_my_profile_path,
6
+ method: :patch,
7
+ local: true,
8
+ data: { controller: "theme-form" } do |f| %>
9
+ <%= render Panda::Core::Admin::FormErrorComponent.new(model: user) %>
18
10
 
19
11
  <div class="space-y-4">
20
12
  <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" %>
13
+ <%= f.label :name %>
14
+ <%= f.text_field :name %>
28
15
  </div>
29
16
 
30
17
  <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" %>
18
+ <%= f.label :email %>
19
+ <%= f.email_field :email %>
33
20
  </div>
34
21
 
35
22
  <div class="field">
36
- <%= f.label :current_theme, "Theme", class: "block text-sm font-medium text-gray-700" %>
23
+ <%= f.label :current_theme, "Theme" %>
37
24
  <%= f.select :current_theme,
38
- options_for_select(Panda::Core.configuration.available_themes || [["Default", "default"], ["Sky", "sky"]], user.current_theme),
25
+ options_for_select(Panda::Core.config.available_themes || [["Default", "default"], ["Sky", "sky"]], user.current_theme),
39
26
  {},
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
27
  data: { action: "change->theme-form#updateTheme" } %>
42
28
  </div>
43
29
  </div>
44
30
 
45
31
  <%= f.submit "Update Profile",
46
- class: "btn btn-primary mt-6",
32
+ class: "btn btn-primary mt-4",
47
33
  data: { disable_with: "Saving..." } %>
48
34
  <% end %>
49
35
  <% end %>
@@ -1,22 +1,23 @@
1
1
  <div class="flex flex-col justify-center py-12 px-6 min-h-full text-center lg:px-8">
2
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">
3
+ <% if Panda::Core.config.login_logo_path %>
4
+ <img src="<%= Panda::Core.config.login_logo_path %>" class="py-2 mx-auto w-auto h-32">
5
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" %>
6
+ <h2 class="mt-10 mb-6 text-2xl font-bold text-center text-white">
7
+ <%= Panda::Core.config.login_page_title || "Sign in to your account" %>
8
8
  </h2>
9
9
  </div>
10
- <% if @providers&.any? || Panda::Core.configuration.authentication_providers.any? %>
11
- <% providers = @providers || Panda::Core.configuration.authentication_providers.keys %>
10
+ <% if @providers&.any? || Panda::Core.config.authentication_providers.any? %>
11
+ <% providers = @providers || Panda::Core.config.authentication_providers.keys %>
12
12
  <% providers.each do |provider| %>
13
+ <% provider_config = Panda::Core.config.authentication_providers[provider] %>
14
+ <% provider_name = provider_config&.dig(:name) || provider.to_s.humanize %>
15
+ <% provider_path = provider_config&.dig(:path_name) || provider %>
13
16
  <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 %>
17
+ <%= form_tag "#{Panda::Core.config.admin_path}/auth/#{provider_path}", method: "post", data: {turbo: false} do %>
18
+ <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 %>
20
21
  </button>
21
22
  <% end %>
22
23
  </div>
@@ -1,35 +1,28 @@
1
- <% if defined?(@breadcrumbs) && @breadcrumbs.any? %>
2
- <div class="flex">
3
- <nav class="flex-1 px-4 py-3 text-gray-700 border-b border-gray-200 bg-gray-50" aria-label="Breadcrumb" id="panda-breadcrumbs">
4
- <ol class="inline-flex items-center space-x-1 md:space-x-3">
5
- <% @breadcrumbs.each_with_index do |breadcrumb, index| %>
6
- <li class="inline-flex items-center">
7
- <% if index > 0 %>
8
- <svg class="w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
9
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
10
- </svg>
11
- <% end %>
12
-
13
- <% if breadcrumb.path && index < @breadcrumbs.length - 1 %>
14
- <%= link_to breadcrumb.label, breadcrumb.path, class: "inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600" %>
15
- <% else %>
16
- <span class="text-sm font-medium text-gray-500"><%= breadcrumb.label %></span>
17
- <% end %>
18
- </li>
19
- <% end %>
20
- </ol>
21
- </nav>
22
-
23
- <% if content_for?(:sidebar) %>
24
- <div class="px-4 py-3 border-b border-gray-200 bg-gray-50 text-gray-700" tabindex="-1" data-controller="toggle">
25
- <a href="#" id="slideover-toggle" data-action="click->toggle#toggle touch->toggle#toggle" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600">
26
- <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
27
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
28
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
29
- </svg>
30
- <%= yield(:sidebar_title) || "Settings" %>
1
+ <% breadcrumb_styles = "text-black/60 hover:text-black/80" %>
2
+
3
+ <div class="flex mt-1 -mb-1">
4
+ <nav aria-label="Breadcrumb" id="panda-breadcrumbs" class="grow">
5
+ <ol role="list" class="px-4 w-full sm:px-6">
6
+ <li class="inline-block">
7
+ <a href="<%= Panda::Core.config.admin_path %>" class="<%= breadcrumb_styles %>">
8
+ <i class="fa fa-solid fa-house"></i>
9
+ <span class="sr-only">Home</span>
31
10
  </a>
32
- </div>
33
- <% end %>
34
- </div>
35
- <% end %>
11
+ </li>
12
+ <% breadcrumbs.each do |crumb| %>
13
+ <li class="inline-block">
14
+ <i class="fa fa-regular fa-chevron-right font-light <%= breadcrumb_styles %> py-5 px-2"></i>
15
+ <a href="<%= crumb.path %>" class="text-sm font-normal <%= breadcrumb_styles %>"><%= crumb.name %></a>
16
+ </li>
17
+ <% end %>
18
+ </ol>
19
+ </nav>
20
+
21
+ <% if content_for :sidebar %>
22
+ <div class="pt-4 pr-8 text-black/80" tabindex="-1" data-controller="toggle">
23
+ <button type="button" id="slideover-toggle" data-action="click->toggle#toggle touch->toggle#toggle" class="text-sm font-light">
24
+ <i class="mr-1 fa-light fa-gear"></i> <%= yield :sidebar_title %>
25
+ </button>
26
+ </div>
27
+ <% end %>
28
+ </div>
@@ -1,31 +1,5 @@
1
1
  <% if flash.any? %>
2
- <div class="fixed top-4 right-4 z-50 space-y-2">
3
- <% flash.each do |type, message| %>
4
- <div class="bg-white rounded-lg shadow-lg p-4 max-w-sm" data-controller="flash" data-flash-delay-value="5000">
5
- <div class="flex items-start">
6
- <div class="flex-shrink-0">
7
- <% if type == "success" %>
8
- <i class="fa-regular fa-check-circle text-green-500"></i>
9
- <% elsif type == "error" %>
10
- <i class="fa-regular fa-times-circle text-red-500"></i>
11
- <% elsif type == "warning" %>
12
- <i class="fa-regular fa-exclamation-triangle text-yellow-500"></i>
13
- <% else %>
14
- <i class="fa-regular fa-info-circle text-blue-500"></i>
15
- <% end %>
16
- </div>
17
- <div class="ml-3 w-0 flex-1">
18
- <p class="text-sm font-medium text-gray-900">
19
- <%= message %>
20
- </p>
21
- </div>
22
- <div class="ml-4 flex-shrink-0 flex">
23
- <button type="button" class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500" data-action="click->flash#close">
24
- <i class="fa-regular fa-times"></i>
25
- </button>
26
- </div>
27
- </div>
28
- </div>
29
- <% end %>
30
- </div>
31
- <% end %>
2
+ <% flash.each do |kind, message| %>
3
+ <%= render Panda::Core::Admin::FlashMessageComponent.new(kind: kind.to_sym, message: message) %>
4
+ <% end %>
5
+ <% end %>
@@ -1,27 +1,43 @@
1
1
  <nav class="flex flex-col flex-1">
2
- <ul role="list" class="flex flex-col flex-1 gap-y-7">
3
- <li>
4
- <ul role="list" class="-mx-2 space-y-1">
5
- <% Panda::Core.configuration.admin_navigation_items&.call(current_user)&.each do |item| %>
6
- <li>
7
- <%= link_to item[:path], class: "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold text-gray-200 hover:text-white hover:bg-gray-700" do %>
8
- <i class="<%= item[:icon] %> text-gray-200 group-hover:text-white"></i>
9
- <%= item[:label] %>
10
- <% end %>
11
- </li>
2
+ <ul role="list" class="flex flex-col flex-1">
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| %>
5
+ <li>
6
+ <%
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
16
+ %>
17
+ <%= 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
+ <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
19
+ <span><%= item[:label] %></span>
12
20
  <% end %>
13
- </ul>
21
+ </li>
22
+ <% end %>
23
+ <li>
24
+ <%= button_to panda_core.admin_logout_path, method: :delete, id: "logout-link", data: { turbo: false }, 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" do %>
25
+ <span class="text-center w-6"><i class="text-xl fa-solid fa-door-open fa-fw"></i></span>
26
+ <span>Logout</span>
27
+ <% end %>
14
28
  </li>
15
-
16
29
  <li class="mt-auto">
17
- <div class="flex items-center gap-x-4 px-6 py-3 text-sm font-semibold leading-6 text-gray-200">
18
- <% if current_user %>
19
- <span class="flex-1"><%= "#{current_user.firstname} #{current_user.lastname}" %></span>
20
- <%= link_to panda_core.admin_logout_path, method: :delete, class: "text-gray-200 hover:text-white" do %>
21
- <i class="fa-regular fa-sign-out"></i>
22
- <% end %>
30
+ <%= 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? %>
32
+ <span class="text-center w-6"><img src="<%= current_user.image_url %>" class="w-auto h-7 rounded-full"></span>
33
+ <% else %>
34
+ <span class="text-center w-6"><i class="text-xl fa-regular fa-circle-user fa-fw"></i></span>
23
35
  <% end %>
24
- </div>
36
+ <span><%= current_user.name %></span>
37
+ <% end %>
38
+ </li>
39
+ <li class="px-2 py-3">
40
+ <span class="text-xs text-white">Panda Core v<%= Panda::Core::VERSION %></span>
25
41
  </li>
26
42
  </ul>
27
- </nav>
43
+ </nav>
@@ -0,0 +1,2 @@
1
+ </body>
2
+ </html>
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html data-theme="<%= Panda::Core::Current&.user&.current_theme || Panda::Core.config.default_theme %>" class="<%= local_assigns[:html_class] || "" %>">
3
+ <head>
4
+ <title><%= content_for?(:title) ? yield(:title) : (Panda::Core.config.admin_title || "Panda Admin") %></title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7.1.0/css/all.min.css">
8
+ <%= panda_core_stylesheet %>
9
+ <%= panda_core_javascript %>
10
+
11
+ <% if defined?(Panda::CMS) && controller.class.name.start_with?("Panda::CMS") %>
12
+ <!-- CMS Assets -->
13
+ <%= panda_cms_javascript %>
14
+ <%= render "panda/cms/shared/favicons" %>
15
+ <% end %>
16
+
17
+ <%= yield :head %>
18
+ </head>
19
+ <body class="overflow-hidden h-full <%= local_assigns[:body_class] || "" %>" data-environment="<%= Rails.env %>">
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
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"
7
+
8
+ # 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
13
+
14
+ # Font Awesome icons (from CDN)
15
+ pin "@fortawesome/fontawesome-free", to: "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@7.1.0/js/all.js"