panda-core 0.12.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +53 -0
  4. data/app/assets/builds/panda_core_admin.css +2 -0
  5. data/app/assets/tailwind/application.css +236 -36
  6. data/app/builders/panda/core/form_builder.rb +120 -18
  7. data/app/components/panda/core/UI/badge.html.erb +10 -0
  8. data/app/components/panda/core/UI/badge.rb +14 -30
  9. data/app/components/panda/core/UI/button.html.erb +5 -0
  10. data/app/components/panda/core/UI/button.rb +11 -15
  11. data/app/components/panda/core/UI/card.html.erb +3 -0
  12. data/app/components/panda/core/UI/card.rb +7 -6
  13. data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
  14. data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
  15. data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
  16. data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
  17. data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
  18. data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
  19. data/app/components/panda/core/admin/button_component.html.erb +9 -0
  20. data/app/components/panda/core/admin/button_component.rb +36 -31
  21. data/app/components/panda/core/admin/callout_component.html.erb +17 -0
  22. data/app/components/panda/core/admin/callout_component.rb +57 -0
  23. data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
  24. data/app/components/panda/core/admin/code_block_component.rb +23 -0
  25. data/app/components/panda/core/admin/container_component.html.erb +27 -0
  26. data/app/components/panda/core/admin/container_component.rb +11 -77
  27. data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
  28. data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
  29. data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
  30. data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
  31. data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
  32. data/app/components/panda/core/admin/delete_button_component.rb +41 -0
  33. data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
  34. data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
  35. data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
  36. data/app/components/panda/core/admin/dropdown_component.rb +65 -0
  37. data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
  38. data/app/components/panda/core/admin/empty_state_component.rb +25 -0
  39. data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
  40. data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
  41. data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
  42. data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
  43. data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
  44. data/app/components/panda/core/admin/flash_message_component.rb +33 -60
  45. data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
  46. data/app/components/panda/core/admin/form_error_component.rb +8 -16
  47. data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
  48. data/app/components/panda/core/admin/form_footer_component.rb +65 -0
  49. data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
  50. data/app/components/panda/core/admin/form_input_component.rb +21 -13
  51. data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
  52. data/app/components/panda/core/admin/form_section_component.rb +64 -0
  53. data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
  54. data/app/components/panda/core/admin/form_select_component.rb +25 -26
  55. data/app/components/panda/core/admin/heading_component.html.erb +26 -0
  56. data/app/components/panda/core/admin/heading_component.rb +23 -60
  57. data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
  58. data/app/components/panda/core/admin/login_form_component.rb +28 -0
  59. data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
  60. data/app/components/panda/core/admin/main_layout_component.rb +19 -0
  61. data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
  62. data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
  63. data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
  64. data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
  65. data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
  66. data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
  67. data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
  68. data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
  69. data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
  70. data/app/components/panda/core/admin/page_header_component.rb +19 -35
  71. data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
  72. data/app/components/panda/core/admin/pagination_component.rb +56 -0
  73. data/app/components/panda/core/admin/panel_component.html.erb +19 -0
  74. data/app/components/panda/core/admin/panel_component.rb +4 -29
  75. data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
  76. data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
  77. data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
  78. data/app/components/panda/core/admin/search_bar_component.rb +31 -0
  79. data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
  80. data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
  81. data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
  82. data/app/components/panda/core/admin/secret_field_component.rb +31 -0
  83. data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
  84. data/app/components/panda/core/admin/sidebar_component.rb +55 -0
  85. data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
  86. data/app/components/panda/core/admin/slideover_component.rb +31 -70
  87. data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
  88. data/app/components/panda/core/admin/statistics_component.rb +6 -8
  89. data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
  90. data/app/components/panda/core/admin/tab_bar_component.rb +32 -67
  91. data/app/components/panda/core/admin/table_component.html.erb +82 -0
  92. data/app/components/panda/core/admin/table_component.rb +54 -87
  93. data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
  94. data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
  95. data/app/components/panda/core/admin/tag_component.html.erb +1 -0
  96. data/app/components/panda/core/admin/tag_component.rb +51 -20
  97. data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
  98. data/app/components/panda/core/admin/tag_input_component.rb +22 -0
  99. data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
  100. data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
  101. data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
  102. data/app/components/panda/core/admin/user_activity_component.rb +9 -22
  103. data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
  104. data/app/components/panda/core/admin/user_display_component.rb +19 -22
  105. data/app/components/panda/core/base.rb +35 -42
  106. data/app/components/panda/core/shared/footer_component.html.erb +2 -0
  107. data/app/components/panda/core/shared/footer_component.rb +11 -0
  108. data/app/components/panda/core/shared/header_component.html.erb +32 -0
  109. data/app/components/panda/core/shared/header_component.rb +19 -0
  110. data/app/constraints/panda/core/admin_constraint.rb +2 -2
  111. data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
  112. data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
  113. data/app/controllers/panda/core/admin/base_controller.rb +62 -3
  114. data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
  115. data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
  116. data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
  117. data/app/controllers/panda/core/admin/files_controller.rb +230 -0
  118. data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
  119. data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
  120. data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
  121. data/app/controllers/panda/core/admin/search_controller.rb +15 -0
  122. data/app/controllers/panda/core/admin/sessions_controller.rb +84 -10
  123. data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
  124. data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
  125. data/app/controllers/panda/core/admin/users_controller.rb +184 -0
  126. data/app/controllers/panda/core/application_controller.rb +13 -1
  127. data/app/helpers/panda/core/active_storage_helper.rb +24 -0
  128. data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
  129. data/app/helpers/panda/core/form_helper.rb +15 -0
  130. data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
  131. data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
  132. data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
  133. data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
  134. data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
  135. data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
  136. data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
  137. data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
  138. data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
  139. data/app/javascript/panda/core/controllers/index.js +36 -0
  140. data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
  141. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
  142. data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
  143. data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
  144. data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
  145. data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
  146. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
  147. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
  148. data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
  149. data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
  150. data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
  151. data/app/models/concerns/panda/core/has_metadata.rb +164 -0
  152. data/app/models/concerns/panda/core/importable.rb +79 -0
  153. data/app/models/concerns/panda/core/searchable.rb +66 -0
  154. data/app/models/concerns/panda/core/taggable.rb +37 -0
  155. data/app/models/panda/core/feature_flag.rb +56 -0
  156. data/app/models/panda/core/file_categorization.rb +14 -0
  157. data/app/models/panda/core/file_category.rb +45 -0
  158. data/app/models/panda/core/import_session.rb +79 -0
  159. data/app/models/panda/core/presence.rb +24 -0
  160. data/app/models/panda/core/tag.rb +33 -0
  161. data/app/models/panda/core/tagging.rb +14 -0
  162. data/app/models/panda/core/user.rb +94 -16
  163. data/app/models/panda/core/user_activity.rb +42 -0
  164. data/app/models/panda/core/user_session.rb +35 -0
  165. data/app/services/panda/core/attach_avatar_service.rb +15 -3
  166. data/app/services/panda/core/csv_import_service.rb +65 -0
  167. data/app/services/panda/core/file_categorizer.rb +52 -0
  168. data/app/services/panda/core/file_parser.rb +76 -0
  169. data/app/services/panda/core/invite_user_service.rb +44 -0
  170. data/app/services/panda/core/presence_service.rb +49 -0
  171. data/app/views/layouts/panda/core/admin.html.erb +6 -76
  172. data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
  173. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
  174. data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
  175. data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
  176. data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
  177. data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
  178. data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
  179. data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
  180. data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
  181. data/app/views/panda/core/admin/files/index.html.erb +61 -0
  182. data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
  183. data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
  184. data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
  185. data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
  186. data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
  187. data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
  188. data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
  189. data/app/views/panda/core/admin/sessions/new.html.erb +1 -37
  190. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
  191. data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
  192. data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
  193. data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
  194. data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
  195. data/app/views/panda/core/admin/tags/index.html.erb +41 -0
  196. data/app/views/panda/core/admin/tags/new.html.erb +4 -0
  197. data/app/views/panda/core/admin/users/activity.html.erb +90 -0
  198. data/app/views/panda/core/admin/users/edit.html.erb +48 -0
  199. data/app/views/panda/core/admin/users/index.html.erb +161 -0
  200. data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
  201. data/app/views/panda/core/admin/users/show.html.erb +224 -0
  202. data/app/views/panda/core/shared/_footer.html.erb +1 -2
  203. data/app/views/panda/core/shared/_header.html.erb +4 -22
  204. data/config/brakeman.ignore +68 -31
  205. data/config/importmap.rb +13 -11
  206. data/config/routes.rb +49 -2
  207. data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
  208. data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
  209. data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
  210. data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
  211. data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
  212. data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
  213. data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
  214. data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
  215. data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
  216. data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
  217. data/lib/generators/panda/core/install_generator.rb +32 -0
  218. data/lib/generators/panda/core/templates/panda.rb +63 -0
  219. data/lib/panda/core/asset_loader.rb +5 -2
  220. data/lib/panda/core/authentication.rb +3 -3
  221. data/lib/panda/core/configuration.rb +195 -1
  222. data/lib/panda/core/engine/admin_controller_config.rb +3 -6
  223. data/lib/panda/core/engine/autoload_config.rb +21 -9
  224. data/lib/panda/core/engine/omniauth_config.rb +108 -38
  225. data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
  226. data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
  227. data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
  228. data/lib/panda/core/engine.rb +90 -25
  229. data/lib/panda/core/middleware.rb +35 -0
  230. data/lib/panda/core/navigation_registry.rb +455 -0
  231. data/lib/panda/core/oauth_providers.rb +27 -0
  232. data/lib/panda/core/permission_registry.rb +89 -0
  233. data/lib/panda/core/search_registry.rb +69 -0
  234. data/lib/panda/core/seeds/file_categories.rb +30 -0
  235. data/lib/panda/core/shared/inflections_config.rb +1 -5
  236. data/lib/panda/core/testing/rails_helper.rb +87 -0
  237. data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
  238. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
  239. data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
  240. data/lib/panda/core/version.rb +1 -1
  241. data/lib/panda/core/widget_registry.rb +50 -0
  242. data/lib/panda/core.rb +17 -8
  243. data/lib/tasks/panda/core/file_categories.rake +41 -0
  244. data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
  245. data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
  246. data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
  247. data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
  248. data/public/panda-core-assets/panda-core.css +2 -2
  249. data/public/panda-core-assets/vanilla-calendar.css +47 -0
  250. metadata +203 -26
  251. data/lib/panda/core/engine/inflections_config.rb +0 -23
@@ -0,0 +1,256 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["select"]
5
+ static values = { open: { type: Boolean, default: false }, index: { type: Number, default: -1 } }
6
+
7
+ connect() {
8
+ this.element.classList.add("relative")
9
+ this.selectTarget.classList.add("sr-only")
10
+ this.options = Array.from(this.selectTarget.options)
11
+ this.buildUI()
12
+ this.handleOutsideClick = this.handleOutsideClick.bind(this)
13
+ document.addEventListener("click", this.handleOutsideClick)
14
+ }
15
+
16
+ disconnect() {
17
+ document.removeEventListener("click", this.handleOutsideClick)
18
+ }
19
+
20
+ buildUI() {
21
+ // Find the selected option
22
+ const selectedOption = this.options.find(o => o.selected) || this.options[0]
23
+ const isPlaceholder = selectedOption && (selectedOption.value === "" || selectedOption.disabled)
24
+
25
+ // Build trigger button
26
+ this.trigger = document.createElement("button")
27
+ this.trigger.type = "button"
28
+ this.trigger.setAttribute("role", "combobox")
29
+ this.trigger.setAttribute("aria-expanded", "false")
30
+ this.trigger.setAttribute("aria-haspopup", "listbox")
31
+ // Border/radius are themeable via --panda-select-trigger-border /
32
+ // --panda-input-radius (see app/assets/tailwind/application.css); the
33
+ // var() fallbacks resolve to today's rounded-xl/border-gray-200 exactly,
34
+ // so this is a no-op unless a host app sets data-theme.
35
+ this.trigger.className = "flex items-center justify-between w-full h-11 rounded-[var(--panda-input-radius,var(--radius-xl))] border border-[var(--panda-select-trigger-border,var(--color-gray-200))] bg-white px-3 py-2 text-sm text-left focus:border-transparent focus:ring-2 focus:ring-primary-500 cursor-pointer"
36
+ this.trigger.innerHTML = `
37
+ <span class="truncate ${isPlaceholder ? "text-gray-400" : "text-gray-900"}">${this.escapeHtml(selectedOption ? selectedOption.text : "")}</span>
38
+ <svg class="w-4 h-4 text-gray-400 shrink-0 ml-2 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
40
+ </svg>
41
+ `
42
+ this.trigger.addEventListener("click", (e) => {
43
+ e.preventDefault()
44
+ this.toggleDropdown()
45
+ })
46
+ this.trigger.addEventListener("keydown", (e) => this.handleTriggerKeydown(e))
47
+
48
+ // Build listbox
49
+ this.listbox = document.createElement("ul")
50
+ this.listbox.setAttribute("role", "listbox")
51
+ this.listbox.setAttribute("tabindex", "-1")
52
+ this.listbox.className = "absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white shadow-lg py-1 text-sm focus:outline-none hidden"
53
+ this.listbox.id = `custom-select-listbox-${this.selectTarget.id || Math.random().toString(36).slice(2)}`
54
+ this.trigger.setAttribute("aria-controls", this.listbox.id)
55
+
56
+ this.options.forEach((option, i) => {
57
+ const li = document.createElement("li")
58
+ li.setAttribute("role", "option")
59
+ li.setAttribute("aria-selected", option.selected ? "true" : "false")
60
+ li.setAttribute("data-index", i)
61
+ li.setAttribute("data-value", option.value)
62
+ li.id = `${this.listbox.id}-option-${i}`
63
+
64
+ const isBlank = option.value === "" || option.disabled
65
+ li.className = `relative cursor-pointer select-none px-3 py-2 flex items-center justify-between ${
66
+ option.selected && !isBlank ? "bg-primary-50 text-primary-800" : "text-gray-900"
67
+ } ${isBlank ? "text-gray-400" : ""}`
68
+
69
+ li.innerHTML = `
70
+ <span class="block truncate">${this.escapeHtml(option.text)}</span>
71
+ ${option.selected && !isBlank ? '<i class="fa-solid fa-check text-primary-600 text-xs"></i>' : ""}
72
+ `
73
+
74
+ li.addEventListener("click", () => this.selectOption(i))
75
+ li.addEventListener("mouseenter", () => this.highlightOption(i))
76
+ this.listbox.appendChild(li)
77
+ })
78
+
79
+ this.listbox.addEventListener("keydown", (e) => this.handleListboxKeydown(e))
80
+
81
+ // Insert after the native select
82
+ this.selectTarget.parentNode.insertBefore(this.trigger, this.selectTarget.nextSibling)
83
+ this.selectTarget.parentNode.appendChild(this.listbox)
84
+
85
+ // Remove any existing SVG chevron (from the FormBuilder select_svg helper)
86
+ const existingSvg = this.element.querySelector("svg.pointer-events-none")
87
+ if (existingSvg) existingSvg.remove()
88
+ }
89
+
90
+ toggleDropdown() {
91
+ this.openValue = !this.openValue
92
+ this.renderDropdown()
93
+
94
+ if (this.openValue) {
95
+ // Highlight the currently selected option
96
+ const selectedIdx = this.options.findIndex(o => o.selected)
97
+ if (selectedIdx >= 0) {
98
+ this.indexValue = selectedIdx
99
+ this.highlightOption(selectedIdx)
100
+ }
101
+ this.listbox.focus()
102
+ }
103
+ }
104
+
105
+ renderDropdown() {
106
+ if (this.openValue) {
107
+ this.listbox.classList.remove("hidden")
108
+ this.trigger.setAttribute("aria-expanded", "true")
109
+ this.trigger.querySelector("svg").style.transform = "rotate(180deg)"
110
+ } else {
111
+ this.listbox.classList.add("hidden")
112
+ this.trigger.setAttribute("aria-expanded", "false")
113
+ this.trigger.querySelector("svg").style.transform = ""
114
+ this.indexValue = -1
115
+ }
116
+ }
117
+
118
+ selectOption(index) {
119
+ const option = this.options[index]
120
+ if (!option) return
121
+
122
+ // Update native select
123
+ this.selectTarget.value = option.value
124
+ this.selectTarget.dispatchEvent(new Event("change", { bubbles: true }))
125
+
126
+ // Update trigger text
127
+ const isPlaceholder = option.value === "" || option.disabled
128
+ const triggerText = this.trigger.querySelector("span")
129
+ triggerText.textContent = option.text
130
+ triggerText.className = `truncate ${isPlaceholder ? "text-gray-400" : "text-gray-900"}`
131
+
132
+ // Update option styles and aria
133
+ const items = this.listbox.querySelectorAll("[role='option']")
134
+ items.forEach((li, i) => {
135
+ const opt = this.options[i]
136
+ const isBlank = opt.value === "" || opt.disabled
137
+ const isSelected = i === index
138
+ li.setAttribute("aria-selected", isSelected ? "true" : "false")
139
+ li.className = `relative cursor-pointer select-none px-3 py-2 flex items-center justify-between ${
140
+ isSelected && !isBlank ? "bg-primary-50 text-primary-800" : "text-gray-900"
141
+ } ${isBlank ? "text-gray-400" : ""}`
142
+ li.innerHTML = `
143
+ <span class="block truncate">${this.escapeHtml(opt.text)}</span>
144
+ ${isSelected && !isBlank ? '<i class="fa-solid fa-check text-primary-600 text-xs"></i>' : ""}
145
+ `
146
+ })
147
+
148
+ // Close
149
+ this.openValue = false
150
+ this.renderDropdown()
151
+ this.trigger.focus()
152
+ }
153
+
154
+ highlightOption(index) {
155
+ this.indexValue = index
156
+ const items = this.listbox.querySelectorAll("[role='option']")
157
+ items.forEach((li, i) => {
158
+ if (i === index) {
159
+ li.classList.add("bg-primary-100")
160
+ li.scrollIntoView({ block: "nearest" })
161
+ this.trigger.setAttribute("aria-activedescendant", li.id)
162
+ } else {
163
+ li.classList.remove("bg-primary-100")
164
+ }
165
+ })
166
+ }
167
+
168
+ handleTriggerKeydown(event) {
169
+ switch (event.key) {
170
+ case "ArrowDown":
171
+ case "ArrowUp":
172
+ case "Enter":
173
+ case " ":
174
+ event.preventDefault()
175
+ if (!this.openValue) {
176
+ this.openValue = true
177
+ this.renderDropdown()
178
+ const selectedIdx = this.options.findIndex(o => o.selected)
179
+ this.highlightOption(selectedIdx >= 0 ? selectedIdx : 0)
180
+ this.listbox.focus()
181
+ }
182
+ break
183
+ case "Escape":
184
+ if (this.openValue) {
185
+ event.preventDefault()
186
+ this.openValue = false
187
+ this.renderDropdown()
188
+ }
189
+ break
190
+ }
191
+ }
192
+
193
+ handleListboxKeydown(event) {
194
+ const maxIndex = this.options.length - 1
195
+
196
+ switch (event.key) {
197
+ case "ArrowDown":
198
+ event.preventDefault()
199
+ this.highlightOption(Math.min(this.indexValue + 1, maxIndex))
200
+ break
201
+ case "ArrowUp":
202
+ event.preventDefault()
203
+ this.highlightOption(Math.max(this.indexValue - 1, 0))
204
+ break
205
+ case "Enter":
206
+ case " ":
207
+ event.preventDefault()
208
+ if (this.indexValue >= 0) {
209
+ this.selectOption(this.indexValue)
210
+ }
211
+ break
212
+ case "Escape":
213
+ event.preventDefault()
214
+ this.openValue = false
215
+ this.renderDropdown()
216
+ this.trigger.focus()
217
+ break
218
+ case "Tab":
219
+ this.openValue = false
220
+ this.renderDropdown()
221
+ break
222
+ default:
223
+ // Type-ahead: jump to first option starting with typed character
224
+ if (event.key.length === 1) {
225
+ const char = event.key.toLowerCase()
226
+ const startIdx = this.indexValue + 1
227
+ const match = this.options.findIndex((o, i) =>
228
+ i >= startIdx && o.text.toLowerCase().startsWith(char)
229
+ )
230
+ if (match >= 0) {
231
+ this.highlightOption(match)
232
+ } else {
233
+ // Wrap around
234
+ const wrapMatch = this.options.findIndex(o =>
235
+ o.text.toLowerCase().startsWith(char)
236
+ )
237
+ if (wrapMatch >= 0) this.highlightOption(wrapMatch)
238
+ }
239
+ }
240
+ break
241
+ }
242
+ }
243
+
244
+ handleOutsideClick(event) {
245
+ if (this.openValue && !this.element.contains(event.target)) {
246
+ this.openValue = false
247
+ this.renderDropdown()
248
+ }
249
+ }
250
+
251
+ escapeHtml(text) {
252
+ const div = document.createElement("div")
253
+ div.textContent = text
254
+ return div.innerHTML
255
+ }
256
+ }
@@ -0,0 +1,121 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Calendar } from "vanilla-calendar-pro"
3
+
4
+ export default class extends Controller {
5
+ static targets = ["display", "hidden", "calendar"]
6
+ static values = {
7
+ dateMin: String,
8
+ dateMax: String
9
+ }
10
+
11
+ connect() {
12
+ this.isOpen = false
13
+ this.calendar = null
14
+ }
15
+
16
+ disconnect() {
17
+ document.removeEventListener("click", this.outsideClick)
18
+ document.removeEventListener("keydown", this.handleKeydown)
19
+ if (this.calendar) this.calendar.destroy()
20
+ }
21
+
22
+ toggle(event) {
23
+ event.stopPropagation()
24
+ if (this.isOpen) {
25
+ this.close()
26
+ } else {
27
+ this.open()
28
+ }
29
+ }
30
+
31
+ open() {
32
+ if (this.isOpen) return
33
+
34
+ const options = {
35
+ type: "default",
36
+ settings: {
37
+ visibility: {
38
+ theme: "light"
39
+ },
40
+ iso8601: true,
41
+ selection: {
42
+ day: "single"
43
+ }
44
+ },
45
+ actions: {
46
+ clickDay: (event, self) => {
47
+ this.handleDateSelect(self)
48
+ }
49
+ }
50
+ }
51
+
52
+ if (this.hasDateMinValue) {
53
+ options.settings.range = options.settings.range || {}
54
+ options.settings.range.min = this.dateMinValue
55
+ }
56
+ if (this.hasDateMaxValue) {
57
+ options.settings.range = options.settings.range || {}
58
+ options.settings.range.max = this.dateMaxValue
59
+ }
60
+
61
+ // Set initial selected date from hidden field value
62
+ const currentValue = this.hiddenTarget.value
63
+ if (currentValue && /^\d{4}-\d{2}-\d{2}$/.test(currentValue)) {
64
+ options.selectedDates = [currentValue]
65
+ options.selectedMonth = parseInt(currentValue.split("-")[1], 10) - 1
66
+ options.selectedYear = parseInt(currentValue.split("-")[0], 10)
67
+ }
68
+
69
+ this.calendarTarget.innerHTML = ""
70
+ this.calendarTarget.classList.remove("hidden")
71
+
72
+ this.calendar = new Calendar(this.calendarTarget, options)
73
+ this.calendar.init()
74
+ this.isOpen = true
75
+
76
+ document.addEventListener("click", this.outsideClick)
77
+ document.addEventListener("keydown", this.handleKeydown)
78
+ }
79
+
80
+ close() {
81
+ if (!this.isOpen) return
82
+ this.calendarTarget.classList.add("hidden")
83
+ if (this.calendar) {
84
+ this.calendar.destroy()
85
+ this.calendar = null
86
+ }
87
+ this.isOpen = false
88
+ document.removeEventListener("click", this.outsideClick)
89
+ document.removeEventListener("keydown", this.handleKeydown)
90
+ }
91
+
92
+ handleDateSelect(calendarInstance) {
93
+ const selectedDates = calendarInstance.context.selectedDates
94
+ if (selectedDates && selectedDates.length > 0) {
95
+ const isoDate = selectedDates[selectedDates.length - 1]
96
+ this.hiddenTarget.value = isoDate
97
+ this.displayTarget.value = this.formatDate(isoDate)
98
+ this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true }))
99
+ this.close()
100
+ }
101
+ }
102
+
103
+ outsideClick = (event) => {
104
+ if (this.isOpen && !this.element.contains(event.target)) {
105
+ this.close()
106
+ }
107
+ }
108
+
109
+ handleKeydown = (event) => {
110
+ if (event.key === "Escape" && this.isOpen) {
111
+ this.close()
112
+ this.displayTarget.focus()
113
+ }
114
+ }
115
+
116
+ formatDate(isoDate) {
117
+ const [year, month, day] = isoDate.split("-")
118
+ const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
119
+ return date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
120
+ }
121
+ }
@@ -0,0 +1,193 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Calendar } from "vanilla-calendar-pro"
3
+
4
+ export default class extends Controller {
5
+ static targets = ["startInput", "endInput", "calendar"]
6
+ static values = {
7
+ dateMin: String,
8
+ dateMax: String
9
+ }
10
+
11
+ connect() {
12
+ this.isOpen = false
13
+ this.calendar = null
14
+ this.activeInput = null
15
+
16
+ this.startInputTarget.readOnly = true
17
+ this.endInputTarget.readOnly = true
18
+ this.startInputTarget.style.cursor = "pointer"
19
+ this.endInputTarget.style.cursor = "pointer"
20
+
21
+ this.startInputTarget.addEventListener("click", this.openFromStart)
22
+ this.startInputTarget.addEventListener("focus", this.openFromStart)
23
+ this.endInputTarget.addEventListener("click", this.openFromEnd)
24
+ this.endInputTarget.addEventListener("focus", this.openFromEnd)
25
+ document.addEventListener("click", this.outsideClick)
26
+ document.addEventListener("keydown", this.handleKeydown)
27
+ }
28
+
29
+ disconnect() {
30
+ this.startInputTarget.removeEventListener("click", this.openFromStart)
31
+ this.startInputTarget.removeEventListener("focus", this.openFromStart)
32
+ this.endInputTarget.removeEventListener("click", this.openFromEnd)
33
+ this.endInputTarget.removeEventListener("focus", this.openFromEnd)
34
+ document.removeEventListener("click", this.outsideClick)
35
+ document.removeEventListener("keydown", this.handleKeydown)
36
+ if (this.calendar) this.calendar.destroy()
37
+ }
38
+
39
+ openFromStart = (event) => {
40
+ event.stopPropagation()
41
+ this.activeInput = "start"
42
+ if (this.isOpen) {
43
+ this.close()
44
+ } else {
45
+ this.open()
46
+ }
47
+ }
48
+
49
+ openFromEnd = (event) => {
50
+ event.stopPropagation()
51
+ this.activeInput = "end"
52
+ if (this.isOpen) {
53
+ this.close()
54
+ } else {
55
+ this.open()
56
+ }
57
+ }
58
+
59
+ open() {
60
+ if (this.isOpen) return
61
+
62
+ const options = {
63
+ type: "default",
64
+ settings: {
65
+ visibility: {
66
+ theme: "light",
67
+ positionToInput: "auto"
68
+ },
69
+ iso8601: true,
70
+ selection: {
71
+ day: "multiple-ranged"
72
+ }
73
+ },
74
+ actions: {
75
+ clickDay: (event, self) => {
76
+ this.handleRangeSelect(self)
77
+ }
78
+ }
79
+ }
80
+
81
+ if (this.hasDateMinValue) {
82
+ options.settings.range = options.settings.range || {}
83
+ options.settings.range.min = this.dateMinValue
84
+ }
85
+ if (this.hasDateMaxValue) {
86
+ options.settings.range = options.settings.range || {}
87
+ options.settings.range.max = this.dateMaxValue
88
+ }
89
+
90
+ // Set initial selected range from input values
91
+ const startDate = this.parseInputDate(this.startInputTarget.value)
92
+ const endDate = this.parseInputDate(this.endInputTarget.value)
93
+ if (startDate && endDate) {
94
+ options.selectedDates = this.getDateRange(startDate, endDate)
95
+ options.selectedMonth = parseInt(startDate.split("-")[1], 10) - 1
96
+ options.selectedYear = parseInt(startDate.split("-")[0], 10)
97
+ } else if (startDate) {
98
+ options.selectedDates = [startDate]
99
+ options.selectedMonth = parseInt(startDate.split("-")[1], 10) - 1
100
+ options.selectedYear = parseInt(startDate.split("-")[0], 10)
101
+ }
102
+
103
+ this.calendarTarget.innerHTML = ""
104
+ this.calendarTarget.classList.remove("hidden")
105
+
106
+ this.calendar = new Calendar(this.calendarTarget, options)
107
+ this.calendar.init()
108
+ this.isOpen = true
109
+ }
110
+
111
+ close() {
112
+ if (!this.isOpen) return
113
+ this.calendarTarget.classList.add("hidden")
114
+ if (this.calendar) {
115
+ this.calendar.destroy()
116
+ this.calendar = null
117
+ }
118
+ this.isOpen = false
119
+ }
120
+
121
+ handleRangeSelect(calendarInstance) {
122
+ const selectedDates = calendarInstance.context.selectedDates
123
+ if (selectedDates && selectedDates.length > 0) {
124
+ const sorted = [...selectedDates].sort()
125
+ const startDate = sorted[0]
126
+ const endDate = sorted[sorted.length - 1]
127
+
128
+ this.startInputTarget.value = this.formatDate(startDate)
129
+ this.endInputTarget.value = this.formatDate(endDate)
130
+
131
+ this.startInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
132
+ this.endInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
133
+
134
+ // Close after selecting end of range (2+ dates selected)
135
+ if (selectedDates.length >= 2) {
136
+ this.close()
137
+ }
138
+ }
139
+ }
140
+
141
+ outsideClick = (event) => {
142
+ if (this.isOpen && !this.element.contains(event.target)) {
143
+ this.close()
144
+ }
145
+ }
146
+
147
+ handleKeydown = (event) => {
148
+ if (event.key === "Escape" && this.isOpen) {
149
+ this.close()
150
+ if (this.activeInput === "end") {
151
+ this.endInputTarget.focus()
152
+ } else {
153
+ this.startInputTarget.focus()
154
+ }
155
+ }
156
+ }
157
+
158
+ getDateRange(start, end) {
159
+ const dates = []
160
+ const current = new Date(start)
161
+ const endDate = new Date(end)
162
+ while (current <= endDate) {
163
+ dates.push(current.toISOString().split("T")[0])
164
+ current.setDate(current.getDate() + 1)
165
+ }
166
+ return dates
167
+ }
168
+
169
+ parseInputDate(value) {
170
+ if (!value) return null
171
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value
172
+
173
+ const ukMatch = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
174
+ if (ukMatch) {
175
+ return `${ukMatch[3]}-${ukMatch[2].padStart(2, "0")}-${ukMatch[1].padStart(2, "0")}`
176
+ }
177
+
178
+ const months = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
179
+ Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" }
180
+ const namedMatch = value.match(/^(\d{1,2})\s+(\w{3})\s+(\d{4})$/)
181
+ if (namedMatch && months[namedMatch[2]]) {
182
+ return `${namedMatch[3]}-${months[namedMatch[2]]}-${namedMatch[1].padStart(2, "0")}`
183
+ }
184
+
185
+ return null
186
+ }
187
+
188
+ formatDate(isoDate) {
189
+ const [year, month, day] = isoDate.split("-")
190
+ const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
191
+ return date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
192
+ }
193
+ }
@@ -0,0 +1,134 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['uploadBackdrop', 'uploadPanel']
5
+ static values = { filesPath: String }
6
+
7
+ connect() {
8
+ this._handleFilenameInput = this.handleFilenameInput.bind(this)
9
+ this._handleFilenameBlur = this.handleFilenameBlur.bind(this)
10
+ document.addEventListener('input', this._handleFilenameInput)
11
+ document.addEventListener('focusout', this._handleFilenameBlur)
12
+ }
13
+
14
+ disconnect() {
15
+ document.removeEventListener('input', this._handleFilenameInput)
16
+ document.removeEventListener('focusout', this._handleFilenameBlur)
17
+ }
18
+
19
+ handleFilenameInput(event) {
20
+ if (event.target.id !== 'blob_filename') return
21
+ const { selectionStart } = event.target
22
+ event.target.value = event.target.value.replace(/\s/g, '_').replace(/[^a-z0-9_-]/gi, '')
23
+ event.target.setSelectionRange(selectionStart, selectionStart)
24
+ }
25
+
26
+ handleFilenameBlur(event) {
27
+ if (event.target.id !== 'blob_filename') return
28
+ event.target.value = event.target.value.toLowerCase()
29
+ }
30
+
31
+ selectFile(event) {
32
+ const button = event.currentTarget
33
+ const fileId = button.dataset.fileId
34
+
35
+ this.selectedFileId = fileId
36
+ this.updateSelectedState(button)
37
+ this.loadFileDetails(fileId)
38
+ }
39
+
40
+ async loadFileDetails(fileId) {
41
+ const slideoverContent = document.getElementById('file-gallery-slideover-content')
42
+ if (!slideoverContent) return
43
+
44
+ // Show loading state
45
+ slideoverContent.innerHTML =
46
+ '<div class="flex items-center justify-center py-12"><i class="fa-solid fa-spinner fa-spin text-2xl text-gray-400"></i></div>'
47
+ this.showSlideover()
48
+
49
+ try {
50
+ const response = await fetch(`${this.filesPathValue}/${fileId}`, {
51
+ headers: {
52
+ Accept: 'text/html',
53
+ 'X-Requested-With': 'XMLHttpRequest',
54
+ },
55
+ })
56
+
57
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
58
+
59
+ slideoverContent.innerHTML = await response.text()
60
+ } catch {
61
+ slideoverContent.innerHTML =
62
+ '<div class="text-center py-12"><i class="fa-solid fa-exclamation-triangle text-2xl text-red-400 mb-2"></i><p class="text-sm text-gray-500">Failed to load file details</p></div>'
63
+ }
64
+ }
65
+
66
+ showSlideover() {
67
+ // Toggle controller is now a descendant (inside ContainerComponent)
68
+ const toggleElement = this.element.querySelector('[data-controller*="toggle"]')
69
+ if (!toggleElement) return
70
+
71
+ const toggleController = this.application.getControllerForElementAndIdentifier(
72
+ toggleElement,
73
+ 'toggle'
74
+ )
75
+ if (toggleController) {
76
+ toggleController.show()
77
+ }
78
+ }
79
+
80
+ reopenSlideover() {
81
+ if (this.selectedFileId) {
82
+ this.loadFileDetails(this.selectedFileId)
83
+ }
84
+ }
85
+
86
+ openUpload() {
87
+ this.uploadBackdropTarget.classList.remove('hidden')
88
+ this.uploadPanelTarget.classList.remove('hidden')
89
+ }
90
+
91
+ closeUpload() {
92
+ this.uploadBackdropTarget.classList.add('hidden')
93
+ this.uploadPanelTarget.classList.add('hidden')
94
+ }
95
+
96
+ updateSelectedState(selectedButton) {
97
+ // Remove selected state from all file items
98
+ const allFileItems = this.element.querySelectorAll('[data-action*="file-gallery#selectFile"]')
99
+ allFileItems.forEach((button) => {
100
+ const container = button.closest('.relative').querySelector('.group')
101
+ if (container) {
102
+ container.classList.remove(
103
+ 'outline',
104
+ 'outline-2',
105
+ 'outline-offset-2',
106
+ 'outline-panda-dark',
107
+ 'dark:outline-panda-light'
108
+ )
109
+ container.classList.add(
110
+ 'focus-within:outline-2',
111
+ 'focus-within:outline-offset-2',
112
+ 'focus-within:outline-primary-600'
113
+ )
114
+ }
115
+ })
116
+
117
+ // Add selected state to clicked item
118
+ const container = selectedButton.closest('.relative').querySelector('.group')
119
+ if (container) {
120
+ container.classList.add(
121
+ 'outline',
122
+ 'outline-2',
123
+ 'outline-offset-2',
124
+ 'outline-panda-dark',
125
+ 'dark:outline-panda-light'
126
+ )
127
+ container.classList.remove(
128
+ 'focus-within:outline-2',
129
+ 'focus-within:outline-offset-2',
130
+ 'focus-within:outline-primary-600'
131
+ )
132
+ }
133
+ }
134
+ }