panda-core 0.12.2 → 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 (255) 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 +413 -131
  6. data/app/builders/panda/core/form_builder.rb +129 -27
  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 +15 -19
  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 +37 -32
  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 +34 -69
  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 +85 -8
  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 +96 -18
  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 -67
  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 -38
  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 +6 -3
  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 +4 -3
  223. data/lib/panda/core/engine/autoload_config.rb +21 -9
  224. data/lib/panda/core/engine/omniauth_config.rb +114 -32
  225. data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
  226. data/lib/panda/core/engine/route_config.rb +1 -1
  227. data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
  228. data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
  229. data/lib/panda/core/engine.rb +90 -25
  230. data/lib/panda/core/middleware.rb +35 -0
  231. data/lib/panda/core/module_registry.rb +21 -13
  232. data/lib/panda/core/navigation_registry.rb +455 -0
  233. data/lib/panda/core/oauth_providers.rb +27 -0
  234. data/lib/panda/core/permission_registry.rb +89 -0
  235. data/lib/panda/core/search_registry.rb +69 -0
  236. data/lib/panda/core/seeds/file_categories.rb +30 -0
  237. data/lib/panda/core/shared/inflections_config.rb +1 -5
  238. data/lib/panda/core/testing/rails_helper.rb +87 -0
  239. data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
  240. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
  241. data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
  242. data/lib/panda/core/version.rb +1 -1
  243. data/lib/panda/core/widget_registry.rb +50 -0
  244. data/lib/panda/core.rb +20 -0
  245. data/lib/tasks/panda/core/file_categories.rake +41 -0
  246. data/public/panda-core-assets/panda-core-0.12.2.css +2 -0
  247. data/public/panda-core-assets/panda-core-0.12.3.css +2 -0
  248. data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
  249. data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
  250. data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
  251. data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
  252. data/public/panda-core-assets/panda-core.css +2 -2
  253. data/public/panda-core-assets/vanilla-calendar.css +47 -0
  254. metadata +205 -26
  255. data/lib/panda/core/engine/inflections_config.rb +0 -23
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # A button-styled dropdown menu. Combines ButtonComponent styling with
7
+ # DropdownComponent behaviour (Stimulus `dropdown` controller).
8
+ #
9
+ # @example In a heading slot
10
+ # heading.with_dropdown_button(text: "View Board") do |db|
11
+ # db.with_item(label: "Fundraising", href: "/boards/1")
12
+ # db.with_item(label: "Events", href: "/boards/2")
13
+ # end
14
+ #
15
+ class DropdownButtonComponent < Panda::Core::Base
16
+ renders_many :items, "ItemComponent"
17
+
18
+ def initialize(text: "Options", action: :secondary, icon: nil, size: :regular, **attrs)
19
+ @text = text
20
+ @action = action
21
+ @icon = icon
22
+ @size = size
23
+ super(**attrs)
24
+ yield self if block_given?
25
+ end
26
+
27
+ attr_reader :text, :action, :icon, :size
28
+
29
+ class ItemComponent < Panda::Core::Base
30
+ def initialize(label:, href: "#", method: nil, **attrs)
31
+ @label = label
32
+ @href = href
33
+ @method = method
34
+ super(**attrs)
35
+ end
36
+
37
+ attr_reader :label, :href, :method
38
+
39
+ def call
40
+ options = {
41
+ class: "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-gray-900",
42
+ role: "menuitem",
43
+ data: {dropdown_target: "menuItem"}
44
+ }
45
+ options[:data][:turbo_method] = method if method
46
+
47
+ content_tag(:a, label, **options.merge(href: href))
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def default_attrs
54
+ {class: "relative inline-block text-left"}
55
+ end
56
+
57
+ def button_classes
58
+ base = "inline-flex items-center rounded-xl font-medium shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 cursor-pointer transition-colors "
59
+ base + size_classes + action_classes
60
+ end
61
+
62
+ def size_classes
63
+ case @size
64
+ when :small, :sm
65
+ "gap-x-1.5 px-3 py-1.5 text-xs "
66
+ when :medium, :regular, :md
67
+ "gap-x-2 px-4 py-2 text-sm "
68
+ when :large, :lg
69
+ "gap-x-2 px-5 py-2.5 text-base "
70
+ else
71
+ "gap-x-2 px-4 py-2 text-sm "
72
+ end
73
+ end
74
+
75
+ def action_classes
76
+ case @action
77
+ when :save, :create, :add, :new
78
+ "text-white bg-primary-500 hover:bg-primary-600 focus-visible:outline-primary-600"
79
+ when :save_inactive
80
+ "text-white bg-gray-300 cursor-not-allowed"
81
+ when :secondary
82
+ "text-gray-700 border border-gray-200 bg-white hover:bg-gray-50"
83
+ when :delete, :destroy, :danger
84
+ "text-error-600 border border-error-200 bg-error-50 hover:bg-error-100 focus-visible:outline-error-300"
85
+ else
86
+ "text-gray-700 border border-gray-200 bg-white hover:bg-gray-50"
87
+ end
88
+ end
89
+
90
+ def render_text
91
+ parts = []
92
+ parts << content_tag(:i, "", class: "fa-solid fa-#{@icon}") if @icon
93
+ parts << @text.titleize
94
+ parts << content_tag(:i, "", class: "fa-solid fa-chevron-down text-xs", aria: {hidden: "true"})
95
+ safe_join(parts)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,32 @@
1
+ <div <%= tag.attributes(**attrs) %>
2
+ data-controller="dropdown"
3
+ data-action="click@window->dropdown#hide keydown.escape@window->dropdown#hide">
4
+ <button type="button"
5
+ data-action="click->dropdown#toggle"
6
+ data-dropdown-target="button"
7
+ class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 focus:outline-none"
8
+ aria-haspopup="true">
9
+ <% if trigger_slot? %>
10
+ <%= trigger_slot %>
11
+ <% else %>
12
+ <%= chevron_icon %>
13
+ <% end %>
14
+ </button>
15
+
16
+ <div data-dropdown-target="menu"
17
+ class="hidden absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg bg-white shadow-sm border border-gray-200 focus:outline-none"
18
+ data-transition-enter="transition ease-out duration-100"
19
+ data-transition-enter-from="transform opacity-0 scale-95"
20
+ data-transition-enter-to="transform opacity-100 scale-100"
21
+ data-transition-leave="transition ease-in duration-75"
22
+ data-transition-leave-from="transform opacity-100 scale-100"
23
+ data-transition-leave-to="transform opacity-0 scale-95"
24
+ role="menu"
25
+ aria-orientation="vertical">
26
+ <div class="py-1" role="none">
27
+ <% items.each do |item| %>
28
+ <%= item %>
29
+ <% end %>
30
+ </div>
31
+ </div>
32
+ </div>
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Reusable dropdown menu component with a chevron trigger.
7
+ #
8
+ # Uses the pre-registered `dropdown` Stimulus controller from
9
+ # tailwindcss-stimulus-components for toggle, outside-click close,
10
+ # Escape key close, and CSS transitions.
11
+ #
12
+ # @example Basic dropdown
13
+ # <%= render Panda::Core::Admin::DropdownComponent.new do |dropdown| %>
14
+ # <% dropdown.with_item(label: "Edit", href: edit_path) %>
15
+ # <% dropdown.with_item(label: "Delete", href: delete_path, method: :delete) %>
16
+ # <% end %>
17
+ #
18
+ # @example With custom trigger content
19
+ # <%= render Panda::Core::Admin::DropdownComponent.new do |dropdown| %>
20
+ # <% dropdown.with_trigger_slot do %>
21
+ # <span class="text-xs text-gray-500">Last 30 days</span>
22
+ # <i class="fa-solid fa-chevron-down text-xs"></i>
23
+ # <% end %>
24
+ # <% dropdown.with_item(label: "Last 7 days", href: "?period=7d") %>
25
+ # <% end %>
26
+ #
27
+ class DropdownComponent < Panda::Core::Base
28
+ renders_one :trigger_slot
29
+ renders_many :items, "ItemComponent"
30
+
31
+ class ItemComponent < Panda::Core::Base
32
+ def initialize(label:, href: "#", method: nil, **attrs)
33
+ @label = label
34
+ @href = href
35
+ @method = method
36
+ super(**attrs)
37
+ end
38
+
39
+ attr_reader :label, :href, :method
40
+
41
+ def call
42
+ options = {
43
+ class: "block w-full text-left px-4 py-2 text-xs text-gray-500 hover:bg-gray-50 hover:text-gray-700",
44
+ role: "menuitem",
45
+ data: {dropdown_target: "menuItem"}
46
+ }
47
+ options[:data][:turbo_method] = method if method
48
+
49
+ content_tag(:a, label, **options.merge(href: href))
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def default_attrs
56
+ {class: "relative inline-block text-left"}
57
+ end
58
+
59
+ def chevron_icon
60
+ content_tag(:i, nil, class: "fa-solid fa-chevron-down text-xs", aria: {hidden: "true"})
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,16 @@
1
+ <%= content_tag :div, **attrs do %>
2
+ <% if icon.present? %>
3
+ <div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100 text-gray-400">
4
+ <i class="<%= icon %>"></i>
5
+ </div>
6
+ <% end %>
7
+ <h3 class="text-lg font-semibold text-gray-900"><%= title %></h3>
8
+ <% if description.present? %>
9
+ <p class="mt-2 text-sm text-gray-500"><%= description %></p>
10
+ <% end %>
11
+ <% if content? %>
12
+ <div class="mt-4 flex justify-center">
13
+ <%= content %>
14
+ </div>
15
+ <% end %>
16
+ <% end %>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Empty state component for tables and lists.
7
+ class EmptyStateComponent < Panda::Core::Base
8
+ def initialize(title:, description: nil, icon: nil, **attrs)
9
+ @title = title
10
+ @description = description
11
+ @icon = icon
12
+ super(**attrs)
13
+ end
14
+
15
+ attr_reader :title, :description, :icon
16
+
17
+ def default_attrs
18
+ {
19
+ class: "border-2 border-dashed border-gray-200 bg-white rounded-2xl px-6 py-10 text-center"
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,58 @@
1
+ <% if @files.any? %>
2
+ <%# Gallery %>
3
+ <section>
4
+ <h2 id="gallery-heading" class="sr-only">Files</h2>
5
+ <ul role="list" class="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
6
+ <% @files.each do |file| %>
7
+ <% is_selected = @selected_file && @selected_file.id == file.id %>
8
+ <li class="relative cursor-pointer">
9
+ <div class="<%= file_container_classes(is_selected) %> aspect-square">
10
+ <% if file.image? %>
11
+ <%= tag.img src: url_for(file),
12
+ alt: file.filename.to_s,
13
+ class: file_image_classes(is_selected),
14
+ onerror: "this.onerror=null;this.style.display='none';this.nextElementSibling.classList.remove('hidden')" %>
15
+ <div class="hidden flex items-center justify-center h-full bg-gray-100 dark:bg-gray-700" role="img" aria-label="Image preview not available">
16
+ <i class="fa-solid fa-file-image text-4xl text-gray-400"></i>
17
+ </div>
18
+ <% else %>
19
+ <%# File icon placeholder - implement based on file type %>
20
+ <div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-700">
21
+ <i class="fa-solid fa-file text-4xl text-gray-400"></i>
22
+ </div>
23
+ <% end %>
24
+
25
+ <%= content_tag :button,
26
+ type: "button",
27
+ class: "absolute inset-0 focus:outline-hidden",
28
+ data: {
29
+ action: "click->file-gallery#selectFile",
30
+ file_id: file.id,
31
+ file_url: url_for(file),
32
+ file_name: file.filename.to_s,
33
+ file_size: file.byte_size,
34
+ file_type: file.content_type,
35
+ file_created: file.created_at.to_s
36
+ } do %>
37
+ <span class="sr-only">View details for <%= file.filename %></span>
38
+ <% end %>
39
+ </div>
40
+
41
+ <p class="pointer-events-none mt-2 block truncate text-sm font-medium text-gray-900 dark:text-white">
42
+ <%= file.filename.to_s %>
43
+ </p>
44
+ <p class="pointer-events-none block text-sm font-medium text-gray-500 dark:text-gray-400">
45
+ <%= file_metadata(file) %>
46
+ </p>
47
+ </li>
48
+ <% end %>
49
+ </ul>
50
+ </section>
51
+ <% else %>
52
+ <%# Empty state %>
53
+ <div class="text-center py-12">
54
+ <i class="fa-solid fa-images text-6xl text-gray-400 mb-4"></i>
55
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">No files</h3>
56
+ <p class="text-sm text-gray-500 dark:text-gray-400">Upload files to see them here.</p>
57
+ </div>
58
+ <% end %>
@@ -4,75 +4,24 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class FileGalleryComponent < Panda::Core::Base
7
- prop :files, _Nilable(Object), default: -> { [] }
8
- prop :selected_file, _Nilable(Object), default: nil
9
-
10
- def view_template
11
- if @files.any?
12
- render_gallery
13
- else
14
- render_empty_state
15
- end
7
+ def initialize(selected_file: nil, files: [], blob_categories: {}, **attrs)
8
+ @files = files
9
+ @selected_file = selected_file
10
+ @blob_categories = blob_categories
11
+ super(**attrs)
16
12
  end
17
13
 
14
+ attr_reader :files, :selected_file, :blob_categories
15
+
18
16
  private
19
17
 
20
18
  def render_gallery
21
- section do
22
- h2(id: "gallery-heading", class: "sr-only") { "Files" }
23
- ul(
24
- role: "list",
25
- class: "grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8"
26
- ) do
27
- @files.each do |file|
28
- render_file_item(file)
29
- end
30
- end
31
- end
19
+ # Implemented in ERB template
32
20
  end
33
21
 
34
22
  def render_file_item(file)
35
- is_selected = @selected_file && @selected_file.id == file.id
36
-
37
- li(class: "relative") do
38
- div(
39
- class: file_container_classes(is_selected),
40
- style: "cursor: pointer;"
41
- ) do
42
- if file.image?
43
- img(
44
- src: url_for(file),
45
- alt: file.filename.to_s,
46
- class: file_image_classes(is_selected)
47
- )
48
- else
49
- render_file_icon(file)
50
- end
51
-
52
- button(
53
- type: "button",
54
- class: "absolute inset-0 focus:outline-hidden",
55
- data: {
56
- action: "click->file-gallery#selectFile",
57
- file_id: file.id,
58
- file_url: url_for(file),
59
- file_name: file.filename.to_s,
60
- file_size: file.byte_size,
61
- file_type: file.content_type,
62
- file_created: file.created_at.to_s
63
- }
64
- ) do
65
- span(class: "sr-only") { "View details for #{file.filename}" }
66
- end
67
- end
68
-
69
- p(class: "pointer-events-none mt-2 block truncate text-sm font-medium text-gray-900 dark:text-white") do
70
- plain file.filename.to_s
71
- end
72
- p(class: "pointer-events-none block text-sm font-medium text-gray-500 dark:text-gray-400") do
73
- plain number_to_human_size(file.byte_size)
74
- end
75
- end
23
+ @selected_file && @selected_file.id == file.id
24
+ # Implemented in ERB template
76
25
  end
77
26
 
78
27
  def file_container_classes(selected)
@@ -80,56 +29,33 @@ module Panda
80
29
  focus = if selected
81
30
  "outline-2 outline-offset-2 outline-panda-dark dark:outline-panda-light outline"
82
31
  else
83
- "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-indigo-600 dark:focus-within:outline-indigo-500"
32
+ "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary-600 dark:focus-within:outline-primary-500"
84
33
  end
85
34
  "#{base} #{focus}"
86
35
  end
87
36
 
88
37
  def file_image_classes(selected)
89
- base = "pointer-events-none aspect-10/7 rounded-lg object-cover outline -outline-offset-1 outline-black/5 dark:outline-white/10"
38
+ base = "pointer-events-none aspect-square object-contain p-1 outline -outline-offset-1 outline-black/5 dark:outline-white/10"
90
39
  hover = selected ? "" : "group-hover:opacity-75"
91
40
  "#{base} #{hover}"
92
41
  end
93
42
 
94
43
  def render_file_icon(file)
95
- div(class: "flex items-center justify-center h-full") do
96
- div(class: "text-center") do
97
- svg(
98
- class: "mx-auto h-12 w-12 text-gray-400",
99
- fill: "none",
100
- viewBox: "0 0 24 24",
101
- stroke: "currentColor",
102
- aria: {hidden: "true"}
103
- ) do
104
- path(
105
- stroke_linecap: "round",
106
- stroke_linejoin: "round",
107
- d: "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
108
- )
109
- end
110
- p(class: "mt-1 text-xs text-gray-500 uppercase") { file.content_type&.split("/")&.last || "file" }
111
- end
112
- end
44
+ # Implemented in ERB template
113
45
  end
114
46
 
115
47
  def render_empty_state
116
- div(class: "text-center py-12 border border-dashed rounded-lg") do
117
- svg(
118
- class: "mx-auto h-12 w-12 text-gray-400",
119
- fill: "none",
120
- viewBox: "0 0 24 24",
121
- stroke: "currentColor",
122
- aria: {hidden: "true"}
123
- ) do
124
- path(
125
- stroke_linecap: "round",
126
- stroke_linejoin: "round",
127
- d: "M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
128
- )
129
- end
130
- h3(class: "mt-2 text-sm font-semibold text-gray-900") { "No files" }
131
- p(class: "mt-1 text-sm text-gray-500") { "Get started by uploading a file." }
132
- end
48
+ # Implemented in ERB template
49
+ end
50
+
51
+ # Build the metadata subtitle for a file in the gallery grid
52
+ def file_metadata(file)
53
+ parts = []
54
+ variant_count = file.variant_records.size
55
+ parts << "#{variant_count} #{"variant".pluralize(variant_count)}" if variant_count > 0
56
+ category = @blob_categories[file.id]
57
+ parts << category.name if category
58
+ parts.any? ? parts.join(" \u00B7 ") : number_to_human_size(file.byte_size)
133
59
  end
134
60
 
135
61
  # Helper method to generate URL for ActiveStorage attachment
@@ -139,6 +65,8 @@ module Panda
139
65
  else
140
66
  "#"
141
67
  end
68
+ rescue
69
+ "#"
142
70
  end
143
71
 
144
72
  # Helper method for human-readable file sizes
@@ -0,0 +1,6 @@
1
+ <div class="flex flex-wrap gap-2 mb-4">
2
+ <%= link_to @all_label, @url_helper.call(nil), class: pill_classes(@active_value.blank?) %>
3
+ <% @items.each do |item| %>
4
+ <%= link_to item[:label], @url_helper.call(item[:value]), class: pill_classes(@active_value == item[:value]) %>
5
+ <% end %>
6
+ </div>
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class FilterPillsComponent < Panda::Core::Base
7
+ def initialize(items:, url_helper:, param_name: :category, active_value: nil, all_label: "All", **attrs)
8
+ @items = items
9
+ @url_helper = url_helper
10
+ @param_name = param_name
11
+ @active_value = active_value
12
+ @all_label = all_label
13
+ super(**attrs)
14
+ end
15
+
16
+ attr_reader :items, :url_helper, :param_name, :active_value, :all_label
17
+
18
+ def pill_classes(active)
19
+ base = "inline-flex items-center rounded-full px-3 py-1.5 text-xs font-medium"
20
+ if active
21
+ "#{base} bg-primary-100 text-primary-700"
22
+ else
23
+ "#{base} bg-gray-100 text-gray-700 hover:bg-gray-200"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,11 @@
1
+ <div aria-live="assertive" class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50">
2
+ <div class="flex w-full flex-col items-center space-y-4 sm:items-end">
3
+ <%= content_tag :div, notification_attrs do %>
4
+ <div class="flex items-center">
5
+ <%= render_icon %>
6
+ <%= render_content %>
7
+ <%= render_close_button %>
8
+ </div>
9
+ <% end %>
10
+ </div>
11
+ </div>
@@ -4,37 +4,19 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class FlashMessageComponent < Panda::Core::Base
7
- prop :message, String
8
- prop :kind, Symbol
9
- prop :temporary, _Boolean, default: true
10
- prop :subtitle, _Nilable(String), default: -> {}
11
-
12
- def view_template
13
- # Global notification container (fixed position)
14
- div(
15
- aria: {live: "assertive"},
16
- class: "pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
17
- ) do
18
- div(class: "flex w-full flex-col items-center space-y-4 sm:items-end") do
19
- # Notification panel with Tailwind Plus styling
20
- div(**notification_attrs) do
21
- div(class: "p-4") do
22
- div(class: "flex items-start") do
23
- render_icon
24
- render_content
25
- render_close_button
26
- end
27
- end
28
- end
29
- end
30
- end
7
+ def initialize(message: "", kind: :default, temporary: true, subtitle: nil, **attrs)
8
+ @message = message
9
+ @kind = kind
10
+ @temporary = temporary
11
+ @subtitle = subtitle
12
+ super(**attrs)
31
13
  end
32
14
 
33
- private
15
+ attr_reader :message, :kind, :temporary, :subtitle
34
16
 
35
17
  def notification_attrs
36
18
  {
37
- class: "pointer-events-auto w-full max-w-sm translate-y-0 transform rounded-lg bg-white opacity-100 shadow-lg transition duration-300 ease-out sm:translate-x-0 dark:bg-gray-800 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0 #{border_color_css}",
19
+ class: "pointer-events-auto w-full max-w-md translate-y-0 transform rounded-2xl border px-4 py-3 shadow-lg transition duration-300 ease-out sm:translate-x-0 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0 #{tone_classes}",
38
20
  data: {
39
21
  controller: "alert",
40
22
  alert_dismiss_after_value: (@temporary ? "5000" : nil)
@@ -43,39 +25,30 @@ module Panda
43
25
  end
44
26
 
45
27
  def render_icon
46
- div(class: "shrink-0") do
47
- i(class: "fa-solid size-6 #{icon_css} #{icon_colour_css}")
28
+ content_tag(:div, class: "shrink-0") do
29
+ content_tag(:i, "", class: "fa-solid size-5 #{icon_css} #{icon_colour_css}")
48
30
  end
49
31
  end
50
32
 
51
33
  def render_content
52
- div(class: "ml-3 w-0 flex-1 pt-0.5") do
53
- p(class: "text-sm font-medium text-gray-900 dark:text-white flash-message-title") { @message }
54
- if @subtitle
55
- p(class: "mt-1 text-sm text-gray-500 dark:text-gray-400 flash-message-subtitle") { @subtitle }
34
+ content_tag(:div, class: "ml-2 w-0 flex-1") do
35
+ message_html = content_tag(:p, @message, class: "text-sm font-medium flash-message-title")
36
+ subtitle_html = if @subtitle
37
+ content_tag(:p, @subtitle, class: "mt-1 text-xs opacity-80 flash-message-subtitle")
38
+ else
39
+ "".html_safe
56
40
  end
41
+ (message_html + subtitle_html).html_safe
57
42
  end
58
43
  end
59
44
 
60
45
  def render_close_button
61
- div(class: "ml-4 flex shrink-0") do
62
- button(
46
+ content_tag(:div, class: "ml-4 flex shrink-0") do
47
+ content_tag(:button,
63
48
  type: "button",
64
- class: "inline-flex rounded-md text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 dark:hover:text-white dark:focus:outline-blue-500",
65
- data: {action: "alert#close"}
66
- ) do
67
- span(class: "sr-only") { "Close" }
68
- svg(
69
- viewBox: "0 0 20 20",
70
- fill: "currentColor",
71
- data: {slot: "icon"},
72
- aria: {hidden: "true"},
73
- class: "size-5"
74
- ) do |s|
75
- s.path(
76
- d: "M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
77
- )
78
- end
49
+ class: "inline-flex items-center cursor-pointer opacity-60 hover:opacity-100",
50
+ data: {action: "alert#close"}) do
51
+ content_tag(:i, "", class: "fa-solid fa-xmark")
79
52
  end
80
53
  end
81
54
  end
@@ -83,30 +56,30 @@ module Panda
83
56
  def icon_colour_css
84
57
  case @kind
85
58
  when :success
86
- "text-green-400 dark:text-green-500"
59
+ "text-emerald-600"
87
60
  when :alert, :error
88
- "text-red-400 dark:text-red-500"
61
+ "text-rose-600"
89
62
  when :warning
90
- "text-yellow-400 dark:text-yellow-500"
63
+ "text-amber-600"
91
64
  when :info, :notice
92
- "text-blue-400 dark:text-blue-500"
65
+ "text-sky-600"
93
66
  else
94
- "text-gray-400 dark:text-gray-500"
67
+ "text-gray-500"
95
68
  end
96
69
  end
97
70
 
98
- def border_color_css
71
+ def tone_classes
99
72
  case @kind
100
73
  when :success
101
- "ring-2 ring-green-400/20 dark:ring-green-500/30"
74
+ "bg-emerald-50 text-emerald-700 border-emerald-200"
102
75
  when :alert, :error
103
- "ring-2 ring-red-400/20 dark:ring-red-500/30"
76
+ "bg-rose-50 text-rose-700 border-rose-200"
104
77
  when :warning
105
- "ring-2 ring-yellow-400/20 dark:ring-yellow-500/30"
78
+ "bg-amber-50 text-amber-700 border-amber-200"
106
79
  when :info, :notice
107
- "ring-2 ring-blue-400/20 dark:ring-blue-500/30"
80
+ "bg-sky-50 text-sky-700 border-sky-200"
108
81
  else
109
- "ring-1 ring-gray-400/10 dark:ring-gray-500/20"
82
+ "bg-white text-gray-700 border-gray-200"
110
83
  end
111
84
  end
112
85
 
@@ -0,0 +1,7 @@
1
+ <%= content_tag :div, **attrs do %>
2
+ <%= content_tag :div, class: "text-sm text-red-600" do %>
3
+ <% error_messages.each do |message| %>
4
+ <p><%= message %></p>
5
+ <% end %>
6
+ <% end %>
7
+ <% end %>