panda-core 0.12.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +53 -0
  4. data/app/assets/builds/panda_core_admin.css +2 -0
  5. data/app/assets/tailwind/application.css +236 -36
  6. data/app/builders/panda/core/form_builder.rb +120 -18
  7. data/app/components/panda/core/UI/badge.html.erb +10 -0
  8. data/app/components/panda/core/UI/badge.rb +14 -30
  9. data/app/components/panda/core/UI/button.html.erb +5 -0
  10. data/app/components/panda/core/UI/button.rb +11 -15
  11. data/app/components/panda/core/UI/card.html.erb +3 -0
  12. data/app/components/panda/core/UI/card.rb +7 -6
  13. data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
  14. data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
  15. data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
  16. data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
  17. data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
  18. data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
  19. data/app/components/panda/core/admin/button_component.html.erb +9 -0
  20. data/app/components/panda/core/admin/button_component.rb +36 -31
  21. data/app/components/panda/core/admin/callout_component.html.erb +17 -0
  22. data/app/components/panda/core/admin/callout_component.rb +57 -0
  23. data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
  24. data/app/components/panda/core/admin/code_block_component.rb +23 -0
  25. data/app/components/panda/core/admin/container_component.html.erb +27 -0
  26. data/app/components/panda/core/admin/container_component.rb +11 -77
  27. data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
  28. data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
  29. data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
  30. data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
  31. data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
  32. data/app/components/panda/core/admin/delete_button_component.rb +41 -0
  33. data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
  34. data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
  35. data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
  36. data/app/components/panda/core/admin/dropdown_component.rb +65 -0
  37. data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
  38. data/app/components/panda/core/admin/empty_state_component.rb +25 -0
  39. data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
  40. data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
  41. data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
  42. data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
  43. data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
  44. data/app/components/panda/core/admin/flash_message_component.rb +33 -60
  45. data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
  46. data/app/components/panda/core/admin/form_error_component.rb +8 -16
  47. data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
  48. data/app/components/panda/core/admin/form_footer_component.rb +65 -0
  49. data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
  50. data/app/components/panda/core/admin/form_input_component.rb +21 -13
  51. data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
  52. data/app/components/panda/core/admin/form_section_component.rb +64 -0
  53. data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
  54. data/app/components/panda/core/admin/form_select_component.rb +25 -26
  55. data/app/components/panda/core/admin/heading_component.html.erb +26 -0
  56. data/app/components/panda/core/admin/heading_component.rb +23 -60
  57. data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
  58. data/app/components/panda/core/admin/login_form_component.rb +28 -0
  59. data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
  60. data/app/components/panda/core/admin/main_layout_component.rb +19 -0
  61. data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
  62. data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
  63. data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
  64. data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
  65. data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
  66. data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
  67. data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
  68. data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
  69. data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
  70. data/app/components/panda/core/admin/page_header_component.rb +19 -35
  71. data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
  72. data/app/components/panda/core/admin/pagination_component.rb +56 -0
  73. data/app/components/panda/core/admin/panel_component.html.erb +19 -0
  74. data/app/components/panda/core/admin/panel_component.rb +4 -29
  75. data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
  76. data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
  77. data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
  78. data/app/components/panda/core/admin/search_bar_component.rb +31 -0
  79. data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
  80. data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
  81. data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
  82. data/app/components/panda/core/admin/secret_field_component.rb +31 -0
  83. data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
  84. data/app/components/panda/core/admin/sidebar_component.rb +55 -0
  85. data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
  86. data/app/components/panda/core/admin/slideover_component.rb +31 -70
  87. data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
  88. data/app/components/panda/core/admin/statistics_component.rb +6 -8
  89. data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
  90. data/app/components/panda/core/admin/tab_bar_component.rb +32 -67
  91. data/app/components/panda/core/admin/table_component.html.erb +82 -0
  92. data/app/components/panda/core/admin/table_component.rb +54 -87
  93. data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
  94. data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
  95. data/app/components/panda/core/admin/tag_component.html.erb +1 -0
  96. data/app/components/panda/core/admin/tag_component.rb +51 -20
  97. data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
  98. data/app/components/panda/core/admin/tag_input_component.rb +22 -0
  99. data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
  100. data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
  101. data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
  102. data/app/components/panda/core/admin/user_activity_component.rb +9 -22
  103. data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
  104. data/app/components/panda/core/admin/user_display_component.rb +19 -22
  105. data/app/components/panda/core/base.rb +35 -42
  106. data/app/components/panda/core/shared/footer_component.html.erb +2 -0
  107. data/app/components/panda/core/shared/footer_component.rb +11 -0
  108. data/app/components/panda/core/shared/header_component.html.erb +32 -0
  109. data/app/components/panda/core/shared/header_component.rb +19 -0
  110. data/app/constraints/panda/core/admin_constraint.rb +2 -2
  111. data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
  112. data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
  113. data/app/controllers/panda/core/admin/base_controller.rb +62 -3
  114. data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
  115. data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
  116. data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
  117. data/app/controllers/panda/core/admin/files_controller.rb +230 -0
  118. data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
  119. data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
  120. data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
  121. data/app/controllers/panda/core/admin/search_controller.rb +15 -0
  122. data/app/controllers/panda/core/admin/sessions_controller.rb +84 -10
  123. data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
  124. data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
  125. data/app/controllers/panda/core/admin/users_controller.rb +184 -0
  126. data/app/controllers/panda/core/application_controller.rb +13 -1
  127. data/app/helpers/panda/core/active_storage_helper.rb +24 -0
  128. data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
  129. data/app/helpers/panda/core/form_helper.rb +15 -0
  130. data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
  131. data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
  132. data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
  133. data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
  134. data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
  135. data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
  136. data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
  137. data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
  138. data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
  139. data/app/javascript/panda/core/controllers/index.js +36 -0
  140. data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
  141. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
  142. data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
  143. data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
  144. data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
  145. data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
  146. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
  147. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
  148. data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
  149. data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
  150. data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
  151. data/app/models/concerns/panda/core/has_metadata.rb +164 -0
  152. data/app/models/concerns/panda/core/importable.rb +79 -0
  153. data/app/models/concerns/panda/core/searchable.rb +66 -0
  154. data/app/models/concerns/panda/core/taggable.rb +37 -0
  155. data/app/models/panda/core/feature_flag.rb +56 -0
  156. data/app/models/panda/core/file_categorization.rb +14 -0
  157. data/app/models/panda/core/file_category.rb +45 -0
  158. data/app/models/panda/core/import_session.rb +79 -0
  159. data/app/models/panda/core/presence.rb +24 -0
  160. data/app/models/panda/core/tag.rb +33 -0
  161. data/app/models/panda/core/tagging.rb +14 -0
  162. data/app/models/panda/core/user.rb +94 -16
  163. data/app/models/panda/core/user_activity.rb +42 -0
  164. data/app/models/panda/core/user_session.rb +35 -0
  165. data/app/services/panda/core/attach_avatar_service.rb +15 -3
  166. data/app/services/panda/core/csv_import_service.rb +65 -0
  167. data/app/services/panda/core/file_categorizer.rb +52 -0
  168. data/app/services/panda/core/file_parser.rb +76 -0
  169. data/app/services/panda/core/invite_user_service.rb +44 -0
  170. data/app/services/panda/core/presence_service.rb +49 -0
  171. data/app/views/layouts/panda/core/admin.html.erb +6 -76
  172. data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
  173. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
  174. data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
  175. data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
  176. data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
  177. data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
  178. data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
  179. data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
  180. data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
  181. data/app/views/panda/core/admin/files/index.html.erb +61 -0
  182. data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
  183. data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
  184. data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
  185. data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
  186. data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
  187. data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
  188. data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
  189. data/app/views/panda/core/admin/sessions/new.html.erb +1 -37
  190. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
  191. data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
  192. data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
  193. data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
  194. data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
  195. data/app/views/panda/core/admin/tags/index.html.erb +41 -0
  196. data/app/views/panda/core/admin/tags/new.html.erb +4 -0
  197. data/app/views/panda/core/admin/users/activity.html.erb +90 -0
  198. data/app/views/panda/core/admin/users/edit.html.erb +48 -0
  199. data/app/views/panda/core/admin/users/index.html.erb +161 -0
  200. data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
  201. data/app/views/panda/core/admin/users/show.html.erb +224 -0
  202. data/app/views/panda/core/shared/_footer.html.erb +1 -2
  203. data/app/views/panda/core/shared/_header.html.erb +4 -22
  204. data/config/brakeman.ignore +68 -31
  205. data/config/importmap.rb +13 -11
  206. data/config/routes.rb +49 -2
  207. data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
  208. data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
  209. data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
  210. data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
  211. data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
  212. data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
  213. data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
  214. data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
  215. data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
  216. data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
  217. data/lib/generators/panda/core/install_generator.rb +32 -0
  218. data/lib/generators/panda/core/templates/panda.rb +63 -0
  219. data/lib/panda/core/asset_loader.rb +5 -2
  220. data/lib/panda/core/authentication.rb +3 -3
  221. data/lib/panda/core/configuration.rb +195 -1
  222. data/lib/panda/core/engine/admin_controller_config.rb +3 -6
  223. data/lib/panda/core/engine/autoload_config.rb +21 -9
  224. data/lib/panda/core/engine/omniauth_config.rb +108 -38
  225. data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
  226. data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
  227. data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
  228. data/lib/panda/core/engine.rb +90 -25
  229. data/lib/panda/core/middleware.rb +35 -0
  230. data/lib/panda/core/navigation_registry.rb +455 -0
  231. data/lib/panda/core/oauth_providers.rb +27 -0
  232. data/lib/panda/core/permission_registry.rb +89 -0
  233. data/lib/panda/core/search_registry.rb +69 -0
  234. data/lib/panda/core/seeds/file_categories.rb +30 -0
  235. data/lib/panda/core/shared/inflections_config.rb +1 -5
  236. data/lib/panda/core/testing/rails_helper.rb +87 -0
  237. data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
  238. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
  239. data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
  240. data/lib/panda/core/version.rb +1 -1
  241. data/lib/panda/core/widget_registry.rb +50 -0
  242. data/lib/panda/core.rb +17 -8
  243. data/lib/tasks/panda/core/file_categories.rake +41 -0
  244. data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
  245. data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
  246. data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
  247. data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
  248. data/public/panda-core-assets/panda-core.css +2 -2
  249. data/public/panda-core-assets/vanilla-calendar.css +47 -0
  250. metadata +203 -26
  251. data/lib/panda/core/engine/inflections_config.rb +0 -23
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ module Navigation
7
+ # User profile menu component for the sidebar footer
8
+ # Displays user avatar/name with expandable menu for profile, security, and logout
9
+ class UserMenuComponent < Panda::Core::Base
10
+ BASE_CLASSES = "transition-all group flex items-center w-full gap-x-3 px-3 py-2.5 " \
11
+ "text-sm font-medium cursor-pointer"
12
+ EXPANDED_BUTTON_CLASSES = "bg-white/15 text-white rounded-t-xl"
13
+ INACTIVE_CLASSES = "text-white/80 hover:bg-white/5 rounded-xl"
14
+
15
+ renders_many :sub_items, SubItemComponent
16
+
17
+ def initialize(user:, active: false, **attrs)
18
+ @user = user
19
+ @active = active
20
+ super(**attrs)
21
+ end
22
+
23
+ attr_reader :user
24
+
25
+ def active?
26
+ @active
27
+ end
28
+
29
+ def button_classes
30
+ "#{BASE_CLASSES} #{active? ? EXPANDED_BUTTON_CLASSES : INACTIVE_CLASSES}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,28 @@
1
+ <div>
2
+ <%# Breadcrumbs section %>
3
+ <% if @breadcrumbs %>
4
+ <%= render Panda::Core::Admin::BreadcrumbComponent.new(
5
+ items: @breadcrumbs,
6
+ show_back: @show_back
7
+ ) %>
8
+ <% end %>
9
+
10
+ <%# Title and actions section %>
11
+ <div class="mt-2 md:flex md:items-center md:justify-between">
12
+ <%# Title %>
13
+ <div class="min-w-0 flex-1">
14
+ <h2 class="text-2xl/7 font-bold text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight dark:text-white">
15
+ <%= @title %>
16
+ </h2>
17
+ </div>
18
+
19
+ <%# Action buttons %>
20
+ <% if buttons.any? %>
21
+ <div class="mt-4 flex shrink-0 md:mt-0 md:ml-4">
22
+ <% buttons.each_with_index do |button_data, index| %>
23
+ <%= render create_button(button_data, index) %>
24
+ <% end %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ </div>
@@ -33,47 +33,31 @@ module Panda
33
33
  # end
34
34
  #
35
35
  class PageHeaderComponent < Panda::Core::Base
36
- prop :title, String
37
- prop :breadcrumbs, _Nilable(Array), default: -> {}
38
- prop :show_back, _Boolean, default: true
39
-
40
- def initialize(**props)
41
- super
36
+ def initialize(title: "", breadcrumbs: nil, show_back: true, **attrs, &block)
37
+ @title = title
38
+ @breadcrumbs = breadcrumbs
39
+ @show_back = show_back
42
40
  @buttons = []
41
+ @setup_block = block
42
+ super(**attrs)
43
43
  end
44
44
 
45
- def view_template(&block)
46
- # Allow buttons to be defined via block
47
- instance_eval(&block) if block_given?
48
-
49
- div do
50
- # Breadcrumbs section
51
- if @breadcrumbs
52
- render Panda::Core::Admin::BreadcrumbComponent.new(
53
- items: @breadcrumbs,
54
- show_back: @show_back
55
- )
56
- end
57
-
58
- # Title and actions section
59
- div(class: "mt-2 md:flex md:items-center md:justify-between") do
60
- # Title
61
- div(class: "min-w-0 flex-1") do
62
- h2(class: "text-2xl/7 font-bold text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight dark:text-white") do
63
- @title
64
- end
65
- end
45
+ attr_reader :title, :breadcrumbs, :show_back
66
46
 
67
- # Action buttons
68
- if @buttons.any?
69
- div(class: "mt-4 flex shrink-0 md:mt-0 md:ml-4") do
70
- @buttons.each_with_index do |button_data, index|
71
- render create_button(button_data, index)
72
- end
73
- end
74
- end
47
+ # Lazy accessor that ensures buttons are registered before returning them.
48
+ # Supports two patterns:
49
+ # 1. Block passed to new() - executed here (for tests)
50
+ # 2. Block passed to render() - executed via content (for ERB templates)
51
+ def buttons
52
+ unless @buttons_registered
53
+ if @setup_block
54
+ @setup_block.call(self)
55
+ else
56
+ content
75
57
  end
58
+ @buttons_registered = true
76
59
  end
60
+ @buttons
77
61
  end
78
62
 
79
63
  # Define a button to be rendered in the header actions area
@@ -0,0 +1,33 @@
1
+ <nav aria-label="Pagination" class="mt-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 pt-4">
2
+ <div class="text-sm text-gray-500 dark:text-gray-400">
3
+ Showing <%= first_item %> to <%= last_item %> of <%= total_count %> <%= item_name %>
4
+ </div>
5
+ <div class="flex gap-1">
6
+ <% if previous_page? %>
7
+ <%= link_to page_url(page - 1),
8
+ class: "rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800",
9
+ "aria-label": "Previous page" do %>
10
+ <i class="fa-solid fa-chevron-left"></i>
11
+ <% end %>
12
+ <% end %>
13
+
14
+ <% (1..total_pages).each do |page_num| %>
15
+ <% if page_num == page %>
16
+ <span class="rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white" aria-current="page"><%= page_num %></span>
17
+ <% elsif show_page?(page_num) %>
18
+ <%= link_to page_num, page_url(page_num),
19
+ class: "rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800" %>
20
+ <% elsif show_ellipsis?(page_num) %>
21
+ <span class="px-2 py-2 text-sm text-gray-400">&hellip;</span>
22
+ <% end %>
23
+ <% end %>
24
+
25
+ <% if next_page? %>
26
+ <%= link_to page_url(page + 1),
27
+ class: "rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800",
28
+ "aria-label": "Next page" do %>
29
+ <i class="fa-solid fa-chevron-right"></i>
30
+ <% end %>
31
+ <% end %>
32
+ </div>
33
+ </nav>
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class PaginationComponent < Panda::Core::Base
7
+ def initialize(page:, total_pages:, total_count:, per_page:, item_name: "items", **attrs)
8
+ @page = page
9
+ @total_pages = total_pages
10
+ @total_count = total_count
11
+ @per_page = per_page
12
+ @item_name = item_name
13
+ super(**attrs)
14
+ end
15
+
16
+ attr_reader :page, :total_pages, :total_count, :per_page, :item_name
17
+
18
+ def render?
19
+ total_pages > 1
20
+ end
21
+
22
+ def first_item
23
+ ((page - 1) * per_page) + 1
24
+ end
25
+
26
+ def last_item
27
+ [page * per_page, total_count].min
28
+ end
29
+
30
+ def previous_page?
31
+ page > 1
32
+ end
33
+
34
+ def next_page?
35
+ page < total_pages
36
+ end
37
+
38
+ def show_page?(page_num)
39
+ page_num == page ||
40
+ (page_num - page).abs <= 2 ||
41
+ page_num == 1 ||
42
+ page_num == total_pages
43
+ end
44
+
45
+ def show_ellipsis?(page_num)
46
+ (page_num - page).abs == 3
47
+ end
48
+
49
+ def page_url(page_num)
50
+ query = helpers.request.query_parameters.merge("page" => page_num)
51
+ "#{helpers.request.path}?#{query.to_query}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ <%# Chrome (bg/border/radius) is themeable via --panda-panel-* tokens — see
2
+ app/assets/tailwind/application.css. Fallbacks resolve to today's
3
+ bg-white/border-gray-200/rounded-2xl exactly, so this is a no-op unless a
4
+ host app sets data-theme. %>
5
+ <div class="col-span-3 mt-4 rounded-[var(--panda-panel-radius,var(--radius-2xl))] border border-[var(--panda-panel-border,var(--color-gray-200))] bg-[var(--panda-panel-bg,var(--color-white))] shadow-sm">
6
+ <% if heading_slot? %>
7
+ <div class="flex items-center text-sm font-medium px-4 py-3 text-gray-500 truncate border-b border-[var(--panda-panel-border,var(--color-gray-200))]">
8
+ <%= heading_slot %>
9
+ </div>
10
+ <% end %>
11
+
12
+ <div class="px-4 py-3 text-black">
13
+ <% if body_slot? %>
14
+ <%= body_slot %>
15
+ <% else %>
16
+ <%= content %>
17
+ <% end %>
18
+ </div>
19
+ </div>
@@ -4,37 +4,12 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class PanelComponent < Panda::Core::Base
7
- def view_template(&block)
8
- # Capture block content differently based on context (ERB vs Phlex)
9
- if block_given?
10
- if defined?(view_context) && view_context
11
- # Called from ERB - capture HTML output
12
- @body_html = view_context.capture { yield(self) }
13
- else
14
- # Called from Phlex - execute block directly to set instance variables
15
- yield(self)
16
- end
17
- end
7
+ renders_one :heading_slot
18
8
 
19
- div(class: "col-span-3 mt-5 rounded-lg shadow-md bg-gray-800 shadow-inherit/20") do
20
- @heading_content&.call
9
+ renders_one :body_slot
21
10
 
22
- div(class: "p-4 text-black bg-white rounded-b-lg") do
23
- if @body_content
24
- @body_content.call
25
- elsif @body_html
26
- raw(@body_html)
27
- end
28
- end
29
- end
30
- end
31
-
32
- def heading(**props)
33
- @heading_content = -> { render(Panda::Core::Admin::HeadingComponent.new(**props.merge(level: :panel))) }
34
- end
35
-
36
- def body(&block)
37
- @body_content = block
11
+ def initialize(**attrs)
12
+ super
38
13
  end
39
14
  end
40
15
  end
@@ -0,0 +1,6 @@
1
+ <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
2
+ <% panel.with_heading_slot { heading } %>
3
+ <div class="<%= prose_classes %>">
4
+ <%= simple_format(text) %>
5
+ </div>
6
+ <% end %>
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Renders a PanelComponent containing prose-formatted text via `simple_format`.
7
+ # Self-hides via `render?` when text is blank.
8
+ #
9
+ # Lives in panda-core because rich-text prose display inside panels is a generic
10
+ # admin pattern (notes, descriptions, agendas) used across any Panda app, not
11
+ # specific to any single domain.
12
+ #
13
+ # @example Basic usage
14
+ # render Panda::Core::Admin::ProsePanelComponent.new(heading: "Notes", text: @person.notes)
15
+ #
16
+ # @example With extra prose styling
17
+ # render Panda::Core::Admin::ProsePanelComponent.new(
18
+ # heading: "Internal Notes",
19
+ # text: @case.notes,
20
+ # prose_class: "text-gray-600"
21
+ # )
22
+ class ProsePanelComponent < Panda::Core::Base
23
+ attr_reader :heading, :text, :prose_class
24
+
25
+ def initialize(heading:, text:, prose_class: "", **attrs)
26
+ @heading = heading
27
+ @text = text
28
+ @prose_class = prose_class
29
+ super(**attrs)
30
+ end
31
+
32
+ def render?
33
+ text.present?
34
+ end
35
+
36
+ def prose_classes
37
+ base = "prose prose-sm max-w-none"
38
+ prose_class.present? ? "#{base} #{prose_class}" : base
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ <div class="relative px-2 mb-2" data-controller="global-search" data-global-search-url-value="<%= search_url %>">
2
+ <div class="relative">
3
+ <i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-white/40 text-xs pointer-events-none"></i>
4
+ <input
5
+ type="text"
6
+ placeholder="Search..."
7
+ autocomplete="off"
8
+ data-global-search-target="input"
9
+ data-action="input->global-search#onInput keydown->global-search#onInputKeydown focus->global-search#onFocus"
10
+ class="w-full rounded-lg border border-white/10 bg-white/5 pl-9 pr-12 py-2 text-sm text-white placeholder-white/40 focus:bg-white/10 focus:border-white/20 focus:outline-none focus:ring-0 transition-colors"
11
+ />
12
+ <kbd class="absolute right-2.5 top-1/2 -translate-y-1/2 text-[10px] text-white/30 border border-white/15 rounded px-1.5 py-0.5 pointer-events-none"><%= shortcut_hint %></kbd>
13
+ </div>
14
+
15
+ <div
16
+ data-global-search-target="results"
17
+ class="hidden absolute left-2 right-2 top-full mt-1 max-h-80 overflow-y-auto rounded-xl border border-white/10 bg-gray-900/95 backdrop-blur-sm shadow-xl z-50"
18
+ ></div>
19
+ </div>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class SearchBarComponent < Panda::Core::Base
7
+ def initialize(**attrs)
8
+ super
9
+ end
10
+
11
+ def render?
12
+ Panda::Core::SearchRegistry.providers.any? { |p| p[:search_class].respond_to?(:admin_search) }
13
+ end
14
+
15
+ def search_url
16
+ Panda::Core::Engine.routes.url_helpers.admin_search_path
17
+ end
18
+
19
+ def shortcut_hint
20
+ mac? ? "\u2318K" : "Ctrl+K"
21
+ end
22
+
23
+ private
24
+
25
+ def mac?
26
+ true # Safe default for shortcut display; JS handles both
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
2
+ <%= form_with url: url, method: :get, local: true, class: "flex flex-col gap-3 sm:flex-row sm:items-center w-full" do %>
3
+ <div class="flex-1 max-w-md grid grid-cols-1">
4
+ <%= text_field_tag search_name, search_value,
5
+ placeholder: search_placeholder,
6
+ class: "col-start-1 row-start-1 block w-full h-9 rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary-600 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:outline-primary-500" %>
7
+ <i class="fa-solid fa-magnifying-glass pointer-events-none col-start-1 row-start-1 ml-3 size-4 self-center text-gray-400 dark:text-gray-500"></i>
8
+ </div>
9
+
10
+ <div class="flex gap-2 items-center">
11
+ <% filters.each do |filter| %>
12
+ <%= filter %>
13
+ <% end %>
14
+
15
+ <%= submit_tag "Filter", class: "h-9 rounded-md bg-white px-3 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-600 cursor-pointer" %>
16
+
17
+ <% if show_clear %>
18
+ <%= link_to "Clear", clear_url, class: "rounded-md px-3 py-1.5 text-sm font-semibold text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" %>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
22
+ </div>
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class SearchFilterBarComponent < Panda::Core::Base
7
+ renders_many :filters
8
+
9
+ def initialize(url:, search_name: :q, search_value: nil, search_placeholder: "Search...", clear_url: nil, show_clear: false, **attrs)
10
+ @url = url
11
+ @search_name = search_name
12
+ @search_value = search_value
13
+ @search_placeholder = search_placeholder
14
+ @clear_url = clear_url || url
15
+ @show_clear = show_clear
16
+ super(**attrs)
17
+ end
18
+
19
+ attr_reader :url, :search_name, :search_value, :search_placeholder, :clear_url, :show_clear
20
+
21
+ def select_classes
22
+ "h-9 w-auto rounded-md border-0 py-1.5 pl-3 pr-10 text-sm text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 dark:bg-gray-700 dark:text-white dark:ring-gray-600"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ <div data-controller="clipboard"
2
+ data-clipboard-secret-value="<%= value %>"
3
+ data-clipboard-masked-value="<%= masked %>"
4
+ class="bg-gray-50 rounded-lg p-3 font-mono text-sm">
5
+ <div class="flex items-center gap-3">
6
+ <code data-clipboard-target="display" class="text-gray-700 break-all flex-1"><%= display_value %></code>
7
+ <button type="button"
8
+ data-action="click->clipboard#toggleReveal"
9
+ data-clipboard-target="revealButton"
10
+ class="<%= button_classes %>"
11
+ aria-label="Reveal secret value">
12
+ <i data-clipboard-target="revealIcon" class="fa-solid fa-eye"></i>
13
+ <span data-clipboard-target="revealText">Reveal</span>
14
+ </button>
15
+ <button type="button"
16
+ data-action="click->clipboard#copy"
17
+ data-clipboard-target="copyButton"
18
+ class="<%= button_classes %>"
19
+ aria-label="Copy secret value to clipboard">
20
+ <i data-clipboard-target="copyIcon" class="fa-solid fa-copy"></i>
21
+ <span data-clipboard-target="copyText">Copy</span>
22
+ </button>
23
+ </div>
24
+ </div>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class SecretFieldComponent < Panda::Core::Base
7
+ def initialize(value:, masked: true, **attrs)
8
+ @value = value
9
+ @masked = masked
10
+ super(**attrs)
11
+ end
12
+
13
+ attr_reader :value, :masked
14
+
15
+ def display_value
16
+ return value unless masked && value.present?
17
+
18
+ if value.length > 4
19
+ "\u2022" * 12 + value[-4..]
20
+ else
21
+ "\u2022" * value.length
22
+ end
23
+ end
24
+
25
+ def button_classes
26
+ "shrink-0 btn btn-secondary transition"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,160 @@
1
+ <nav <%= tag.attributes(**attrs) %>>
2
+ <ul role="list" class="flex flex-col flex-1">
3
+ <%# Logo area — uses admin_logo config or falls back to admin_title text %>
4
+ <li class="hidden lg:block mt-4 mb-4 px-2">
5
+ <% if admin_logo %>
6
+ <div class="flex items-center justify-between">
7
+ <div class="flex items-center gap-2">
8
+ <%= safe_html(admin_logo) %>
9
+ </div>
10
+ <% if admin_logo_actions.any? || admin_settings_path %>
11
+ <div class="flex items-center gap-1">
12
+ <% admin_logo_actions.each do |action| %>
13
+ <%= helpers.link_to action[:path], class: "flex items-center justify-center w-7 h-7 rounded-lg text-white/40 hover:text-white hover:bg-white/10 transition-colors", title: action[:title] do %>
14
+ <i class="<%= action[:icon] %> text-sm"></i>
15
+ <% end %>
16
+ <% end %>
17
+ <% if admin_settings_path %>
18
+ <%= helpers.link_to admin_settings_path, class: "flex items-center justify-center w-7 h-7 rounded-lg text-white/40 hover:text-white hover:bg-white/10 transition-colors", title: "Settings" do %>
19
+ <i class="fa-solid fa-gear text-sm"></i>
20
+ <% end %>
21
+ <% end %>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+ <% else %>
26
+ <a class="p-0 text-xl font-medium text-white"><%= Panda::Core.config.admin_title || "Panda Admin" %></a>
27
+ <% end %>
28
+ </li>
29
+
30
+ <%= render Panda::Core::Admin::SearchBarComponent.new %>
31
+
32
+ <%
33
+ nav_items = Panda::Core::NavigationRegistry.build(@user, helpers: helpers)
34
+
35
+ top_items = nav_items.select { |item| item[:position] != :bottom }
36
+ bottom_items = nav_items.select { |item| item[:position] == :bottom }
37
+
38
+ def collect_paths(items)
39
+ items.flat_map do |item|
40
+ paths = item[:path] ? [item[:path]] : []
41
+ paths += collect_paths(item[:children] || [])
42
+ paths
43
+ end
44
+ end
45
+
46
+ all_paths = collect_paths(nav_items).compact
47
+ current_path = helpers.request&.path || "/"
48
+ active_path = all_paths
49
+ .sort_by { |path| -path.length }
50
+ .find { |path| current_path == path || current_path.starts_with?(path + "/") }
51
+ %>
52
+
53
+ <% if sectioned?(top_items) %>
54
+ <%# Section-aware navigation — insert headings when section changes, preserving order %>
55
+ <% current_section = nil %>
56
+ <% section_count = 0 %>
57
+ <% top_items.each_with_index do |item, index| %>
58
+ <% item_section = item[:section].presence %>
59
+ <% if item_section && item_section != current_section %>
60
+ <% current_section = item_section %>
61
+ <li class="px-3 <%= section_count > 0 ? (compact_navigation? ? 'mt-3' : 'mt-5') : '' %> <%= compact_navigation? ? 'mb-0.5' : 'mb-1' %>">
62
+ <span class="text-[10px] font-bold uppercase tracking-widest text-white/40 sidebar-section-label"><%= current_section %></span>
63
+ </li>
64
+ <% section_count += 1 %>
65
+ <% end %>
66
+ <%
67
+ has_children = item[:children].present?
68
+ if has_children
69
+ is_active = item[:children].any? { |child| child[:path] == active_path }
70
+ else
71
+ is_active = item[:path] == active_path
72
+ end
73
+ %>
74
+ <%= render Panda::Core::Admin::Navigation::ItemComponent.new(
75
+ label: item[:label],
76
+ icon: item[:icon],
77
+ path: item[:path],
78
+ active: is_active,
79
+ menu_id: "sub-menu-#{section_count}-#{index}",
80
+ target: item[:target],
81
+ badge: item[:badge],
82
+ badge_color: item[:badge_color],
83
+ compact: compact_navigation?
84
+ ) do |nav_item|
85
+ if has_children
86
+ item[:children].each do |child|
87
+ child_is_active = child[:path] == active_path
88
+ nav_item.with_sub_item(label: child[:label], path: child[:path], active: child_is_active, target: child[:target])
89
+ end
90
+ end
91
+ end %>
92
+ <% end %>
93
+ <% else %>
94
+ <%# Legacy flat navigation (no section keys) %>
95
+ <% top_items.each_with_index do |item, index| %>
96
+ <%
97
+ has_children = item[:children].present?
98
+ if has_children
99
+ is_active = item[:children].any? { |child| child[:path] == active_path }
100
+ else
101
+ is_active = item[:path] == active_path
102
+ end
103
+ %>
104
+ <%= render Panda::Core::Admin::Navigation::ItemComponent.new(
105
+ label: item[:label],
106
+ icon: item[:icon],
107
+ path: item[:path],
108
+ active: is_active,
109
+ menu_id: "sub-menu-#{index}",
110
+ target: item[:target],
111
+ badge: item[:badge],
112
+ badge_color: item[:badge_color],
113
+ compact: compact_navigation?
114
+ ) do |nav_item|
115
+ if has_children
116
+ item[:children].each do |child|
117
+ child_is_active = child[:path] == active_path
118
+ nav_item.with_sub_item(label: child[:label], path: child[:path], active: child_is_active, target: child[:target])
119
+ end
120
+ end
121
+ end %>
122
+ <% end %>
123
+ <% end %>
124
+
125
+ <% bottom_items.each do |section| %>
126
+ <%
127
+ is_section_active = (section[:children] || []).any? { |child|
128
+ child[:path] && current_path.starts_with?(child[:path])
129
+ }
130
+ %>
131
+ <%= render Panda::Core::Admin::Navigation::UserMenuComponent.new(user: helpers.current_user, active: is_section_active) do |user_menu|
132
+ (section[:children] || []).each do |item|
133
+ item_path = item[:path]
134
+ if item_path.nil? && item[:path_helper]
135
+ helper_name = item[:path_helper].to_sym
136
+ item_path = helpers.panda_core.public_send(helper_name) if helpers.panda_core.respond_to?(helper_name)
137
+ end
138
+
139
+ item_active = item_path && current_path.starts_with?(item_path)
140
+ user_menu.with_sub_item(
141
+ label: item[:label],
142
+ path: item_path,
143
+ active: item_active,
144
+ method: item[:method],
145
+ button_options: item[:button_options] || {}
146
+ )
147
+ end
148
+ end %>
149
+ <% end %>
150
+
151
+ <% sidebar_footer = Panda::Core.config.admin_sidebar_footer %>
152
+ <li class="px-2 py-3 border-t border-white/10">
153
+ <% if sidebar_footer %>
154
+ <%= safe_html(sidebar_footer) %>
155
+ <% else %>
156
+ <span class="text-xs text-white/30">Panda Core v<%= Panda::Core::VERSION %></span>
157
+ <% end %>
158
+ </li>
159
+ </ul>
160
+ </nav>