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
@@ -4,95 +4,60 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class TabBarComponent < Panda::Core::Base
7
- prop :tabs, Array, default: -> { [].freeze }
8
-
9
- def view_template
10
- div(class: "mt-3 sm:mt-2") do
11
- render_mobile_select
12
- render_desktop_tabs
13
- end
7
+ def initialize(tabs: [].freeze, **attrs)
8
+ @tabs = tabs
9
+ super(**attrs)
14
10
  end
15
11
 
12
+ attr_reader :tabs
13
+
16
14
  private
17
15
 
18
- def render_mobile_select
19
- div(class: "sm:hidden") do
20
- label(for: "tabs", class: "sr-only") { "Select a tab" }
21
- select(
22
- id: "tabs",
23
- name: "tabs",
24
- class: "block py-1.5 pr-10 pl-3 w-full text-gray-900 rounded-md border-0 ring-1 ring-inset focus:ring-2 focus:ring-inset ring-mid focus:border-panda-dark focus:ring-panda-dark"
25
- ) do
26
- @tabs.each do |tab|
27
- option { tab[:name] }
28
- end
29
- end
30
- end
16
+ def any_tab_current?
17
+ @any_tab_current ||= @tabs.any? { |tab| tab[:current] }
31
18
  end
32
19
 
33
- def render_desktop_tabs
34
- div(class: "hidden sm:block") do
35
- div(class: "flex items-center border-b border-gray-200") do
36
- nav(class: "flex flex-1 -mb-px space-x-6 xl:space-x-8", aria: {label: "Tabs"}) do
37
- @tabs.each_with_index do |tab, index|
38
- render_tab(tab, index.zero?)
39
- end
40
- end
41
- render_view_toggle
42
- end
43
- end
20
+ def tab_current?(tab, index)
21
+ tab[:current] || (!any_tab_current? && index.zero?)
44
22
  end
45
23
 
46
- def render_tab(tab, is_current = false)
24
+ def tab_classes(tab, index)
47
25
  classes = "py-4 px-1 text-sm font-medium whitespace-nowrap border-b-2 "
48
- classes += if is_current || tab[:current]
49
- "border-panda-dark text-panda-dark"
26
+ classes += if tab_current?(tab, index)
27
+ "border-primary-600 text-primary-600"
50
28
  else
51
29
  "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
52
30
  end
53
-
54
- a(
55
- href: tab[:url] || "#",
56
- class: classes,
57
- aria: {current: (is_current || tab[:current]) ? "page" : nil}
58
- ) { tab[:name] }
31
+ classes
59
32
  end
60
33
 
61
- def render_view_toggle
62
- div(class: "hidden items-center p-0.5 ml-6 bg-gray-100 rounded-lg sm:flex") do
63
- render_view_button(:list)
64
- render_view_button(:grid, selected: true)
65
- end
66
- end
67
-
68
- def render_view_button(type, selected: false)
69
- button_class = "p-1.5 text-gray-400 rounded-md focus:ring-2 focus:ring-inset focus:outline-none focus:ring-panda-dark"
34
+ def view_button_classes(selected)
35
+ button_class = "p-1.5 text-gray-400 rounded-md focus:ring-2 focus:ring-inset focus:outline-none focus:ring-primary-600"
70
36
  button_class += if selected
71
37
  " ml-0.5 bg-white shadow-sm"
72
38
  else
73
39
  " hover:bg-white hover:shadow-sm"
74
40
  end
41
+ button_class
42
+ end
43
+
44
+ def list_view_icon
45
+ content_tag(:svg, class: "w-5 h-5", viewBox: "0 0 20 20", fill: "currentColor", aria: {hidden: "true"}) do
46
+ tag.path(
47
+ fill_rule: "evenodd",
48
+ d: "M2 3.75A.75.75 0 012.75 3h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.166a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z",
49
+ clip_rule: "evenodd"
50
+ )
51
+ end
52
+ end
75
53
 
76
- button(type: "button", class: button_class) do
77
- if type == :list
78
- svg(class: "w-5 h-5", viewBox: "0 0 20 20", fill: "currentColor", aria: {hidden: "true"}) do |s|
79
- s.path(
80
- fill_rule: "evenodd",
81
- d: "M2 3.75A.75.75 0 012.75 3h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.166a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z",
82
- clip_rule: "evenodd"
83
- )
84
- end
85
- span(class: "sr-only") { "Use list view" }
86
- else
87
- svg(class: "w-5 h-5", viewBox: "0 0 20 20", fill: "currentColor", aria: {hidden: "true"}) do |s|
88
- s.path(
89
- fill_rule: "evenodd",
90
- d: "M4.25 2A2.25 2.25 0 002 4.25v2.5A2.25 2.25 0 004.25 9h2.5A2.25 2.25 0 009 6.75v-2.5A2.25 2.25 0 006.75 2h-2.5zm0 9A2.25 2.25 0 002 13.25v2.5A2.25 2.25 0 004.25 18h2.5A2.25 2.25 0 009 15.75v-2.5A2.25 2.25 0 006.75 11h-2.5zm9-9A2.25 2.25 0 0011 4.25v2.5A2.25 2.25 0 0013.25 9h2.5A2.25 2.25 0 0018 6.75v-2.5A2.25 2.25 0 0015.75 2h-2.5zm0 9A2.25 2.25 0 0011 13.25v2.5A2.25 2.25 0 0013.25 18h2.5A2.25 2.25 0 0018 15.75v-2.5A2.25 2.25 0 0015.75 11h-2.5z",
91
- clip_rule: "evenodd"
92
- )
93
- end
94
- span(class: "sr-only") { "Use grid view" }
95
- end
54
+ def grid_view_icon
55
+ content_tag(:svg, class: "w-5 h-5", viewBox: "0 0 20 20", fill: "currentColor", aria: {hidden: "true"}) do
56
+ tag.path(
57
+ fill_rule: "evenodd",
58
+ d: "M4.25 2A2.25 2.25 0 002 4.25v2.5A2.25 2.25 0 004.25 9h2.5A2.25 2.25 0 009 6.75v-2.5A2.25 2.25 0 006.75 2h-2.5zm0 9A2.25 2.25 0 002 13.25v2.5A2.25 2.25 0 004.25 18h2.5A2.25 2.25 0 009 15.75v-2.5A2.25 2.25 0 006.75 11h-2.5zm9-9A2.25 2.25 0 0011 4.25v2.5A2.25 2.25 0 0013.25 9h2.5A2.25 2.25 0 0018 6.75v-2.5A2.25 2.25 0 0015.75 2h-2.5zm0 9A2.25 2.25 0 0011 13.25v2.5A2.25 2.25 0 0013.25 18h2.5A2.25 2.25 0 0018 15.75v-2.5A2.25 2.25 0 0015.75 11h-2.5z",
59
+ clip_rule: "evenodd"
60
+ )
96
61
  end
97
62
  end
98
63
  end
@@ -0,0 +1,82 @@
1
+ <%# Chrome (bg/border/radius, header/row borders, cell text colour) is
2
+ themeable via the --panda-panel-* / --panda-table-* tokens — see
3
+ app/assets/tailwind/application.css. Fallbacks resolve to today's
4
+ literal utilities exactly, so this is a no-op unless a host app sets
5
+ data-theme. %>
6
+ <% if rows.any? %>
7
+ <% if responsive? %>
8
+ <%# Mobile card view - hidden on lg screens %>
9
+ <div class="mb-12 space-y-3 lg:hidden">
10
+ <% rows.each do |row| %>
11
+ <div class="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))] p-4 hover:bg-gray-50 transition-colors" data-controller="row-link" data-action="click->row-link#click">
12
+ <% columns.each_with_index do |column, i| %>
13
+ <% cell_content = render_cell_content(row, column.cell) %>
14
+ <% if i == 0 %>
15
+ <div class="font-medium text-gray-900 text-sm mb-2"><%= cell_content %></div>
16
+ <% else %>
17
+ <% if cell_content.present? && cell_content.to_s.strip.present? %>
18
+ <div class="flex justify-between items-baseline py-1">
19
+ <span class="text-xs text-gray-400"><%= column.label %></span>
20
+ <span class="text-sm text-gray-600"><%= cell_content %></span>
21
+ </div>
22
+ <% end %>
23
+ <% end %>
24
+ <% end %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
29
+
30
+ <%# Desktop table view %>
31
+ <div class="mb-12 w-full overflow-x-auto 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))] <%= 'hidden lg:block' if responsive? %>" data-controller="row-link" data-action="click->row-link#click">
32
+ <%# Header %>
33
+ <div class="table w-full" style="table-layout: fixed;">
34
+ <div class="table-header-group">
35
+ <div class="table-row text-sm font-medium text-gray-500 truncate bg-[var(--panda-table-header-bg,var(--color-gray-50))] border-b border-[var(--panda-table-border,var(--color-gray-200))]">
36
+ <% columns.each do |column| %>
37
+ <% header_style = column.width ? "width: #{column.width};" : nil %>
38
+ <% aria_sort = if column.sortable? && sort == column.sort_key
39
+ sort_direction == "asc" ? "ascending" : "descending"
40
+ elsif column.sortable?
41
+ "none"
42
+ end %>
43
+ <div class="table-cell sticky top-0 z-10 px-4 py-3 align-middle" style="<%= header_style %>"<% if aria_sort %> aria-sort="<%= aria_sort %>"<% end %>>
44
+ <% if column.sortable? %>
45
+ <a href="<%= sort_url_for(column) %>" class="inline-flex items-center gap-1 hover:text-gray-900 transition-colors group" aria-label="Sort by <%= column.label %>">
46
+ <%= column.label %>
47
+ <% if (indicator = sort_indicator_for(column)) %>
48
+ <span class="text-gray-700 text-xs" aria-hidden="true"><%= indicator %></span>
49
+ <% else %>
50
+ <span class="text-gray-300 text-xs opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden="true">↕</span>
51
+ <% end %>
52
+ </a>
53
+ <% else %>
54
+ <%= column.label %>
55
+ <% end %>
56
+ </div>
57
+ <% end %>
58
+ </div>
59
+ </div>
60
+
61
+ <%# Rows %>
62
+ <div class="table-row-group">
63
+ <% rows.each do |row| %>
64
+ <div class="table-row odd:bg-white even:bg-gray-50/60 hover:bg-gray-100 cursor-pointer" data-<%= term %>-id="<%= row.id %>">
65
+ <% columns.each do |column| %>
66
+ <div class="table-cell px-4 py-3 text-sm align-middle break-words border-b border-[var(--panda-table-row-border,color-mix(in_srgb,var(--color-gray-200)_70%,transparent))] text-[var(--panda-table-cell-color,var(--color-gray-600))]">
67
+ <%= render_cell_content(row, column.cell) %>
68
+ </div>
69
+ <% end %>
70
+ </div>
71
+ <% end %>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ <% else %>
76
+ <%# Empty state %>
77
+ <%= render Panda::Core::Admin::EmptyStateComponent.new(
78
+ title: "No #{term.pluralize}",
79
+ description: "Get started by creating a new #{term}.",
80
+ icon: icon
81
+ ) %>
82
+ <% end %>
@@ -4,122 +4,89 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class TableComponent < Panda::Core::Base
7
- prop :term, String
8
- prop :rows, _Nilable(Object), default: -> { [] }
9
- prop :icon, String, default: ""
10
-
11
- attr_reader :columns
12
-
13
- def initialize(**props)
14
- super
7
+ attr_reader :term, :rows, :icon, :sort, :sort_direction
8
+
9
+ def initialize(term: "", rows: [], icon: "", responsive: true, sort: nil, sort_direction: nil, **attrs, &block)
10
+ @term = term
11
+ @rows = rows
12
+ @icon = icon
13
+ @responsive = responsive
14
+ @sort = sort&.to_s
15
+ @sort_direction = (sort_direction&.to_s == "desc") ? "desc" : "asc"
15
16
  @columns = []
17
+ @setup_block = block
18
+ super(**attrs)
16
19
  end
17
20
 
18
- def view_template(&block)
19
- # Capture the block to populate columns
20
- instance_eval(&block) if block_given?
21
-
22
- if @rows.any?
23
- render_table_with_rows
24
- else
25
- render_empty_table
26
- end
21
+ def responsive?
22
+ @responsive
27
23
  end
28
24
 
29
- def column(label, width: nil, &cell_block)
30
- @columns << Column.new(label, width, &cell_block)
25
+ def column(label, width: nil, sortable: false, sort_key: nil, &cell_block)
26
+ @columns << Column.new(label, width, sortable: sortable, sort_key: sort_key, &cell_block)
27
+ self # Allow chaining
31
28
  end
32
29
 
33
- private
34
-
35
- def render_table_with_rows
36
- div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark", style: "table-layout: fixed;") do
37
- render_header
38
- render_rows
39
- end
30
+ def render_cell_content(row, cell_block)
31
+ # Use capture to properly handle ERB blocks that output to the template buffer.
32
+ # Directly calling cell_block.call(row) causes double-rendering because ERB blocks
33
+ # both output to the buffer AND return their last expression.
34
+ helpers.capture(row, &cell_block)
40
35
  end
41
36
 
42
- def render_empty_table
43
- div(class: "text-center block border border-dashed py-12 rounded-lg") do
44
- i(class: "#{@icon} text-4xl text-gray-400 mb-3") if @icon.present?
45
- h3(class: "py-1 text-xl font-semibold text-gray-900") { "No #{@term.pluralize}" }
46
- p(class: "py-1 text-base text-gray-500") { "Get started by creating a new #{@term}." }
47
- end
37
+ def sort_url_for(column)
38
+ return unless column.sortable?
39
+
40
+ new_direction = (sort == column.sort_key && sort_direction == "asc") ? "desc" : "asc"
41
+ request = helpers.request
42
+ query_params = request.query_parameters.merge("sort" => column.sort_key, "direction" => new_direction)
43
+ "#{request.path}?#{query_params.to_query}"
48
44
  end
49
45
 
50
- def render_header
51
- div(class: "table-header-group") do
52
- div(class: "table-row text-base font-medium text-white bg-dark") do
53
- @columns.each_with_index do |column, i|
54
- header_classes = "table-cell sticky top-0 z-10 p-4"
55
- header_classes += " rounded-tl-md" if i.zero?
56
- header_classes += " rounded-tr-md" if i == @columns.size - 1
46
+ def sort_indicator_for(column)
47
+ return unless sort == column.sort_key
57
48
 
58
- header_style = column.width ? "width: #{column.width};" : nil
59
- div(class: header_classes, style: header_style) { column.label }
60
- end
61
- end
62
- end
49
+ (sort_direction == "asc") ? "" : "↓"
63
50
  end
64
51
 
65
- def render_rows
66
- div(class: "table-row-group") do
67
- @rows.each do |row|
68
- div(
69
- class: "table-row relative bg-gray-500/5 hover:bg-gray-500/20",
70
- data: {post_id: row.id}
71
- ) do
72
- @columns.each do |column|
73
- div(class: "table-cell py-5 px-3 h-20 text-sm align-middle whitespace-nowrap border-b border-dark/20") do
74
- # Capture the cell content by calling the block with the row
75
- render_cell_content(row, column.cell)
76
- end
77
- end
78
- end
52
+ # Lazy accessor that ensures columns are registered before returning them.
53
+ # Supports two patterns:
54
+ # 1. Block passed to new() - executed here (for tests)
55
+ # 2. Block passed to render() - executed via content (for ERB templates)
56
+ def columns
57
+ unless @columns_registered
58
+ if @setup_block
59
+ # Block was passed to new() - execute it
60
+ @setup_block.call(self)
61
+ else
62
+ # Block was passed to render() - execute via content
63
+ content
79
64
  end
65
+ @columns_registered = true
80
66
  end
67
+ @columns
81
68
  end
82
69
 
83
- def render_cell_content(row, cell_block)
84
- # When called from ERB, we need to capture the block's output buffer
85
- # When called from Phlex, evaluate directly
86
- if defined?(view_context) && view_context
87
- # Use capture to get ERB output buffer content
88
- captured_html = view_context.capture(row, &cell_block)
89
- # Render the captured HTML (already html_safe from capture)
90
- raw(captured_html)
91
- else
92
- # Pure Phlex context - execute block directly
93
- result = cell_block.call(row)
70
+ private
94
71
 
95
- # Handle different return types
96
- if result.is_a?(String)
97
- plain(result)
98
- elsif result.respond_to?(:render_in)
99
- render(result)
100
- else
101
- plain(result.to_s)
102
- end
103
- end
72
+ def pluralized_term
73
+ @pluralized_term ||= ActiveSupport::Inflector.pluralize(term)
104
74
  end
105
75
  end
106
76
 
107
77
  class Column
108
- attr_reader :label, :cell, :width
78
+ attr_reader :label, :cell, :width, :sort_key
109
79
 
110
- def initialize(label, width = nil, &block)
80
+ def initialize(label, width = nil, sortable: false, sort_key: nil, &block)
111
81
  @label = label
112
82
  @width = width
83
+ @sortable = sortable
84
+ @sort_key = sort_key || ActiveSupport::Inflector.parameterize(label.to_s, separator: "_")
113
85
  @cell = block
114
86
  end
115
- end
116
87
 
117
- class TagColumn < Column
118
- attr_reader :label, :cell
119
-
120
- def initialize(label, &block)
121
- @label = label
122
- @cell = Panda::Core::Admin::TagComponent.new(status: block)
88
+ def sortable?
89
+ @sortable
123
90
  end
124
91
  end
125
92
  end
@@ -0,0 +1,7 @@
1
+ <span class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium"
2
+ style="background-color: <%= colour %>1a; color: <%= colour %>; border: 1px solid <%= colour %>33;">
3
+ <%= tag.name %>
4
+ <% if removable %>
5
+ <button type="button" class="ml-0.5 hover:opacity-70" data-action="tag-input#removeTag" data-tag-id="<%= tag.id %>">&times;</button>
6
+ <% end %>
7
+ </span>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class TagBadgeComponent < Panda::Core::Base
7
+ attr_reader :tag, :removable
8
+
9
+ def initialize(tag:, removable: false, **attrs)
10
+ @tag = tag
11
+ @removable = removable
12
+ super(**attrs)
13
+ end
14
+
15
+ def colour
16
+ tag.display_colour
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1 @@
1
+ <%= content_tag :span, computed_text, class: tag_classes %>
@@ -4,15 +4,14 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class TagComponent < Panda::Core::Base
7
- prop :status, Symbol, default: :active
8
- prop :text, _Nilable(String), default: -> {}
9
- prop :page_type, _Nilable(Symbol), default: -> {}
10
-
11
- def view_template
12
- span(class: tag_classes) { computed_text }
7
+ def initialize(text: nil, page_type: nil, status: :active, **attrs)
8
+ @status = status
9
+ @text = text
10
+ @page_type = page_type
11
+ super(**attrs)
13
12
  end
14
13
 
15
- private
14
+ attr_reader :status, :text, :page_type
16
15
 
17
16
  def computed_text
18
17
  if @page_type
@@ -33,44 +32,76 @@ module Panda
33
32
  end
34
33
  end
35
34
 
35
+ # Badge colours are themeable via the --panda-badge-{success,error,
36
+ # warning,info,neutral}-{bg,fg} tokens (see
37
+ # app/assets/tailwind/application.css). Each helper below returns an
38
+ # arbitrary-value Tailwind utility pair whose var() fallback resolves
39
+ # to exactly today's literal emerald/amber/sky/rose/gray-100
40
+ # utility classes, so this is a no-op unless a host app sets
41
+ # data-theme.
36
42
  def tag_classes
37
- base = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
43
+ base = "inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full "
38
44
  base + (@page_type ? type_classes : status_classes)
39
45
  end
40
46
 
41
47
  def type_classes
42
48
  case @page_type
43
49
  when :system
44
- "text-red-700 bg-red-100 ring-red-600/20 dark:bg-red-400/10 dark:text-red-400"
50
+ badge_classes(:error)
45
51
  when :posts
46
- "text-purple-700 bg-purple-100 ring-purple-600/20 dark:bg-purple-400/10 dark:text-purple-400"
52
+ badge_classes(:info)
47
53
  when :code
48
- "text-blue-700 bg-blue-100 ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400"
54
+ badge_classes(:info)
49
55
  when :standard
50
- "text-green-700 bg-green-100 ring-green-600/20 dark:bg-green-400/10 dark:text-green-400"
56
+ badge_classes(:success)
51
57
  when :hidden_type
52
- "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
58
+ badge_classes(:neutral)
53
59
  else
54
- "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
60
+ badge_classes(:neutral)
55
61
  end
56
62
  end
57
63
 
58
64
  def status_classes
59
65
  case @status
60
66
  when :active
61
- "text-white ring-black/30 bg-green-600 border-0"
67
+ badge_classes(:success)
68
+ when :live
69
+ badge_classes(:success)
62
70
  when :draft
63
- "text-black ring-black/30 bg-yellow-400"
71
+ badge_classes(:warning)
64
72
  when :inactive, :hidden
65
- "text-black ring-black/30 bg-black/5 bg-white"
73
+ badge_classes(:neutral)
66
74
  when :auto
67
- "text-blue-700 bg-blue-100 ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400"
75
+ badge_classes(:info)
76
+ when :warning
77
+ badge_classes(:error)
68
78
  when :static
69
- "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
79
+ badge_classes(:neutral)
70
80
  else
71
- "text-black bg-white"
81
+ badge_classes(:neutral)
72
82
  end
73
83
  end
84
+
85
+ private
86
+
87
+ # Tailwind's content scanner extracts candidate class names by
88
+ # scanning raw source text — it does not evaluate Ruby, so every
89
+ # arbitrary-value class must appear as a complete literal string
90
+ # somewhere in this file (interpolating the tone into the class name
91
+ # would produce unscannable text like "bg-[var(--panda-badge-#{tone}
92
+ # -bg...)]" and silently generate no CSS).
93
+ BADGE_CLASSES = {
94
+ success: "text-[var(--panda-badge-success-fg,var(--color-emerald-600))] bg-[var(--panda-badge-success-bg,var(--color-emerald-50))]",
95
+ warning: "text-[var(--panda-badge-warning-fg,var(--color-amber-600))] bg-[var(--panda-badge-warning-bg,var(--color-amber-50))]",
96
+ info: "text-[var(--panda-badge-info-fg,var(--color-sky-600))] bg-[var(--panda-badge-info-bg,var(--color-sky-50))]",
97
+ error: "text-[var(--panda-badge-error-fg,var(--color-rose-600))] bg-[var(--panda-badge-error-bg,var(--color-rose-50))]",
98
+ neutral: "text-[var(--panda-badge-neutral-fg,var(--color-gray-600))] bg-[var(--panda-badge-neutral-bg,var(--color-gray-100))]"
99
+ }.freeze
100
+ private_constant :BADGE_CLASSES
101
+
102
+ def badge_classes(tone)
103
+ BADGE_CLASSES.fetch(tone)
104
+ end
74
105
  end
75
106
  end
76
107
  end
@@ -0,0 +1,37 @@
1
+ <div data-controller="tag-input"
2
+ data-tag-input-url-value="<%= tags_url %>"
3
+ data-tag-input-selected-value="<%= selected_tags_json %>"
4
+ class="relative">
5
+
6
+ <%# Selected tags as pills + hidden inputs %>
7
+ <div data-tag-input-target="selected" class="flex flex-wrap gap-1.5 mb-2">
8
+ <% selected_tags.each do |tag| %>
9
+ <span class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium"
10
+ style="background-color: <%= tag.display_colour %>1a; color: <%= tag.display_colour %>; border: 1px solid <%= tag.display_colour %>33;"
11
+ data-tag-id="<%= tag.id %>">
12
+ <%= tag.name %>
13
+ <button type="button" class="ml-0.5 hover:opacity-70" data-action="tag-input#removeTag" data-tag-id="<%= tag.id %>">&times;</button>
14
+ </span>
15
+ <% end %>
16
+ </div>
17
+
18
+ <%# Hidden inputs for form submission %>
19
+ <div data-tag-input-target="hiddenInputs">
20
+ <% selected_tags.each do |tag| %>
21
+ <input type="hidden" name="<%= field_name %>" value="<%= tag.id %>" data-tag-id="<%= tag.id %>">
22
+ <% end %>
23
+ </div>
24
+
25
+ <%# Search input %>
26
+ <input type="text"
27
+ data-tag-input-target="input"
28
+ data-action="focus->tag-input#showAll input->tag-input#search keydown->tag-input#keydown"
29
+ placeholder="Search or add tags..."
30
+ autocomplete="off"
31
+ class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary">
32
+
33
+ <%# Results dropdown %>
34
+ <div data-tag-input-target="results"
35
+ class="absolute z-10 mt-1 hidden w-full rounded-lg border border-gray-200 bg-white shadow-lg max-h-48 overflow-y-auto">
36
+ </div>
37
+ </div>
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class TagInputComponent < Panda::Core::Base
7
+ attr_reader :field_name, :tags_url, :selected_tags
8
+
9
+ def initialize(tags_url:, field_name: "tag_ids[]", selected_tags: [], **attrs)
10
+ @field_name = field_name
11
+ @tags_url = tags_url
12
+ @selected_tags = selected_tags
13
+ super(**attrs)
14
+ end
15
+
16
+ def selected_tags_json
17
+ selected_tags.map { |t| {id: t.id, name: t.name, colour: t.display_colour} }.to_json
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
2
+ <% panel.with_heading_slot { heading } %>
3
+ <div class="flex flex-wrap gap-2">
4
+ <% tags.each do |tag| %>
5
+ <%= render Panda::Core::Admin::TagBadgeComponent.new(tag: tag) %>
6
+ <% end %>
7
+ </div>
8
+ <% end %>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Renders a PanelComponent containing TagBadgeComponents for a collection of tags.
7
+ # Self-hides via `render?` when the tags collection is empty.
8
+ #
9
+ # Lives in panda-core because tags are a core Panda concept (Panda::Core::Tag)
10
+ # and this component composes two existing panda-core components (PanelComponent
11
+ # and TagBadgeComponent) into a pattern used on most admin show pages.
12
+ #
13
+ # @example Basic usage
14
+ # render Panda::Core::Admin::TagsPanelComponent.new(tags: @person.tags)
15
+ #
16
+ # @example Custom heading
17
+ # render Panda::Core::Admin::TagsPanelComponent.new(tags: @item.tags, heading: "Categories")
18
+ class TagsPanelComponent < Panda::Core::Base
19
+ attr_reader :tags, :heading
20
+
21
+ def initialize(tags:, heading: "Tags", **attrs)
22
+ @tags = tags
23
+ @heading = heading
24
+ super(**attrs)
25
+ end
26
+
27
+ def render?
28
+ tags.present? && tags.any?
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ <% if should_render? %>
2
+ <% if @user.present? && time %>
3
+ <%= render Panda::Core::Admin::UserDisplayComponent.new(
4
+ user: @user,
5
+ metadata: "#{time_ago_in_words(time)} ago"
6
+ ) %>
7
+ <% elsif @user.present? %>
8
+ <%= render Panda::Core::Admin::UserDisplayComponent.new(
9
+ user: @user,
10
+ metadata: "Not published"
11
+ ) %>
12
+ <% elsif time %>
13
+ <div class="text-black/60"><%= "#{time_ago_in_words(time)} ago" %></div>
14
+ <% end %>
15
+ <% end %>