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
@@ -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-gray-700", 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-gray-800") 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-gray-700/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 %>
@@ -4,29 +4,16 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class UserActivityComponent < Panda::Core::Base
7
- include ActionView::Helpers::DateHelper
8
-
9
- prop :model, _Nilable(Object), default: -> {}
10
- prop :at, _Nilable(Object), default: -> {}
11
- prop :user, _Nilable(Object), default: -> {}
7
+ def initialize(model: nil, at: nil, user: nil, **attrs)
8
+ @model = model
9
+ @at = at
10
+ @user = user
11
+ super(**attrs)
12
+ end
12
13
 
13
- def view_template
14
- return unless should_render?
14
+ attr_reader :model, :at, :user
15
15
 
16
- if @user.is_a?(Panda::Core::User) && time
17
- render Panda::Core::Admin::UserDisplayComponent.new(
18
- user: @user,
19
- metadata: "#{time_ago_in_words(time)} ago"
20
- )
21
- elsif @user.is_a?(Panda::Core::User)
22
- render Panda::Core::Admin::UserDisplayComponent.new(
23
- user: @user,
24
- metadata: "Not published"
25
- )
26
- elsif time
27
- div(class: "text-black/60") { "#{time_ago_in_words(time)} ago" }
28
- end
29
- end
16
+ include ActionView::Helpers::DateHelper
30
17
 
31
18
  private
32
19
 
@@ -35,7 +22,7 @@ module Panda
35
22
  end
36
23
 
37
24
  def should_render?
38
- @user.is_a?(Panda::Core::User) || time
25
+ @user.present? || time
39
26
  end
40
27
  end
41
28
  end
@@ -0,0 +1,8 @@
1
+ <% if resolved_user %>
2
+ <div class="block flex-shrink-0 group">
3
+ <div class="flex items-center">
4
+ <%= render_avatar %>
5
+ <%= render_user_info %>
6
+ </div>
7
+ </div>
8
+ <% end %>
@@ -4,21 +4,15 @@ module Panda
4
4
  module Core
5
5
  module Admin
6
6
  class UserDisplayComponent < Panda::Core::Base
7
- prop :user_id, _Nilable(String), default: -> {}
8
- prop :user, _Nilable(Object), default: -> {}
9
- prop :metadata, String, default: ""
10
-
11
- def view_template
12
- return unless resolved_user
13
-
14
- div(class: "block flex-shrink-0 group") do
15
- div(class: "flex items-center") do
16
- render_avatar
17
- render_user_info
18
- end
19
- end
7
+ def initialize(user_id: nil, user: nil, metadata: "", **attrs)
8
+ @user_id = user_id
9
+ @user = user
10
+ @metadata = metadata
11
+ super(**attrs)
20
12
  end
21
13
 
14
+ attr_reader :user_id, :user, :metadata
15
+
22
16
  private
23
17
 
24
18
  def resolved_user
@@ -34,8 +28,8 @@ module Panda
34
28
  resolved_user.avatar_url(size: :small).present?
35
29
 
36
30
  if has_image
37
- div do
38
- img(
31
+ content_tag(:div) do
32
+ tag.img(
39
33
  class: "inline-block w-10 h-10 rounded-full object-cover",
40
34
  src: resolved_user.avatar_url(size: :small),
41
35
  alt: "",
@@ -43,8 +37,8 @@ module Panda
43
37
  )
44
38
  end
45
39
  else
46
- div(class: "inline-block w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center") do
47
- span(class: "text-sm font-medium text-gray-600") { user_initials }
40
+ content_tag(:div, class: "inline-block w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center") do
41
+ content_tag(:span, user_initials, class: "text-sm font-medium text-gray-600")
48
42
  end
49
43
  end
50
44
  end
@@ -63,13 +57,16 @@ module Panda
63
57
  end
64
58
 
65
59
  def render_user_info
66
- div(class: "ml-3") do
67
- p(class: "text-sm text-black") { resolved_user.name }
68
- if @metadata.present?
69
- p(class: "text-sm text-black/60") { @metadata }
60
+ content_tag(:div, class: "ml-3") do
61
+ name_html = content_tag(:p, resolved_user.name, class: "text-sm text-black")
62
+ meta_html = if @metadata.present?
63
+ content_tag(:p, @metadata, class: "text-sm text-black/60")
70
64
  elsif resolved_user.respond_to?(:email) && resolved_user.email.present?
71
- p(class: "text-sm text-gray-500") { resolved_user.email }
65
+ content_tag(:p, resolved_user.email, class: "text-sm text-gray-500")
66
+ else
67
+ "".html_safe
72
68
  end
69
+ (name_html + meta_html).html_safe
73
70
  end
74
71
  end
75
72
  end