@getmicdrop/svelte-components 5.10.3 → 5.13.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 (299) hide show
  1. package/dist/calendar/AboutShow/AboutShow.svelte +172 -172
  2. package/dist/calendar/Calendar/MiniMonthCalendar.svelte +782 -782
  3. package/dist/calendar/FAQs/FAQs.svelte +75 -75
  4. package/dist/calendar/MonthSwitcher/MonthSwitcher.svelte +126 -126
  5. package/dist/calendar/OrderSummary/OrderSummary.svelte +367 -367
  6. package/dist/calendar/PublicCard/PublicCard.svelte +146 -146
  7. package/dist/calendar/ShowCard/ShowCard.svelte +157 -157
  8. package/dist/calendar/ShowTimeCard/ShowTimeCard.svelte +61 -61
  9. package/dist/components/Heading.svelte +60 -60
  10. package/dist/components/Layout/AppShell.svelte +104 -104
  11. package/dist/components/Layout/ContentSection.svelte +80 -80
  12. package/dist/components/Layout/Grid.svelte +4 -4
  13. package/dist/components/Layout/Heading.svelte +81 -81
  14. package/dist/components/Layout/PageContainer.svelte +69 -69
  15. package/dist/components/Layout/Responsive.svelte +75 -75
  16. package/dist/components/Layout/Section.svelte +80 -80
  17. package/dist/components/Layout/ShowOnDesktop.svelte +37 -37
  18. package/dist/components/Layout/ShowOnMobile.svelte +37 -37
  19. package/dist/components/Layout/Sidebar.svelte +108 -108
  20. package/dist/components/Layout/Stack.spec.js +1 -1
  21. package/dist/components/Layout/Stack.svelte +6 -6
  22. package/dist/components/Layout/Text.svelte +87 -87
  23. package/dist/components/Layout/TwoColumn.svelte +108 -108
  24. package/dist/components/Text.svelte +53 -53
  25. package/dist/constants/validation.js +91 -91
  26. package/dist/constants/validation.spec.js +64 -64
  27. package/dist/datetime/__tests__/format.test.js +1 -1
  28. package/dist/datetime/__tests__/parse.test.js +1 -1
  29. package/dist/datetime/__tests__/timezone.test.js +1 -1
  30. package/dist/datetime/parse.js +1 -1
  31. package/dist/forms/createFormStore.svelte.js +0 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +57 -51
  35. package/dist/index.spec.js +0 -1
  36. package/dist/patterns/data/DataGrid.svelte +45 -45
  37. package/dist/patterns/data/DataList.svelte +24 -24
  38. package/dist/patterns/data/DataTable.svelte +36 -36
  39. package/dist/patterns/forms/FormActions.spec.js +95 -95
  40. package/dist/patterns/forms/FormActions.stories.svelte +97 -97
  41. package/dist/patterns/forms/FormActions.svelte +46 -46
  42. package/dist/patterns/forms/FormGrid.svelte +33 -33
  43. package/dist/patterns/forms/FormSection.svelte +32 -32
  44. package/dist/patterns/forms/FormValidationSummary.stories.svelte +83 -83
  45. package/dist/patterns/forms/FormValidationSummary.svelte +74 -74
  46. package/dist/patterns/layout/Sidebar.svelte +39 -39
  47. package/dist/patterns/layout/index.js +29 -29
  48. package/dist/patterns/navigation/BottomNav.stories.svelte +117 -117
  49. package/dist/patterns/navigation/BottomNav.svelte +74 -74
  50. package/dist/patterns/navigation/Header.stories.svelte +77 -77
  51. package/dist/patterns/navigation/Header.svelte +251 -255
  52. package/dist/patterns/navigation/Header.svelte.d.ts.map +1 -1
  53. package/dist/patterns/page/PageHeader.svelte +18 -18
  54. package/dist/patterns/page/PageLayout.svelte +40 -40
  55. package/dist/patterns/page/PageLoader.spec.js +57 -57
  56. package/dist/patterns/page/PageLoader.stories.svelte +137 -137
  57. package/dist/patterns/page/PageLoader.svelte +24 -24
  58. package/dist/patterns/page/SectionHeader.svelte +29 -29
  59. package/dist/presets/badges.js +112 -112
  60. package/dist/presets/buttons.js +76 -76
  61. package/dist/presets/index.js +9 -9
  62. package/dist/primitives/Accordion/Accordion.stories.svelte +75 -75
  63. package/dist/primitives/Accordion/Accordion.svelte +42 -42
  64. package/dist/primitives/Accordion/AccordionItem.svelte +95 -95
  65. package/dist/primitives/Alert/Alert.spec.js +173 -173
  66. package/dist/primitives/Alert/Alert.stories.svelte +88 -88
  67. package/dist/primitives/Alert/Alert.svelte +27 -27
  68. package/dist/primitives/Avatar/Avatar.stories.svelte +94 -94
  69. package/dist/primitives/Avatar/Avatar.svelte +66 -66
  70. package/dist/primitives/AvatarButton/AvatarButton.svelte +57 -0
  71. package/dist/primitives/AvatarButton/AvatarButton.svelte.d.ts +18 -0
  72. package/dist/primitives/AvatarButton/AvatarButton.svelte.d.ts.map +1 -0
  73. package/dist/primitives/Badges/Badge.spec.js +144 -144
  74. package/dist/primitives/Badges/Badge.stories.svelte +86 -86
  75. package/dist/primitives/Badges/Badge.svelte +99 -99
  76. package/dist/primitives/BottomSheet/BottomSheet.spec.js +136 -136
  77. package/dist/primitives/BottomSheet/BottomSheet.stories.svelte +83 -83
  78. package/dist/primitives/BottomSheet/BottomSheet.svelte +100 -100
  79. package/dist/primitives/BottomSheet/BottomSheet.svelte.d.ts +2 -2
  80. package/dist/primitives/BottomSheet/BottomSheet.svelte.d.ts.map +1 -1
  81. package/dist/primitives/BottomSheet/BottomSheetWrapper.test.svelte +3 -3
  82. package/dist/primitives/BottomSheet/BottomSheetWrapper.test.svelte.d.ts +1 -1
  83. package/dist/primitives/Breadcrumb/Breadcrumb.spec.js +123 -122
  84. package/dist/primitives/Breadcrumb/Breadcrumb.stories.svelte +23 -23
  85. package/dist/primitives/Breadcrumb/Breadcrumb.svelte +99 -98
  86. package/dist/primitives/Button/Button.spec.js +223 -223
  87. package/dist/primitives/Button/Button.stories.svelte +76 -76
  88. package/dist/primitives/Button/Button.svelte +278 -283
  89. package/dist/primitives/Button/Button.svelte.d.ts +2 -0
  90. package/dist/primitives/Button/Button.svelte.d.ts.map +1 -1
  91. package/dist/primitives/Button/ButtonGroup.svelte +50 -50
  92. package/dist/primitives/Button/ButtonSaveDemo.spec.js +146 -146
  93. package/dist/primitives/Button/ButtonSaveDemo.svelte +25 -25
  94. package/dist/primitives/Button/ButtonVariantShowcase.svelte +129 -129
  95. package/dist/primitives/Card.spec.js +49 -49
  96. package/dist/primitives/Card.stories.svelte +22 -22
  97. package/dist/primitives/Card.svelte +28 -28
  98. package/dist/primitives/CardAction/CardAction.svelte +68 -0
  99. package/dist/primitives/CardAction/CardAction.svelte.d.ts +20 -0
  100. package/dist/primitives/CardAction/CardAction.svelte.d.ts.map +1 -0
  101. package/dist/primitives/Checkbox/Checkbox.stories.svelte +84 -84
  102. package/dist/primitives/Checkbox/Checkbox.svelte +88 -88
  103. package/dist/primitives/DarkModeToggle.spec.js +390 -390
  104. package/dist/primitives/DarkModeToggle.stories.svelte +57 -57
  105. package/dist/primitives/DarkModeToggle.svelte +136 -136
  106. package/dist/primitives/Drawer/Drawer.spec.js +33 -33
  107. package/dist/primitives/Drawer/Drawer.stories.svelte +80 -80
  108. package/dist/primitives/Drawer/Drawer.svelte +123 -127
  109. package/dist/primitives/Drawer/Drawer.svelte.d.ts +2 -3
  110. package/dist/primitives/Drawer/Drawer.svelte.d.ts.map +1 -1
  111. package/dist/primitives/Dropdown/Dropdown.stories.svelte +137 -137
  112. package/dist/primitives/Dropdown/Dropdown.svelte +170 -170
  113. package/dist/primitives/Dropdown/DropdownDivider.svelte +9 -9
  114. package/dist/primitives/Dropdown/DropdownItem.svelte +80 -80
  115. package/dist/primitives/Helper/Helper.svelte +33 -33
  116. package/dist/primitives/Icons/ArrowLeft.svelte +8 -8
  117. package/dist/primitives/Icons/ArrowRight.svelte +8 -8
  118. package/dist/primitives/Icons/Availability.svelte +14 -14
  119. package/dist/primitives/Icons/Back.svelte +14 -14
  120. package/dist/primitives/Icons/CheckCircle.svelte +6 -6
  121. package/dist/primitives/Icons/CheckCircleOutline.svelte +15 -15
  122. package/dist/primitives/Icons/ChevronLeft.svelte +4 -4
  123. package/dist/primitives/Icons/ChevronRight.svelte +4 -4
  124. package/dist/primitives/Icons/Copy.svelte +15 -15
  125. package/dist/primitives/Icons/Cross.svelte +5 -5
  126. package/dist/primitives/Icons/DownArrow.svelte +8 -8
  127. package/dist/primitives/Icons/ErrorCircle.svelte +6 -6
  128. package/dist/primitives/Icons/FacebookIcon.svelte +2 -2
  129. package/dist/primitives/Icons/Home.svelte +15 -15
  130. package/dist/primitives/Icons/Icon.spec.js +169 -169
  131. package/dist/primitives/Icons/Icon.stories.svelte +100 -100
  132. package/dist/primitives/Icons/Icon.svelte +52 -52
  133. package/dist/primitives/Icons/IconGallery.stories.svelte +235 -235
  134. package/dist/primitives/Icons/Info.svelte +7 -7
  135. package/dist/primitives/Icons/InstagramIcon.svelte +4 -4
  136. package/dist/primitives/Icons/LogoInstagram.svelte +2 -2
  137. package/dist/primitives/Icons/Message.svelte +15 -15
  138. package/dist/primitives/Icons/MoonIcon.svelte +5 -5
  139. package/dist/primitives/Icons/More.svelte +21 -21
  140. package/dist/primitives/Icons/MoreHori.spec.js +61 -61
  141. package/dist/primitives/Icons/MoreHori.svelte +22 -22
  142. package/dist/primitives/Icons/Notification.svelte +14 -14
  143. package/dist/primitives/Icons/Payment.svelte +14 -14
  144. package/dist/primitives/Icons/Profile.svelte +21 -21
  145. package/dist/primitives/Icons/Reload.svelte +29 -29
  146. package/dist/primitives/Icons/Shows.svelte +21 -21
  147. package/dist/primitives/Icons/Signout.svelte +21 -21
  148. package/dist/primitives/Icons/SunIcon.svelte +8 -8
  149. package/dist/primitives/Icons/TiktokIcon.svelte +2 -2
  150. package/dist/primitives/Icons/TwitterIcon.svelte +2 -2
  151. package/dist/primitives/Icons/WarningIcon.spec.js +18 -18
  152. package/dist/primitives/Icons/WarningIcon.svelte +5 -5
  153. package/dist/primitives/Input/Input.spec.js +573 -573
  154. package/dist/primitives/Input/Input.stories.svelte +139 -139
  155. package/dist/primitives/Input/Input.svelte +423 -423
  156. package/dist/primitives/Input/Select.spec.js +218 -218
  157. package/dist/primitives/Input/Select.stories.svelte +112 -112
  158. package/dist/primitives/Input/Select.svelte +252 -252
  159. package/dist/primitives/Input/Textarea.stories.svelte +137 -137
  160. package/dist/primitives/Input/Textarea.svelte +105 -105
  161. package/dist/primitives/Label/Label.svelte +37 -37
  162. package/dist/primitives/LandingButton/LandingButton.svelte +92 -0
  163. package/dist/primitives/LandingButton/LandingButton.svelte.d.ts +22 -0
  164. package/dist/primitives/LandingButton/LandingButton.svelte.d.ts.map +1 -0
  165. package/dist/primitives/MenuItem/MenuItem.svelte +85 -0
  166. package/dist/primitives/MenuItem/MenuItem.svelte.d.ts +24 -0
  167. package/dist/primitives/MenuItem/MenuItem.svelte.d.ts.map +1 -0
  168. package/dist/primitives/Modal/Modal.spec.js +99 -99
  169. package/dist/primitives/Modal/Modal.stories.svelte +86 -86
  170. package/dist/primitives/Modal/Modal.svelte +181 -157
  171. package/dist/primitives/Modal/Modal.svelte.d.ts +7 -5
  172. package/dist/primitives/Modal/Modal.svelte.d.ts.map +1 -1
  173. package/dist/primitives/Modal/ModalTestWrapper.svelte +3 -3
  174. package/dist/primitives/Modal/ModalTestWrapper.svelte.d.ts +2 -2
  175. package/dist/primitives/NavItem/NavItem.svelte +75 -0
  176. package/dist/primitives/NavItem/NavItem.svelte.d.ts +20 -0
  177. package/dist/primitives/NavItem/NavItem.svelte.d.ts.map +1 -0
  178. package/dist/primitives/Pagination/Pagination.stories.svelte +76 -76
  179. package/dist/primitives/Pagination/Pagination.svelte +261 -261
  180. package/dist/primitives/Radio/Radio.stories.svelte +80 -80
  181. package/dist/primitives/Radio/Radio.svelte +67 -67
  182. package/dist/primitives/SearchResultItem/SearchResultItem.svelte +109 -0
  183. package/dist/primitives/SearchResultItem/SearchResultItem.svelte.d.ts +26 -0
  184. package/dist/primitives/SearchResultItem/SearchResultItem.svelte.d.ts.map +1 -0
  185. package/dist/primitives/SidebarToggle/SidebarToggle.svelte +55 -0
  186. package/dist/primitives/SidebarToggle/SidebarToggle.svelte.d.ts +18 -0
  187. package/dist/primitives/SidebarToggle/SidebarToggle.svelte.d.ts.map +1 -0
  188. package/dist/primitives/Skeleton/CardPlaceholder.svelte +87 -87
  189. package/dist/primitives/Skeleton/ImagePlaceholder.svelte +59 -59
  190. package/dist/primitives/Skeleton/ListPlaceholder.svelte +76 -76
  191. package/dist/primitives/Skeleton/Skeleton.stories.svelte +151 -151
  192. package/dist/primitives/Skeleton/Skeleton.svelte +26 -26
  193. package/dist/primitives/Spinner/Spinner.spec.js +71 -71
  194. package/dist/primitives/Spinner/Spinner.stories.svelte +29 -29
  195. package/dist/primitives/Spinner/Spinner.svelte +20 -20
  196. package/dist/primitives/Tabs/TabItem.svelte +49 -49
  197. package/dist/primitives/Tabs/Tabs.stories.svelte +112 -112
  198. package/dist/primitives/Tabs/Tabs.svelte +137 -137
  199. package/dist/primitives/Toggle.spec.js +146 -146
  200. package/dist/primitives/Toggle.stories.svelte +92 -92
  201. package/dist/primitives/Toggle.svelte +141 -131
  202. package/dist/primitives/Toggle.svelte.d.ts +2 -0
  203. package/dist/primitives/Toggle.svelte.d.ts.map +1 -1
  204. package/dist/primitives/Tooltip/Tooltip.svelte +83 -83
  205. package/dist/primitives/Typography/Typography.svelte +53 -53
  206. package/dist/primitives/ValidationError.spec.js +103 -103
  207. package/dist/primitives/ValidationError.stories.svelte +69 -69
  208. package/dist/primitives/ValidationError.svelte +29 -29
  209. package/dist/primitives/index.d.ts +7 -0
  210. package/dist/primitives/index.js +113 -92
  211. package/dist/recipes/CropImage/CropImage.spec.js +208 -208
  212. package/dist/recipes/CropImage/CropImage.stories.svelte +104 -104
  213. package/dist/recipes/CropImage/CropImage.svelte +219 -219
  214. package/dist/recipes/ImageUploader/ImageUploader.stories.svelte +125 -125
  215. package/dist/recipes/ImageUploader/ImageUploader.svelte +970 -970
  216. package/dist/recipes/SuperLogin/SuperLogin.svelte +3 -3
  217. package/dist/recipes/SuperLogin/SuperLogin.svelte.d.ts.map +1 -1
  218. package/dist/recipes/Toaster/Toaster.stories.svelte +62 -62
  219. package/dist/recipes/feedback/EmptyState/EmptyState.svelte +1 -1
  220. package/dist/recipes/feedback/ErrorDisplay.spec.js +69 -69
  221. package/dist/recipes/feedback/ErrorDisplay.stories.svelte +101 -101
  222. package/dist/recipes/feedback/ErrorDisplay.svelte +1 -1
  223. package/dist/recipes/feedback/StatusIndicator/StatusIndicator.spec.js +133 -133
  224. package/dist/recipes/feedback/StatusIndicator/StatusIndicator.svelte +157 -157
  225. package/dist/recipes/fields/CheckboxField.svelte +85 -85
  226. package/dist/recipes/fields/FormField.svelte +58 -58
  227. package/dist/recipes/fields/RadioGroup.svelte +95 -95
  228. package/dist/recipes/fields/SelectField.svelte +80 -80
  229. package/dist/recipes/fields/TextareaField.svelte +97 -97
  230. package/dist/recipes/fields/ToggleField.svelte +60 -60
  231. package/dist/recipes/fields/index.js +7 -7
  232. package/dist/recipes/inputs/MultiSelect.spec.js +260 -260
  233. package/dist/recipes/inputs/MultiSelect.stories.svelte +133 -133
  234. package/dist/recipes/inputs/MultiSelect.svelte +283 -283
  235. package/dist/recipes/inputs/OTPInput.spec.js +251 -251
  236. package/dist/recipes/inputs/OTPInput.stories.svelte +162 -162
  237. package/dist/recipes/inputs/OTPInput.svelte +117 -117
  238. package/dist/recipes/inputs/PasswordInput.svelte +22 -22
  239. package/dist/recipes/inputs/PasswordStrengthIndicator/PasswordStrengthIndicator.svelte +131 -131
  240. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.stories.svelte +123 -123
  241. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.svelte +344 -344
  242. package/dist/recipes/inputs/Search.svelte +102 -102
  243. package/dist/recipes/inputs/index.d.ts +0 -1
  244. package/dist/recipes/inputs/index.js +0 -1
  245. package/dist/recipes/modals/AlertModal.spec.js +2 -2
  246. package/dist/recipes/modals/AlertModal.svelte +130 -130
  247. package/dist/recipes/modals/AlertModal.svelte.d.ts +3 -3
  248. package/dist/recipes/modals/ConfirmationModal.spec.js +206 -206
  249. package/dist/recipes/modals/ConfirmationModal.stories.svelte +119 -119
  250. package/dist/recipes/modals/ConfirmationModal.svelte +162 -152
  251. package/dist/recipes/modals/ConfirmationModal.svelte.d.ts +5 -3
  252. package/dist/recipes/modals/ConfirmationModal.svelte.d.ts.map +1 -1
  253. package/dist/recipes/modals/InputModal.spec.js +2 -2
  254. package/dist/recipes/modals/InputModal.svelte +182 -182
  255. package/dist/recipes/modals/InputModal.svelte.d.ts +3 -3
  256. package/dist/recipes/modals/ModalStateManager.spec.js +100 -100
  257. package/dist/recipes/modals/ModalStateManager.svelte +77 -77
  258. package/dist/recipes/modals/ModalTestWrapper.spec.js +49 -49
  259. package/dist/recipes/modals/ModalTestWrapper.svelte +65 -65
  260. package/dist/recipes/modals/ModalTestWrapper.svelte.d.ts +2 -2
  261. package/dist/recipes/modals/StatusModal.spec.js +2 -2
  262. package/dist/recipes/modals/StatusModal.svelte +206 -206
  263. package/dist/recipes/modals/StatusModal.svelte.d.ts +3 -3
  264. package/dist/services/EventService.js +75 -75
  265. package/dist/services/EventService.spec.js +217 -217
  266. package/dist/services/ShowService.spec.js +345 -345
  267. package/dist/stores/toaster.js +13 -13
  268. package/dist/stories/ButtonAuditReview.stories.svelte +14 -14
  269. package/dist/stories/ButtonAuditReview.svelte +427 -427
  270. package/dist/stories/ComponentConsolidation.stories.svelte +10 -10
  271. package/dist/stories/PatternsGallery.stories.svelte +19 -19
  272. package/dist/stories/PatternsGallery.svelte +206 -206
  273. package/dist/stories/PrimitivesGallery.stories.svelte +19 -19
  274. package/dist/stories/PrimitivesGallery.svelte +756 -752
  275. package/dist/stories/PrimitivesGallery.svelte.d.ts.map +1 -1
  276. package/dist/stories/RecipesGallery.spec.js +9 -18
  277. package/dist/stories/RecipesGallery.stories.svelte +19 -19
  278. package/dist/stories/RecipesGallery.svelte +454 -471
  279. package/dist/stories/RecipesGallery.svelte.d.ts.map +1 -1
  280. package/dist/stories/button-audit-manifest.json +11186 -11186
  281. package/dist/tailwind/preset.cjs +82 -82
  282. package/dist/telemetry.js +405 -405
  283. package/dist/telemetry.spec.js +1169 -1169
  284. package/dist/tokens/tokens.css +87 -87
  285. package/dist/tokens/typography-base.css +163 -163
  286. package/dist/tokens/utilities.css +353 -353
  287. package/dist/utils/apiConfig.spec.js +219 -219
  288. package/dist/utils/haptic.d.ts +41 -0
  289. package/dist/utils/haptic.d.ts.map +1 -0
  290. package/dist/utils/haptic.js +115 -0
  291. package/dist/utils/transitions.js +4 -4
  292. package/dist/utils/utils.js +693 -693
  293. package/package.json +297 -297
  294. package/dist/recipes/inputs/SelectDropdown.spec.d.ts +0 -2
  295. package/dist/recipes/inputs/SelectDropdown.spec.d.ts.map +0 -1
  296. package/dist/recipes/inputs/SelectDropdown.spec.js +0 -518
  297. package/dist/recipes/inputs/SelectDropdown.svelte +0 -171
  298. package/dist/recipes/inputs/SelectDropdown.svelte.d.ts +0 -16
  299. package/dist/recipes/inputs/SelectDropdown.svelte.d.ts.map +0 -1
@@ -1,970 +1,970 @@
1
- <script lang="ts">
2
- /**
3
- * ImageUploader - Unified image upload component
4
- *
5
- * Supports:
6
- * - Single image mode (event posters, venue images)
7
- * - Multi-image mode with grid layout (performer photos)
8
- * - Optional cropping with aspect ratio selection
9
- * - Optional drag-to-reorder for multi-image
10
- * - Different shapes: square, wide
11
- * - Image compression before upload callback
12
- */
13
-
14
- import { browser } from '../../__LIB_ENVIRONMENT__.js';
15
- import FilePond, { registerPlugin } from 'svelte-filepond';
16
- import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
17
- import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
18
- import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
19
- import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
20
- import 'filepond/dist/filepond.min.css';
21
- import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
22
-
23
- import Sortable from 'sortablejs';
24
- import CropImage from '../CropImage/CropImage.svelte';
25
- import { optimizeImage } from '../../utils/imageOptimizer';
26
- import Badge from '../../primitives/Badges/Badge.svelte';
27
- import Button from '../../primitives/Button/Button.svelte';
28
- import { CloseOutline, ImageOutline, PlusOutline, TrashBinOutline } from '../../primitives/Icons';
29
- import { typography } from '../../tokens/typography';
30
-
31
- // Register FilePond plugins once
32
- registerPlugin(
33
- FilePondPluginImagePreview,
34
- FilePondPluginImageExifOrientation,
35
- FilePondPluginFileValidateType,
36
- FilePondPluginFileValidateSize
37
- );
38
-
39
- // Types
40
- type Shape = 'square' | 'wide';
41
- type AspectRatio = '1:1' | '2:1' | '16:9' | '4:3' | 'free';
42
- type Size = 'sm' | 'md' | 'lg';
43
-
44
- interface Props {
45
- /** Single image or array of images (URLs) */
46
- images?: string | string[];
47
- /** Maximum number of images (only for multi mode) */
48
- maxImages?: number;
49
- /** Enable cropping step */
50
- enableCrop?: boolean;
51
- /** Crop aspect ratio */
52
- cropAspectRatio?: AspectRatio;
53
- /** Enable drag-to-reorder (only for multi mode) */
54
- enableReorder?: boolean;
55
- /** Dropzone shape - affects aspect ratio and styling */
56
- shape?: Shape;
57
- /** Size of image slots: sm (~64px), md (~96px), lg (~200px+) */
58
- size?: Size;
59
- /** Show "Main" badge on first image (multi mode) */
60
- showMainBadge?: boolean;
61
- /** Maximum file size (e.g., "10MB") */
62
- maxFileSize?: string;
63
- /** Accepted file types */
64
- acceptedTypes?: string[];
65
- /** Label shown when empty */
66
- emptyLabel?: string;
67
- /** Constraints text shown inside dropzone (e.g., "Max 10MB, 2160px") */
68
- constraintsText?: string;
69
- /** Helper text shown below dropzone */
70
- helperText?: string;
71
- /** Callback when image is uploaded - receives File, optionally returns URL
72
- * If returns URL: component adds to images (self-managed)
73
- * If returns void/null: parent must manage images (controlled) */
74
- onUpload?: (file: File) => Promise<string | null | void> | void;
75
- /** Callback when image is removed */
76
- onRemove?: (index: number) => void;
77
- /** Callback when images are reordered - receives new array */
78
- onReorder?: (images: string[]) => void;
79
- /** Callback when images are reordered - receives indices (for store integration) */
80
- onReorderIndices?: (data: { from: number; to: number }) => void;
81
- /** Callback when user clicks to set main image (moves to first position) */
82
- onSetMain?: (data: { index: number }) => void;
83
- /** Callback when images change (single source of truth) */
84
- onchange?: (images: string[]) => void;
85
- /** Show upload error */
86
- error?: string;
87
- /** Disabled state */
88
- disabled?: boolean;
89
- /** Custom class for wrapper */
90
- class?: string;
91
- }
92
-
93
- let {
94
- images = $bindable([]),
95
- maxImages = 6,
96
- enableCrop = false,
97
- cropAspectRatio = '1:1',
98
- enableReorder = true,
99
- shape = 'square',
100
- size = 'md',
101
- showMainBadge = true,
102
- maxFileSize = '20MB',
103
- acceptedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
104
- emptyLabel = 'Drag & drop or click to upload',
105
- constraintsText = '',
106
- helperText = '',
107
- onUpload,
108
- onRemove,
109
- onReorder,
110
- onReorderIndices,
111
- onSetMain,
112
- onchange,
113
- error = '',
114
- disabled = false,
115
- class: className = '',
116
- }: Props = $props();
117
-
118
- // Normalize images to array
119
- let imageArray = $derived(
120
- Array.isArray(images)
121
- ? images.filter(Boolean)
122
- : images
123
- ? [images]
124
- : []
125
- );
126
-
127
- // Determine mode based on maxImages
128
- let isMultiMode = $derived(maxImages > 1);
129
- let canAddMore = $derived(imageArray.length < maxImages);
130
-
131
- // FilePond state
132
- let filePondFiles = $state<unknown[]>([]);
133
- let filePondInstance = $state<unknown>(undefined);
134
- let showFilePondModal = $state(false);
135
- let targetSlotIndex = $state<number | null>(null);
136
-
137
- // Crop state
138
- let showCropModal = $state(false);
139
- let imageForCrop = $state('');
140
- let isUploading = $state(false);
141
-
142
- // Reorder state
143
- let gridContainer = $state<HTMLDivElement | undefined>(undefined);
144
- let sortableInstance: Sortable | null = null;
145
- let isDragging = $state(false);
146
- let gridRenderKey = $state(0);
147
-
148
- // Direct drag-drop state for dropzone
149
- let isDropzoneDragOver = $state(false);
150
-
151
- // Native modal dropzone state
152
- let isModalDragOver = $state(false);
153
- let modalFileInput = $state<HTMLInputElement | undefined>(undefined);
154
-
155
- // Single-mode file input for replace functionality
156
- let singleModeFileInput = $state<HTMLInputElement | undefined>(undefined);
157
-
158
- // Prevent browser from opening dragged files in new tab
159
- // This MUST be at document level to intercept before browser default behavior
160
- $effect(() => {
161
- if (!browser) return;
162
-
163
- const preventBrowserDefault = (e: DragEvent) => {
164
- // Only prevent if it's a file drag (not text selection, etc.)
165
- if (e.dataTransfer?.types?.includes('Files')) {
166
- e.preventDefault();
167
- }
168
- };
169
-
170
- window.addEventListener('dragover', preventBrowserDefault, true);
171
- window.addEventListener('drop', preventBrowserDefault, true);
172
-
173
- return () => {
174
- window.removeEventListener('dragover', preventBrowserDefault, true);
175
- window.removeEventListener('drop', preventBrowserDefault, true);
176
- };
177
- });
178
-
179
- // Shape to aspect ratio mapping
180
- const shapeAspects: Record<Shape, string> = {
181
- square: 'aspect-square',
182
- wide: 'aspect-video',
183
- };
184
-
185
- // Size configuration - layout classes, slot width, and element sizes
186
- const sizeConfig: Record<Size, { containerClass: string; slotClass: string; iconClass: string; badgeSize: 'small' | 'medium'; removeSize: string }> = {
187
- sm: {
188
- containerClass: 'flex flex-wrap gap-1.5',
189
- slotClass: 'w-16 h-16', // 64px fixed
190
- iconClass: 'w-4 h-4',
191
- badgeSize: 'small',
192
- removeSize: 'w-4 h-4',
193
- },
194
- md: {
195
- containerClass: 'flex flex-wrap gap-2',
196
- slotClass: 'w-24 h-24', // 96px fixed
197
- iconClass: 'w-5 h-5',
198
- badgeSize: 'small',
199
- removeSize: 'w-5 h-5',
200
- },
201
- lg: {
202
- containerClass: 'grid grid-cols-3 gap-4',
203
- slotClass: '', // Let grid control width (uses aspect-square)
204
- iconClass: 'w-8 h-8',
205
- badgeSize: 'medium',
206
- removeSize: 'w-6 h-6',
207
- },
208
- };
209
-
210
- // Get current size config
211
- let currentSizeConfig = $derived(sizeConfig[size]);
212
-
213
- // Crop aspect ratio to number
214
- const aspectRatioValues: Record<AspectRatio, number> = {
215
- '1:1': 1,
216
- '2:1': 2,
217
- '16:9': 16 / 9,
218
- '4:3': 4 / 3,
219
- 'free': 0,
220
- };
221
-
222
- // FilePond options
223
- const filePondOptions = $derived({
224
- allowMultiple: false,
225
- maxFiles: 1,
226
- acceptedFileTypes: acceptedTypes,
227
- maxFileSize: maxFileSize,
228
- // Drag-drop configuration
229
- allowDrop: true,
230
- dropOnPage: false, // Only accept drops on the FilePond element
231
- dropOnElement: true,
232
- labelIdle: `
233
- <div class="filepond-custom-label">
234
- <svg class="w-8 h-8 mb-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
235
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
236
- </svg>
237
- <span class="text-sm font-medium text-gray-600 dark:text-gray-300">${emptyLabel}</span>
238
- <span class="text-[11px] text-gray-400 dark:text-gray-500 mt-2 block">JPG, PNG, WebP</span>
239
- ${constraintsText ? `<span class="text-[10px] text-gray-400/50 dark:text-gray-500/50 mt-1 block">${constraintsText}</span>` : ''}
240
- </div>
241
- `,
242
- credits: false,
243
- instantUpload: false,
244
- allowRevert: true,
245
- allowProcess: false,
246
- imagePreviewHeight: shape === 'wide' ? 150 : 200,
247
- stylePanelLayout: 'compact',
248
- stylePanelAspectRatio: shape === 'wide' ? '16:9' : '1:1',
249
- });
250
-
251
- // Handle FilePond file add
252
- function handleFilePondAddFile(err: unknown, fileItem: unknown) {
253
- if (err) {
254
- console.error('FilePond error:', err);
255
- return;
256
- }
257
-
258
- const item = fileItem as { file: File } | undefined;
259
- const file = item?.file;
260
- if (!file) return;
261
-
262
- if (enableCrop) {
263
- // Show crop modal
264
- const objectUrl = URL.createObjectURL(file);
265
- imageForCrop = objectUrl;
266
- showCropModal = true;
267
- } else {
268
- // Optimize then upload
269
- optimizeImage(file).then((optimizedFile) => {
270
- handleUpload(optimizedFile);
271
- });
272
- }
273
-
274
- // Clear FilePond
275
- setTimeout(() => {
276
- if (filePondInstance && typeof (filePondInstance as { removeFiles: () => void }).removeFiles === 'function') {
277
- (filePondInstance as { removeFiles: () => void }).removeFiles();
278
- }
279
- }, 100);
280
-
281
- showFilePondModal = false;
282
- }
283
-
284
- // Handle crop save
285
- async function handleCropSave(croppedFile: File) {
286
- showCropModal = false;
287
- if (imageForCrop.startsWith('blob:')) {
288
- URL.revokeObjectURL(imageForCrop);
289
- }
290
- imageForCrop = '';
291
- await handleUpload(croppedFile);
292
- }
293
-
294
- // Handle crop cancel
295
- function handleCropCancel() {
296
- showCropModal = false;
297
- if (imageForCrop.startsWith('blob:')) {
298
- URL.revokeObjectURL(imageForCrop);
299
- }
300
- imageForCrop = '';
301
- }
302
-
303
- // Handle upload
304
- // Supports two modes:
305
- // 1. Self-managed: onUpload returns URL, component adds to images
306
- // 2. Controlled: onUpload returns void, parent manages images array
307
- async function handleUpload(file: File) {
308
- if (!onUpload) {
309
- console.warn('ImageUploader: onUpload callback not provided');
310
- return;
311
- }
312
-
313
- isUploading = true;
314
-
315
- try {
316
- const result = await onUpload(file);
317
-
318
- // If onUpload returns a URL, component manages adding to images (self-managed mode)
319
- // If onUpload returns void/null, parent is expected to manage images (controlled mode)
320
- if (typeof result === 'string' && result) {
321
- const url = result;
322
- const newImages = [...imageArray];
323
- if (targetSlotIndex !== null && targetSlotIndex < newImages.length) {
324
- // Replace existing image
325
- newImages[targetSlotIndex] = url;
326
- } else {
327
- // Add new image
328
- newImages.push(url);
329
- }
330
-
331
- // Update images and notify
332
- if (Array.isArray(images)) {
333
- images = newImages;
334
- } else {
335
- images = newImages[0] || '';
336
- }
337
- onchange?.(newImages);
338
- }
339
- // If result is void/null/undefined, parent handles adding to images
340
- } catch (err) {
341
- console.error('Upload failed:', err);
342
- } finally {
343
- isUploading = false;
344
- targetSlotIndex = null;
345
- }
346
- }
347
-
348
- // Handle remove
349
- // In controlled mode (onReorderIndices provided), just fire callback
350
- // In self-managed mode, update images internally
351
- function handleRemoveImage(index: number) {
352
- // Always fire the callback
353
- onRemove?.(index);
354
-
355
- // Only manage state internally if not in controlled mode
356
- if (!onReorderIndices) {
357
- const newImages = [...imageArray];
358
- newImages.splice(index, 1);
359
-
360
- if (Array.isArray(images)) {
361
- images = newImages;
362
- } else {
363
- images = newImages[0] || '';
364
- }
365
-
366
- onchange?.(newImages);
367
- }
368
-
369
- gridRenderKey++;
370
- }
371
-
372
- // Open upload modal/dropzone
373
- function openUploadModal(slotIndex?: number) {
374
- if (disabled) return;
375
- targetSlotIndex = slotIndex ?? null;
376
- if (isMultiMode) {
377
- showFilePondModal = true;
378
- }
379
- }
380
-
381
- // Handle direct drag-drop on dropzone (bypasses modal)
382
- function handleDropzoneDragOver(e: DragEvent) {
383
- e.preventDefault();
384
- e.stopPropagation();
385
- if (disabled || !canAddMore) return;
386
- isDropzoneDragOver = true;
387
- }
388
-
389
- function handleDropzoneDragLeave(e: DragEvent) {
390
- e.preventDefault();
391
- e.stopPropagation();
392
- isDropzoneDragOver = false;
393
- }
394
-
395
- function handleDropzoneDrop(e: DragEvent, slotIndex?: number) {
396
- e.preventDefault();
397
- e.stopPropagation();
398
- isDropzoneDragOver = false;
399
-
400
- if (disabled || !canAddMore) return;
401
-
402
- const files = e.dataTransfer?.files;
403
- if (!files || files.length === 0) return;
404
-
405
- const file = files[0];
406
-
407
- // Check if it's an accepted image type
408
- if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
409
- console.warn('File type not accepted:', file.type);
410
- return;
411
- }
412
-
413
- targetSlotIndex = slotIndex ?? null;
414
-
415
- if (enableCrop) {
416
- // Show crop modal
417
- const objectUrl = URL.createObjectURL(file);
418
- imageForCrop = objectUrl;
419
- showCropModal = true;
420
- } else {
421
- // Optimize then upload
422
- optimizeImage(file).then((optimizedFile) => {
423
- handleUpload(optimizedFile);
424
- });
425
- }
426
- }
427
-
428
- // Handle modal dropzone drop
429
- function handleModalDrop(e: DragEvent) {
430
- e.preventDefault();
431
- e.stopPropagation();
432
- isModalDragOver = false;
433
- showFilePondModal = false;
434
-
435
- if (disabled || !canAddMore) return;
436
-
437
- const files = e.dataTransfer?.files;
438
- if (!files || files.length === 0) return;
439
-
440
- const file = files[0];
441
-
442
- // Check if it's an accepted image type
443
- if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
444
- console.warn('File type not accepted:', file.type);
445
- return;
446
- }
447
-
448
- if (enableCrop) {
449
- const objectUrl = URL.createObjectURL(file);
450
- imageForCrop = objectUrl;
451
- showCropModal = true;
452
- } else {
453
- optimizeImage(file).then((optimizedFile) => {
454
- handleUpload(optimizedFile);
455
- });
456
- }
457
- }
458
-
459
- // Handle modal file input selection
460
- function handleModalFileSelect(e: Event) {
461
- const input = e.target as HTMLInputElement;
462
- const file = input?.files?.[0];
463
- if (!file) return;
464
-
465
- showFilePondModal = false;
466
-
467
- if (enableCrop) {
468
- const objectUrl = URL.createObjectURL(file);
469
- imageForCrop = objectUrl;
470
- showCropModal = true;
471
- } else {
472
- optimizeImage(file).then((optimizedFile) => {
473
- handleUpload(optimizedFile);
474
- });
475
- }
476
-
477
- // Clear input for next selection
478
- input.value = '';
479
- }
480
-
481
- // Handle single-mode file input selection (for replace)
482
- function handleSingleModeFileSelect(e: Event) {
483
- const input = e.target as HTMLInputElement;
484
- const file = input?.files?.[0];
485
- if (!file) return;
486
-
487
- targetSlotIndex = 0; // Replace the existing image
488
-
489
- if (enableCrop) {
490
- const objectUrl = URL.createObjectURL(file);
491
- imageForCrop = objectUrl;
492
- showCropModal = true;
493
- } else {
494
- optimizeImage(file).then((optimizedFile) => {
495
- handleUpload(optimizedFile);
496
- });
497
- }
498
-
499
- // Clear input for next selection
500
- input.value = '';
501
- }
502
-
503
- // Initialize sortable for multi-image grid
504
- function initSortable() {
505
- if (!browser || !gridContainer || !enableReorder || !isMultiMode) return;
506
-
507
- if (sortableInstance) {
508
- sortableInstance.destroy();
509
- }
510
-
511
- sortableInstance = new Sortable(gridContainer, {
512
- animation: 200,
513
- ghostClass: 'sortable-ghost',
514
- chosenClass: 'sortable-chosen',
515
- dragClass: 'sortable-drag',
516
- delay: 150,
517
- delayOnTouchOnly: true,
518
- touchStartThreshold: 5,
519
- filter: '.empty-slot, .remove-btn',
520
- preventOnFilter: false,
521
- onStart: () => {
522
- isDragging = true;
523
- },
524
- onEnd: (evt) => {
525
- isDragging = false;
526
- const { oldIndex, newIndex } = evt;
527
- if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
528
-
529
- // Fire index-based callback for store integration (controlled mode)
530
- onReorderIndices?.({ from: oldIndex, to: newIndex });
531
-
532
- // For self-managed mode, update images internally
533
- if (!onReorderIndices) {
534
- const newImages = [...imageArray];
535
- const [moved] = newImages.splice(oldIndex, 1);
536
- newImages.splice(newIndex, 0, moved);
537
-
538
- if (Array.isArray(images)) {
539
- images = newImages;
540
- }
541
-
542
- onReorder?.(newImages);
543
- onchange?.(newImages);
544
- }
545
-
546
- gridRenderKey++;
547
-
548
- // Re-init after state update
549
- if (sortableInstance) {
550
- sortableInstance.destroy();
551
- sortableInstance = null;
552
- }
553
- },
554
- });
555
- }
556
-
557
- // Setup sortable on mount and when images change
558
- $effect(() => {
559
- if (browser && gridContainer && !isDragging && isMultiMode && enableReorder) {
560
- // Use setTimeout to ensure DOM is ready
561
- setTimeout(initSortable, 0);
562
- }
563
- });
564
-
565
- // Cleanup
566
- $effect(() => {
567
- return () => {
568
- if (sortableInstance) {
569
- sortableInstance.destroy();
570
- }
571
- };
572
- });
573
-
574
- // Generate slots with progressive disclosure
575
- // Only show filled images + 1 empty slot (up to maxImages)
576
- let slots = $derived.by(() => {
577
- const slotCount = Math.min(imageArray.length + 1, maxImages);
578
- return Array.from({ length: slotCount }, (_, i) => ({
579
- id: `slot-${i}`,
580
- image: imageArray[i] || null,
581
- isNext: i === imageArray.length,
582
- }));
583
- });
584
- </script>
585
-
586
-
587
- <!-- Single Image Mode -->
588
- {#if !isMultiMode}
589
- <div class="image-uploader-single {className}">
590
- {#if imageArray.length > 0}
591
- <!-- Show existing image with remove option -->
592
- <div class="relative {shapeAspects[shape]} bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden group">
593
- <img
594
- src={imageArray[0]}
595
- alt="Uploaded"
596
- class="w-full h-full object-cover"
597
- />
598
- {#if !disabled}
599
- <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
600
- <Button
601
- variant="default"
602
- size="sm"
603
- onclick={() => singleModeFileInput?.click()}
604
- >
605
- <ImageOutline class="w-4 h-4 mr-1.5" />
606
- Replace
607
- </Button>
608
- <Button
609
- variant="red"
610
- size="sm"
611
- onclick={() => handleRemoveImage(0)}
612
- >
613
- <TrashBinOutline class="w-4 h-4 mr-1.5" />
614
- Delete
615
- </Button>
616
- </div>
617
- <!-- Hidden file input for replace -->
618
- <input
619
- bind:this={singleModeFileInput}
620
- type="file"
621
- accept={acceptedTypes.join(',')}
622
- class="hidden"
623
- onchange={handleSingleModeFileSelect}
624
- />
625
- {/if}
626
- </div>
627
- {:else}
628
- <!-- Empty dropzone with direct drag-drop support -->
629
- <div
630
- class="{shapeAspects[shape]} filepond-wrapper-single {shape === 'wide' ? 'filepond-wide' : ''}
631
- {isDropzoneDragOver ? 'dropzone-drag-over' : ''}"
632
- role="button"
633
- tabindex="0"
634
- ondragover={handleDropzoneDragOver}
635
- ondragleave={handleDropzoneDragLeave}
636
- ondrop={(e) => handleDropzoneDrop(e, 0)}
637
- >
638
- <FilePond
639
- bind:this={filePondInstance}
640
- bind:files={filePondFiles}
641
- {...filePondOptions}
642
- onaddfile={handleFilePondAddFile}
643
- />
644
- </div>
645
- {/if}
646
-
647
- {#if helperText}
648
- <p class={`${typography.smMuted} mt-2`}>{helperText}</p>
649
- {/if}
650
-
651
- {#if error}
652
- <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
653
- {/if}
654
- </div>
655
-
656
- <!-- Multi Image Mode (Grid) -->
657
- {:else}
658
- <div class="image-uploader-multi {className}">
659
- {#key gridRenderKey}
660
- <div
661
- bind:this={gridContainer}
662
- class="{currentSizeConfig.containerClass}"
663
- >
664
- {#each slots as slot, index (slot.id)}
665
- <div
666
- class="relative {size === 'lg' ? shapeAspects[shape] : ''} {currentSizeConfig.slotClass} rounded-lg overflow-hidden"
667
- class:cursor-grab={slot.image && enableReorder && !disabled}
668
- class:empty-slot={!slot.image}
669
- data-slot-id={slot.id}
670
- >
671
- {#if slot.image}
672
- <!-- Filled slot -->
673
- <!-- svelte-ignore a11y_click_events_have_key_events -->
674
- <!-- svelte-ignore a11y_no_static_element_interactions -->
675
- <div
676
- class="relative w-full h-full bg-gray-100 dark:bg-gray-800 group"
677
- class:cursor-pointer={onSetMain && index !== 0 && !disabled}
678
- onclick={() => {
679
- if (onSetMain && index !== 0 && !disabled) {
680
- onSetMain({ index });
681
- }
682
- }}
683
- >
684
- <img
685
- src={slot.image}
686
- alt="Uploaded"
687
- class="w-full h-full object-cover pointer-events-none"
688
- draggable="false"
689
- />
690
- {#if showMainBadge && index === 0}
691
- <div class="absolute bottom-1 left-1 z-10 pointer-events-none">
692
- <Badge variant="info" size={currentSizeConfig.badgeSize}>Main</Badge>
693
- </div>
694
- {/if}
695
- {#if !disabled}
696
- <button
697
- type="button"
698
- class="remove-btn absolute top-1 right-1 {currentSizeConfig.removeSize} bg-white/90 dark:bg-gray-900/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-900"
699
- onclick={(e) => { e.stopPropagation(); handleRemoveImage(index); }}
700
- aria-label="Remove photo"
701
- >
702
- <CloseOutline class="w-3 h-3 text-gray-700 dark:text-gray-300" />
703
- </button>
704
- {/if}
705
- </div>
706
- {:else if slot.isNext && canAddMore && !disabled}
707
- <!-- Next available slot - dropzone with direct drag-drop support -->
708
- <!-- svelte-ignore a11y_no_static_element_interactions -->
709
- <div
710
- class="w-full h-full border-2 border-dashed rounded-lg transition-colors flex items-center justify-center cursor-pointer
711
- {isDropzoneDragOver
712
- ? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40'
713
- : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
714
- role="button"
715
- tabindex="0"
716
- onclick={() => openUploadModal(index)}
717
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openUploadModal(index); }}
718
- ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
719
- ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
720
- ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = false; }}
721
- ondrop={(e) => handleDropzoneDrop(e, index)}
722
- aria-label="Add photo - drag and drop or click to upload"
723
- >
724
- <PlusOutline class={currentSizeConfig.iconClass + ' text-gray-400 dark:text-gray-500'} />
725
- </div>
726
- {/if}
727
- </div>
728
- {/each}
729
- </div>
730
- {/key}
731
-
732
- {#if helperText}
733
- <p class={`${typography.smMuted} mt-3`}>{helperText}</p>
734
- {/if}
735
-
736
- {#if error}
737
- <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
738
- {/if}
739
- </div>
740
-
741
- <!-- Upload Modal for multi-image (native dropzone - more reliable than FilePond) -->
742
- {#if showFilePondModal}
743
- <!-- svelte-ignore a11y_click_events_have_key_events -->
744
- <!-- svelte-ignore a11y_no_static_element_interactions -->
745
- <div
746
- class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4"
747
- onclick={() => (showFilePondModal = false)}
748
- ondragover={(e) => e.preventDefault()}
749
- ondrop={(e) => e.preventDefault()}
750
- >
751
- <!-- svelte-ignore a11y_click_events_have_key_events -->
752
- <!-- svelte-ignore a11y_no_static_element_interactions -->
753
- <div
754
- class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"
755
- onclick={(e) => e.stopPropagation()}
756
- >
757
- <div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
758
- <h3 class={typography.h3}>Upload photo</h3>
759
- <button
760
- type="button"
761
- class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
762
- onclick={() => (showFilePondModal = false)}
763
- aria-label="Close"
764
- >
765
- <CloseOutline class="w-5 h-5" />
766
- </button>
767
- </div>
768
- <div class="p-4">
769
- <!-- Native dropzone with file input -->
770
- <div
771
- class="dropzone-native border-2 border-dashed rounded-lg transition-all duration-200 min-h-[200px] flex flex-col items-center justify-center cursor-pointer
772
- {isModalDragOver
773
- ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
774
- : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-900/20'}"
775
- role="button"
776
- tabindex="0"
777
- onclick={() => modalFileInput?.click()}
778
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') modalFileInput?.click(); }}
779
- ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
780
- ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
781
- ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = false; }}
782
- ondrop={(e) => handleModalDrop(e)}
783
- >
784
- <PlusOutline class="w-8 h-8 mb-2 text-gray-400" />
785
- <span class={typography.smMuted}>{emptyLabel}</span>
786
- <span class={`${typography.xsMuted} mt-1`}>JPG, PNG, WebP</span>
787
- </div>
788
- <input
789
- bind:this={modalFileInput}
790
- type="file"
791
- accept={acceptedTypes.join(',')}
792
- class="hidden"
793
- onchange={handleModalFileSelect}
794
- />
795
- </div>
796
- </div>
797
- </div>
798
- {/if}
799
- {/if}
800
-
801
- <!-- Crop Modal -->
802
- {#if enableCrop}
803
- <CropImage
804
- bind:showModal={showCropModal}
805
- imageSrc={imageForCrop}
806
- onSave={handleCropSave}
807
- onCancel={handleCropCancel}
808
- isUploadingImage={isUploading}
809
- />
810
- {/if}
811
-
812
- <style>
813
- /* FilePond customizations */
814
- :global(.filepond-wrapper .filepond--root) {
815
- font-family: inherit;
816
- min-height: 180px;
817
- }
818
-
819
- :global(.filepond-wrapper .filepond--panel-root) {
820
- background-color: transparent;
821
- }
822
-
823
- :global(.filepond-wrapper .filepond--drop-label) {
824
- color: inherit;
825
- min-height: 180px;
826
- display: flex;
827
- align-items: center;
828
- justify-content: center;
829
- }
830
-
831
- /* Note: FilePond dark mode is handled via runtime JS injection
832
- because Svelte's scoped :global() can't reliably override
833
- FilePond's CSS that loads from node_modules */
834
-
835
- :global(.filepond-wrapper .filepond--drop-label label) {
836
- display: flex;
837
- flex-direction: column;
838
- align-items: center;
839
- justify-content: center;
840
- width: 100%;
841
- height: 100%;
842
- cursor: pointer;
843
- }
844
-
845
- :global(.filepond-wrapper .filepond--label-action) {
846
- text-decoration: none;
847
- color: inherit;
848
- }
849
-
850
- /* FilePond drag hover state */
851
- :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
852
- border-color: rgb(59 130 246); /* blue-500 */
853
- background-color: rgb(239 246 255); /* blue-50 */
854
- }
855
-
856
- :global(.dark .filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
857
- background-color: rgba(30, 58, 138, 0.3); /* blue-900/30 */
858
- }
859
-
860
- :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"] .filepond--panel-root) {
861
- border-color: rgb(59 130 246); /* blue-500 */
862
- }
863
-
864
- /* Modal FilePond styling */
865
- :global(.filepond-wrapper) {
866
- border: 2px dashed rgb(209 213 219); /* gray-300 */
867
- border-radius: 0.5rem; /* rounded-lg */
868
- background-color: rgb(249 250 251); /* gray-50 */
869
- transition-property: color, background-color, border-color;
870
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
871
- transition-duration: 150ms;
872
- }
873
-
874
- :global(.dark .filepond-wrapper) {
875
- border-color: rgb(75 85 99); /* gray-600 */
876
- background-color: rgb(31 41 55); /* gray-800 */
877
- }
878
-
879
- :global(.filepond-wrapper:hover) {
880
- border-color: rgb(96 165 250); /* blue-400 */
881
- }
882
-
883
- /* Single mode dropzone styling */
884
- .filepond-wrapper-single {
885
- border: 2px dashed rgb(209 213 219); /* gray-300 */
886
- border-radius: 0.75rem; /* rounded-xl */
887
- background-color: rgb(249 250 251); /* gray-50 */
888
- transition-property: color, background-color, border-color;
889
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
890
- transition-duration: 150ms;
891
- }
892
-
893
- :global(.dark) .filepond-wrapper-single {
894
- border-color: rgb(75 85 99); /* gray-600 */
895
- background-color: rgb(31 41 55); /* gray-800 */
896
- }
897
-
898
- .filepond-wrapper-single:hover {
899
- border-color: rgb(96 165 250); /* blue-400 */
900
- background-color: rgb(239 246 255); /* blue-50 */
901
- }
902
-
903
- :global(.dark) .filepond-wrapper-single:hover {
904
- background-color: rgba(30, 58, 138, 0.2); /* blue-900/20 */
905
- }
906
-
907
- .filepond-wrapper-single :global(.filepond--root) {
908
- height: 100%;
909
- }
910
-
911
- .filepond-wrapper-single :global(.filepond--root .filepond--drop-label) {
912
- height: 100%;
913
- min-height: 0;
914
- }
915
-
916
- /* Wide shape specific styling */
917
- .filepond-wide {
918
- aspect-ratio: 16 / 9;
919
- }
920
-
921
- .filepond-wide :global(.filepond--root .filepond--drop-label label) {
922
- display: flex;
923
- flex-direction: column;
924
- align-items: center;
925
- justify-content: center;
926
- height: 100%;
927
- padding-top: 2rem;
928
- padding-bottom: 2rem;
929
- }
930
-
931
- /* Direct drag-over state for dropzone */
932
- .dropzone-drag-over {
933
- border-color: rgb(59 130 246); /* blue-500 */
934
- background-color: rgb(219 234 254); /* blue-100 */
935
- }
936
-
937
- :global(.dark) .dropzone-drag-over {
938
- background-color: rgba(30, 58, 138, 0.4); /* blue-900/40 */
939
- }
940
-
941
- /* Custom label styling inside FilePond */
942
- :global(.filepond-custom-label) {
943
- display: flex;
944
- flex-direction: column;
945
- align-items: center;
946
- justify-content: center;
947
- }
948
-
949
- /* Cursor for draggable items */
950
- .cursor-grab {
951
- cursor: grab;
952
- }
953
-
954
- .cursor-grab:active {
955
- cursor: grabbing;
956
- }
957
-
958
- /* SortableJS classes - must be single class names, not space-separated */
959
- :global(.sortable-ghost) {
960
- opacity: 0.5;
961
- }
962
-
963
- :global(.sortable-chosen) {
964
- box-shadow: 0 0 0 2px rgb(59 130 246); /* ring-2 ring-blue-500 */
965
- }
966
-
967
- :global(.sortable-drag) {
968
- cursor: grabbing;
969
- }
970
- </style>
1
+ <script lang="ts">
2
+ /**
3
+ * ImageUploader - Unified image upload component
4
+ *
5
+ * Supports:
6
+ * - Single image mode (event posters, venue images)
7
+ * - Multi-image mode with grid layout (performer photos)
8
+ * - Optional cropping with aspect ratio selection
9
+ * - Optional drag-to-reorder for multi-image
10
+ * - Different shapes: square, wide
11
+ * - Image compression before upload callback
12
+ */
13
+
14
+ import { browser } from '../../__LIB_ENVIRONMENT__.js';
15
+ import FilePond, { registerPlugin } from 'svelte-filepond';
16
+ import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
17
+ import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
18
+ import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
19
+ import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
20
+ import 'filepond/dist/filepond.min.css';
21
+ import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
22
+
23
+ import Sortable from 'sortablejs';
24
+ import CropImage from '../CropImage/CropImage.svelte';
25
+ import { optimizeImage } from '../../utils/imageOptimizer';
26
+ import Badge from '../../primitives/Badges/Badge.svelte';
27
+ import Button from '../../primitives/Button/Button.svelte';
28
+ import { CloseOutline, ImageOutline, PlusOutline, TrashBinOutline } from '../../primitives/Icons';
29
+ import { typography } from '../../tokens/typography';
30
+
31
+ // Register FilePond plugins once
32
+ registerPlugin(
33
+ FilePondPluginImagePreview,
34
+ FilePondPluginImageExifOrientation,
35
+ FilePondPluginFileValidateType,
36
+ FilePondPluginFileValidateSize
37
+ );
38
+
39
+ // Types
40
+ type Shape = 'square' | 'wide';
41
+ type AspectRatio = '1:1' | '2:1' | '16:9' | '4:3' | 'free';
42
+ type Size = 'sm' | 'md' | 'lg';
43
+
44
+ interface Props {
45
+ /** Single image or array of images (URLs) */
46
+ images?: string | string[];
47
+ /** Maximum number of images (only for multi mode) */
48
+ maxImages?: number;
49
+ /** Enable cropping step */
50
+ enableCrop?: boolean;
51
+ /** Crop aspect ratio */
52
+ cropAspectRatio?: AspectRatio;
53
+ /** Enable drag-to-reorder (only for multi mode) */
54
+ enableReorder?: boolean;
55
+ /** Dropzone shape - affects aspect ratio and styling */
56
+ shape?: Shape;
57
+ /** Size of image slots: sm (~64px), md (~96px), lg (~200px+) */
58
+ size?: Size;
59
+ /** Show "Main" badge on first image (multi mode) */
60
+ showMainBadge?: boolean;
61
+ /** Maximum file size (e.g., "10MB") */
62
+ maxFileSize?: string;
63
+ /** Accepted file types */
64
+ acceptedTypes?: string[];
65
+ /** Label shown when empty */
66
+ emptyLabel?: string;
67
+ /** Constraints text shown inside dropzone (e.g., "Max 10MB, 2160px") */
68
+ constraintsText?: string;
69
+ /** Helper text shown below dropzone */
70
+ helperText?: string;
71
+ /** Callback when image is uploaded - receives File, optionally returns URL
72
+ * If returns URL: component adds to images (self-managed)
73
+ * If returns void/null: parent must manage images (controlled) */
74
+ onUpload?: (file: File) => Promise<string | null | void> | void;
75
+ /** Callback when image is removed */
76
+ onRemove?: (index: number) => void;
77
+ /** Callback when images are reordered - receives new array */
78
+ onReorder?: (images: string[]) => void;
79
+ /** Callback when images are reordered - receives indices (for store integration) */
80
+ onReorderIndices?: (data: { from: number; to: number }) => void;
81
+ /** Callback when user clicks to set main image (moves to first position) */
82
+ onSetMain?: (data: { index: number }) => void;
83
+ /** Callback when images change (single source of truth) */
84
+ onchange?: (images: string[]) => void;
85
+ /** Show upload error */
86
+ error?: string;
87
+ /** Disabled state */
88
+ disabled?: boolean;
89
+ /** Custom class for wrapper */
90
+ class?: string;
91
+ }
92
+
93
+ let {
94
+ images = $bindable([]),
95
+ maxImages = 6,
96
+ enableCrop = false,
97
+ cropAspectRatio = '1:1',
98
+ enableReorder = true,
99
+ shape = 'square',
100
+ size = 'md',
101
+ showMainBadge = true,
102
+ maxFileSize = '20MB',
103
+ acceptedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
104
+ emptyLabel = 'Drag & drop or click to upload',
105
+ constraintsText = '',
106
+ helperText = '',
107
+ onUpload,
108
+ onRemove,
109
+ onReorder,
110
+ onReorderIndices,
111
+ onSetMain,
112
+ onchange,
113
+ error = '',
114
+ disabled = false,
115
+ class: className = '',
116
+ }: Props = $props();
117
+
118
+ // Normalize images to array
119
+ let imageArray = $derived(
120
+ Array.isArray(images)
121
+ ? images.filter(Boolean)
122
+ : images
123
+ ? [images]
124
+ : []
125
+ );
126
+
127
+ // Determine mode based on maxImages
128
+ let isMultiMode = $derived(maxImages > 1);
129
+ let canAddMore = $derived(imageArray.length < maxImages);
130
+
131
+ // FilePond state
132
+ let filePondFiles = $state<unknown[]>([]);
133
+ let filePondInstance = $state<unknown>(undefined);
134
+ let showFilePondModal = $state(false);
135
+ let targetSlotIndex = $state<number | null>(null);
136
+
137
+ // Crop state
138
+ let showCropModal = $state(false);
139
+ let imageForCrop = $state('');
140
+ let isUploading = $state(false);
141
+
142
+ // Reorder state
143
+ let gridContainer = $state<HTMLDivElement | undefined>(undefined);
144
+ let sortableInstance: Sortable | null = null;
145
+ let isDragging = $state(false);
146
+ let gridRenderKey = $state(0);
147
+
148
+ // Direct drag-drop state for dropzone
149
+ let isDropzoneDragOver = $state(false);
150
+
151
+ // Native modal dropzone state
152
+ let isModalDragOver = $state(false);
153
+ let modalFileInput = $state<HTMLInputElement | undefined>(undefined);
154
+
155
+ // Single-mode file input for replace functionality
156
+ let singleModeFileInput = $state<HTMLInputElement | undefined>(undefined);
157
+
158
+ // Prevent browser from opening dragged files in new tab
159
+ // This MUST be at document level to intercept before browser default behavior
160
+ $effect(() => {
161
+ if (!browser) return;
162
+
163
+ const preventBrowserDefault = (e: DragEvent) => {
164
+ // Only prevent if it's a file drag (not text selection, etc.)
165
+ if (e.dataTransfer?.types?.includes('Files')) {
166
+ e.preventDefault();
167
+ }
168
+ };
169
+
170
+ window.addEventListener('dragover', preventBrowserDefault, true);
171
+ window.addEventListener('drop', preventBrowserDefault, true);
172
+
173
+ return () => {
174
+ window.removeEventListener('dragover', preventBrowserDefault, true);
175
+ window.removeEventListener('drop', preventBrowserDefault, true);
176
+ };
177
+ });
178
+
179
+ // Shape to aspect ratio mapping
180
+ const shapeAspects: Record<Shape, string> = {
181
+ square: 'aspect-square',
182
+ wide: 'aspect-video',
183
+ };
184
+
185
+ // Size configuration - layout classes, slot width, and element sizes
186
+ const sizeConfig: Record<Size, { containerClass: string; slotClass: string; iconClass: string; badgeSize: 'small' | 'medium'; removeSize: string }> = {
187
+ sm: {
188
+ containerClass: 'flex flex-wrap gap-1.5',
189
+ slotClass: 'w-16 h-16', // 64px fixed
190
+ iconClass: 'w-4 h-4',
191
+ badgeSize: 'small',
192
+ removeSize: 'w-4 h-4',
193
+ },
194
+ md: {
195
+ containerClass: 'flex flex-wrap gap-2',
196
+ slotClass: 'w-24 h-24', // 96px fixed
197
+ iconClass: 'w-5 h-5',
198
+ badgeSize: 'small',
199
+ removeSize: 'w-5 h-5',
200
+ },
201
+ lg: {
202
+ containerClass: 'grid grid-cols-3 gap-4',
203
+ slotClass: '', // Let grid control width (uses aspect-square)
204
+ iconClass: 'w-8 h-8',
205
+ badgeSize: 'medium',
206
+ removeSize: 'w-6 h-6',
207
+ },
208
+ };
209
+
210
+ // Get current size config
211
+ let currentSizeConfig = $derived(sizeConfig[size]);
212
+
213
+ // Crop aspect ratio to number
214
+ const aspectRatioValues: Record<AspectRatio, number> = {
215
+ '1:1': 1,
216
+ '2:1': 2,
217
+ '16:9': 16 / 9,
218
+ '4:3': 4 / 3,
219
+ 'free': 0,
220
+ };
221
+
222
+ // FilePond options
223
+ const filePondOptions = $derived({
224
+ allowMultiple: false,
225
+ maxFiles: 1,
226
+ acceptedFileTypes: acceptedTypes,
227
+ maxFileSize: maxFileSize,
228
+ // Drag-drop configuration
229
+ allowDrop: true,
230
+ dropOnPage: false, // Only accept drops on the FilePond element
231
+ dropOnElement: true,
232
+ labelIdle: `
233
+ <div class="filepond-custom-label">
234
+ <svg class="w-8 h-8 mb-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
235
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
236
+ </svg>
237
+ <span class="text-sm font-medium text-gray-600 dark:text-gray-300">${emptyLabel}</span>
238
+ <span class="text-[11px] text-gray-400 dark:text-gray-500 mt-2 block">JPG, PNG, WebP</span>
239
+ ${constraintsText ? `<span class="text-[10px] text-gray-400/50 dark:text-gray-500/50 mt-1 block">${constraintsText}</span>` : ''}
240
+ </div>
241
+ `,
242
+ credits: false,
243
+ instantUpload: false,
244
+ allowRevert: true,
245
+ allowProcess: false,
246
+ imagePreviewHeight: shape === 'wide' ? 150 : 200,
247
+ stylePanelLayout: 'compact',
248
+ stylePanelAspectRatio: shape === 'wide' ? '16:9' : '1:1',
249
+ });
250
+
251
+ // Handle FilePond file add
252
+ function handleFilePondAddFile(err: unknown, fileItem: unknown) {
253
+ if (err) {
254
+ console.error('FilePond error:', err);
255
+ return;
256
+ }
257
+
258
+ const item = fileItem as { file: File } | undefined;
259
+ const file = item?.file;
260
+ if (!file) return;
261
+
262
+ if (enableCrop) {
263
+ // Show crop modal
264
+ const objectUrl = URL.createObjectURL(file);
265
+ imageForCrop = objectUrl;
266
+ showCropModal = true;
267
+ } else {
268
+ // Optimize then upload
269
+ optimizeImage(file).then((optimizedFile) => {
270
+ handleUpload(optimizedFile);
271
+ });
272
+ }
273
+
274
+ // Clear FilePond
275
+ setTimeout(() => {
276
+ if (filePondInstance && typeof (filePondInstance as { removeFiles: () => void }).removeFiles === 'function') {
277
+ (filePondInstance as { removeFiles: () => void }).removeFiles();
278
+ }
279
+ }, 100);
280
+
281
+ showFilePondModal = false;
282
+ }
283
+
284
+ // Handle crop save
285
+ async function handleCropSave(croppedFile: File) {
286
+ showCropModal = false;
287
+ if (imageForCrop.startsWith('blob:')) {
288
+ URL.revokeObjectURL(imageForCrop);
289
+ }
290
+ imageForCrop = '';
291
+ await handleUpload(croppedFile);
292
+ }
293
+
294
+ // Handle crop cancel
295
+ function handleCropCancel() {
296
+ showCropModal = false;
297
+ if (imageForCrop.startsWith('blob:')) {
298
+ URL.revokeObjectURL(imageForCrop);
299
+ }
300
+ imageForCrop = '';
301
+ }
302
+
303
+ // Handle upload
304
+ // Supports two modes:
305
+ // 1. Self-managed: onUpload returns URL, component adds to images
306
+ // 2. Controlled: onUpload returns void, parent manages images array
307
+ async function handleUpload(file: File) {
308
+ if (!onUpload) {
309
+ console.warn('ImageUploader: onUpload callback not provided');
310
+ return;
311
+ }
312
+
313
+ isUploading = true;
314
+
315
+ try {
316
+ const result = await onUpload(file);
317
+
318
+ // If onUpload returns a URL, component manages adding to images (self-managed mode)
319
+ // If onUpload returns void/null, parent is expected to manage images (controlled mode)
320
+ if (typeof result === 'string' && result) {
321
+ const url = result;
322
+ const newImages = [...imageArray];
323
+ if (targetSlotIndex !== null && targetSlotIndex < newImages.length) {
324
+ // Replace existing image
325
+ newImages[targetSlotIndex] = url;
326
+ } else {
327
+ // Add new image
328
+ newImages.push(url);
329
+ }
330
+
331
+ // Update images and notify
332
+ if (Array.isArray(images)) {
333
+ images = newImages;
334
+ } else {
335
+ images = newImages[0] || '';
336
+ }
337
+ onchange?.(newImages);
338
+ }
339
+ // If result is void/null/undefined, parent handles adding to images
340
+ } catch (err) {
341
+ console.error('Upload failed:', err);
342
+ } finally {
343
+ isUploading = false;
344
+ targetSlotIndex = null;
345
+ }
346
+ }
347
+
348
+ // Handle remove
349
+ // In controlled mode (onReorderIndices provided), just fire callback
350
+ // In self-managed mode, update images internally
351
+ function handleRemoveImage(index: number) {
352
+ // Always fire the callback
353
+ onRemove?.(index);
354
+
355
+ // Only manage state internally if not in controlled mode
356
+ if (!onReorderIndices) {
357
+ const newImages = [...imageArray];
358
+ newImages.splice(index, 1);
359
+
360
+ if (Array.isArray(images)) {
361
+ images = newImages;
362
+ } else {
363
+ images = newImages[0] || '';
364
+ }
365
+
366
+ onchange?.(newImages);
367
+ }
368
+
369
+ gridRenderKey++;
370
+ }
371
+
372
+ // Open upload modal/dropzone
373
+ function openUploadModal(slotIndex?: number) {
374
+ if (disabled) return;
375
+ targetSlotIndex = slotIndex ?? null;
376
+ if (isMultiMode) {
377
+ showFilePondModal = true;
378
+ }
379
+ }
380
+
381
+ // Handle direct drag-drop on dropzone (bypasses modal)
382
+ function handleDropzoneDragOver(e: DragEvent) {
383
+ e.preventDefault();
384
+ e.stopPropagation();
385
+ if (disabled || !canAddMore) return;
386
+ isDropzoneDragOver = true;
387
+ }
388
+
389
+ function handleDropzoneDragLeave(e: DragEvent) {
390
+ e.preventDefault();
391
+ e.stopPropagation();
392
+ isDropzoneDragOver = false;
393
+ }
394
+
395
+ function handleDropzoneDrop(e: DragEvent, slotIndex?: number) {
396
+ e.preventDefault();
397
+ e.stopPropagation();
398
+ isDropzoneDragOver = false;
399
+
400
+ if (disabled || !canAddMore) return;
401
+
402
+ const files = e.dataTransfer?.files;
403
+ if (!files || files.length === 0) return;
404
+
405
+ const file = files[0];
406
+
407
+ // Check if it's an accepted image type
408
+ if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
409
+ console.warn('File type not accepted:', file.type);
410
+ return;
411
+ }
412
+
413
+ targetSlotIndex = slotIndex ?? null;
414
+
415
+ if (enableCrop) {
416
+ // Show crop modal
417
+ const objectUrl = URL.createObjectURL(file);
418
+ imageForCrop = objectUrl;
419
+ showCropModal = true;
420
+ } else {
421
+ // Optimize then upload
422
+ optimizeImage(file).then((optimizedFile) => {
423
+ handleUpload(optimizedFile);
424
+ });
425
+ }
426
+ }
427
+
428
+ // Handle modal dropzone drop
429
+ function handleModalDrop(e: DragEvent) {
430
+ e.preventDefault();
431
+ e.stopPropagation();
432
+ isModalDragOver = false;
433
+ showFilePondModal = false;
434
+
435
+ if (disabled || !canAddMore) return;
436
+
437
+ const files = e.dataTransfer?.files;
438
+ if (!files || files.length === 0) return;
439
+
440
+ const file = files[0];
441
+
442
+ // Check if it's an accepted image type
443
+ if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
444
+ console.warn('File type not accepted:', file.type);
445
+ return;
446
+ }
447
+
448
+ if (enableCrop) {
449
+ const objectUrl = URL.createObjectURL(file);
450
+ imageForCrop = objectUrl;
451
+ showCropModal = true;
452
+ } else {
453
+ optimizeImage(file).then((optimizedFile) => {
454
+ handleUpload(optimizedFile);
455
+ });
456
+ }
457
+ }
458
+
459
+ // Handle modal file input selection
460
+ function handleModalFileSelect(e: Event) {
461
+ const input = e.target as HTMLInputElement;
462
+ const file = input?.files?.[0];
463
+ if (!file) return;
464
+
465
+ showFilePondModal = false;
466
+
467
+ if (enableCrop) {
468
+ const objectUrl = URL.createObjectURL(file);
469
+ imageForCrop = objectUrl;
470
+ showCropModal = true;
471
+ } else {
472
+ optimizeImage(file).then((optimizedFile) => {
473
+ handleUpload(optimizedFile);
474
+ });
475
+ }
476
+
477
+ // Clear input for next selection
478
+ input.value = '';
479
+ }
480
+
481
+ // Handle single-mode file input selection (for replace)
482
+ function handleSingleModeFileSelect(e: Event) {
483
+ const input = e.target as HTMLInputElement;
484
+ const file = input?.files?.[0];
485
+ if (!file) return;
486
+
487
+ targetSlotIndex = 0; // Replace the existing image
488
+
489
+ if (enableCrop) {
490
+ const objectUrl = URL.createObjectURL(file);
491
+ imageForCrop = objectUrl;
492
+ showCropModal = true;
493
+ } else {
494
+ optimizeImage(file).then((optimizedFile) => {
495
+ handleUpload(optimizedFile);
496
+ });
497
+ }
498
+
499
+ // Clear input for next selection
500
+ input.value = '';
501
+ }
502
+
503
+ // Initialize sortable for multi-image grid
504
+ function initSortable() {
505
+ if (!browser || !gridContainer || !enableReorder || !isMultiMode) return;
506
+
507
+ if (sortableInstance) {
508
+ sortableInstance.destroy();
509
+ }
510
+
511
+ sortableInstance = new Sortable(gridContainer, {
512
+ animation: 200,
513
+ ghostClass: 'sortable-ghost',
514
+ chosenClass: 'sortable-chosen',
515
+ dragClass: 'sortable-drag',
516
+ delay: 150,
517
+ delayOnTouchOnly: true,
518
+ touchStartThreshold: 5,
519
+ filter: '.empty-slot, .remove-btn',
520
+ preventOnFilter: false,
521
+ onStart: () => {
522
+ isDragging = true;
523
+ },
524
+ onEnd: (evt) => {
525
+ isDragging = false;
526
+ const { oldIndex, newIndex } = evt;
527
+ if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
528
+
529
+ // Fire index-based callback for store integration (controlled mode)
530
+ onReorderIndices?.({ from: oldIndex, to: newIndex });
531
+
532
+ // For self-managed mode, update images internally
533
+ if (!onReorderIndices) {
534
+ const newImages = [...imageArray];
535
+ const [moved] = newImages.splice(oldIndex, 1);
536
+ newImages.splice(newIndex, 0, moved);
537
+
538
+ if (Array.isArray(images)) {
539
+ images = newImages;
540
+ }
541
+
542
+ onReorder?.(newImages);
543
+ onchange?.(newImages);
544
+ }
545
+
546
+ gridRenderKey++;
547
+
548
+ // Re-init after state update
549
+ if (sortableInstance) {
550
+ sortableInstance.destroy();
551
+ sortableInstance = null;
552
+ }
553
+ },
554
+ });
555
+ }
556
+
557
+ // Setup sortable on mount and when images change
558
+ $effect(() => {
559
+ if (browser && gridContainer && !isDragging && isMultiMode && enableReorder) {
560
+ // Use setTimeout to ensure DOM is ready
561
+ setTimeout(initSortable, 0);
562
+ }
563
+ });
564
+
565
+ // Cleanup
566
+ $effect(() => {
567
+ return () => {
568
+ if (sortableInstance) {
569
+ sortableInstance.destroy();
570
+ }
571
+ };
572
+ });
573
+
574
+ // Generate slots with progressive disclosure
575
+ // Only show filled images + 1 empty slot (up to maxImages)
576
+ let slots = $derived.by(() => {
577
+ const slotCount = Math.min(imageArray.length + 1, maxImages);
578
+ return Array.from({ length: slotCount }, (_, i) => ({
579
+ id: `slot-${i}`,
580
+ image: imageArray[i] || null,
581
+ isNext: i === imageArray.length,
582
+ }));
583
+ });
584
+ </script>
585
+
586
+
587
+ <!-- Single Image Mode -->
588
+ {#if !isMultiMode}
589
+ <div class="image-uploader-single {className}">
590
+ {#if imageArray.length > 0}
591
+ <!-- Show existing image with remove option -->
592
+ <div class="relative {shapeAspects[shape]} bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden group">
593
+ <img
594
+ src={imageArray[0]}
595
+ alt="Uploaded"
596
+ class="w-full h-full object-cover"
597
+ />
598
+ {#if !disabled}
599
+ <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
600
+ <Button
601
+ variant="default"
602
+ size="sm"
603
+ onclick={() => singleModeFileInput?.click()}
604
+ >
605
+ <ImageOutline class="w-4 h-4 mr-1.5" />
606
+ Replace
607
+ </Button>
608
+ <Button
609
+ variant="red"
610
+ size="sm"
611
+ onclick={() => handleRemoveImage(0)}
612
+ >
613
+ <TrashBinOutline class="w-4 h-4 mr-1.5" />
614
+ Delete
615
+ </Button>
616
+ </div>
617
+ <!-- Hidden file input for replace -->
618
+ <input
619
+ bind:this={singleModeFileInput}
620
+ type="file"
621
+ accept={acceptedTypes.join(',')}
622
+ class="hidden"
623
+ onchange={handleSingleModeFileSelect}
624
+ />
625
+ {/if}
626
+ </div>
627
+ {:else}
628
+ <!-- Empty dropzone with direct drag-drop support -->
629
+ <div
630
+ class="{shapeAspects[shape]} filepond-wrapper-single {shape === 'wide' ? 'filepond-wide' : ''}
631
+ {isDropzoneDragOver ? 'dropzone-drag-over' : ''}"
632
+ role="button"
633
+ tabindex="0"
634
+ ondragover={handleDropzoneDragOver}
635
+ ondragleave={handleDropzoneDragLeave}
636
+ ondrop={(e) => handleDropzoneDrop(e, 0)}
637
+ >
638
+ <FilePond
639
+ bind:this={filePondInstance}
640
+ bind:files={filePondFiles}
641
+ {...filePondOptions}
642
+ onaddfile={handleFilePondAddFile}
643
+ />
644
+ </div>
645
+ {/if}
646
+
647
+ {#if helperText}
648
+ <p class={`${typography.smMuted} mt-2`}>{helperText}</p>
649
+ {/if}
650
+
651
+ {#if error}
652
+ <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
653
+ {/if}
654
+ </div>
655
+
656
+ <!-- Multi Image Mode (Grid) -->
657
+ {:else}
658
+ <div class="image-uploader-multi {className}">
659
+ {#key gridRenderKey}
660
+ <div
661
+ bind:this={gridContainer}
662
+ class="{currentSizeConfig.containerClass}"
663
+ >
664
+ {#each slots as slot, index (slot.id)}
665
+ <div
666
+ class="relative {size === 'lg' ? shapeAspects[shape] : ''} {currentSizeConfig.slotClass} rounded-lg overflow-hidden"
667
+ class:cursor-grab={slot.image && enableReorder && !disabled}
668
+ class:empty-slot={!slot.image}
669
+ data-slot-id={slot.id}
670
+ >
671
+ {#if slot.image}
672
+ <!-- Filled slot -->
673
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
674
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
675
+ <div
676
+ class="relative w-full h-full bg-gray-100 dark:bg-gray-800 group"
677
+ class:cursor-pointer={onSetMain && index !== 0 && !disabled}
678
+ onclick={() => {
679
+ if (onSetMain && index !== 0 && !disabled) {
680
+ onSetMain({ index });
681
+ }
682
+ }}
683
+ >
684
+ <img
685
+ src={slot.image}
686
+ alt="Uploaded"
687
+ class="w-full h-full object-cover pointer-events-none"
688
+ draggable="false"
689
+ />
690
+ {#if showMainBadge && index === 0}
691
+ <div class="absolute bottom-1 left-1 z-10 pointer-events-none">
692
+ <Badge variant="info" size={currentSizeConfig.badgeSize}>Main</Badge>
693
+ </div>
694
+ {/if}
695
+ {#if !disabled}
696
+ <button
697
+ type="button"
698
+ class="remove-btn absolute top-1 right-1 {currentSizeConfig.removeSize} bg-white/90 dark:bg-gray-900/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-900"
699
+ onclick={(e) => { e.stopPropagation(); handleRemoveImage(index); }}
700
+ aria-label="Remove photo"
701
+ >
702
+ <CloseOutline class="w-3 h-3 text-gray-700 dark:text-gray-300" />
703
+ </button>
704
+ {/if}
705
+ </div>
706
+ {:else if slot.isNext && canAddMore && !disabled}
707
+ <!-- Next available slot - dropzone with direct drag-drop support -->
708
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
709
+ <div
710
+ class="w-full h-full border-2 border-dashed rounded-lg transition-colors flex items-center justify-center cursor-pointer
711
+ {isDropzoneDragOver
712
+ ? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40'
713
+ : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
714
+ role="button"
715
+ tabindex="0"
716
+ onclick={() => openUploadModal(index)}
717
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openUploadModal(index); }}
718
+ ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
719
+ ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
720
+ ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = false; }}
721
+ ondrop={(e) => handleDropzoneDrop(e, index)}
722
+ aria-label="Add photo - drag and drop or click to upload"
723
+ >
724
+ <PlusOutline class={currentSizeConfig.iconClass + ' text-gray-400 dark:text-gray-500'} />
725
+ </div>
726
+ {/if}
727
+ </div>
728
+ {/each}
729
+ </div>
730
+ {/key}
731
+
732
+ {#if helperText}
733
+ <p class={`${typography.smMuted} mt-3`}>{helperText}</p>
734
+ {/if}
735
+
736
+ {#if error}
737
+ <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
738
+ {/if}
739
+ </div>
740
+
741
+ <!-- Upload Modal for multi-image (native dropzone - more reliable than FilePond) -->
742
+ {#if showFilePondModal}
743
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
744
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
745
+ <div
746
+ class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4"
747
+ onclick={() => (showFilePondModal = false)}
748
+ ondragover={(e) => e.preventDefault()}
749
+ ondrop={(e) => e.preventDefault()}
750
+ >
751
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
752
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
753
+ <div
754
+ class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"
755
+ onclick={(e) => e.stopPropagation()}
756
+ >
757
+ <div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
758
+ <h3 class={typography.h3}>Upload photo</h3>
759
+ <button
760
+ type="button"
761
+ class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
762
+ onclick={() => (showFilePondModal = false)}
763
+ aria-label="Close"
764
+ >
765
+ <CloseOutline class="w-5 h-5" />
766
+ </button>
767
+ </div>
768
+ <div class="p-4">
769
+ <!-- Native dropzone with file input -->
770
+ <div
771
+ class="dropzone-native border-2 border-dashed rounded-lg transition-all duration-200 min-h-[200px] flex flex-col items-center justify-center cursor-pointer
772
+ {isModalDragOver
773
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
774
+ : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-900/20'}"
775
+ role="button"
776
+ tabindex="0"
777
+ onclick={() => modalFileInput?.click()}
778
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') modalFileInput?.click(); }}
779
+ ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
780
+ ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
781
+ ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = false; }}
782
+ ondrop={(e) => handleModalDrop(e)}
783
+ >
784
+ <PlusOutline class="w-8 h-8 mb-2 text-gray-400" />
785
+ <span class={typography.smMuted}>{emptyLabel}</span>
786
+ <span class={`${typography.xsMuted} mt-1`}>JPG, PNG, WebP</span>
787
+ </div>
788
+ <input
789
+ bind:this={modalFileInput}
790
+ type="file"
791
+ accept={acceptedTypes.join(',')}
792
+ class="hidden"
793
+ onchange={handleModalFileSelect}
794
+ />
795
+ </div>
796
+ </div>
797
+ </div>
798
+ {/if}
799
+ {/if}
800
+
801
+ <!-- Crop Modal -->
802
+ {#if enableCrop}
803
+ <CropImage
804
+ bind:showModal={showCropModal}
805
+ imageSrc={imageForCrop}
806
+ onSave={handleCropSave}
807
+ onCancel={handleCropCancel}
808
+ isUploadingImage={isUploading}
809
+ />
810
+ {/if}
811
+
812
+ <style>
813
+ /* FilePond customizations */
814
+ :global(.filepond-wrapper .filepond--root) {
815
+ font-family: inherit;
816
+ min-height: 180px;
817
+ }
818
+
819
+ :global(.filepond-wrapper .filepond--panel-root) {
820
+ background-color: transparent;
821
+ }
822
+
823
+ :global(.filepond-wrapper .filepond--drop-label) {
824
+ color: inherit;
825
+ min-height: 180px;
826
+ display: flex;
827
+ align-items: center;
828
+ justify-content: center;
829
+ }
830
+
831
+ /* Note: FilePond dark mode is handled via runtime JS injection
832
+ because Svelte's scoped :global() can't reliably override
833
+ FilePond's CSS that loads from node_modules */
834
+
835
+ :global(.filepond-wrapper .filepond--drop-label label) {
836
+ display: flex;
837
+ flex-direction: column;
838
+ align-items: center;
839
+ justify-content: center;
840
+ width: 100%;
841
+ height: 100%;
842
+ cursor: pointer;
843
+ }
844
+
845
+ :global(.filepond-wrapper .filepond--label-action) {
846
+ text-decoration: none;
847
+ color: inherit;
848
+ }
849
+
850
+ /* FilePond drag hover state */
851
+ :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
852
+ border-color: rgb(59 130 246); /* blue-500 */
853
+ background-color: rgb(239 246 255); /* blue-50 */
854
+ }
855
+
856
+ :global(.dark .filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
857
+ background-color: rgba(30, 58, 138, 0.3); /* blue-900/30 */
858
+ }
859
+
860
+ :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"] .filepond--panel-root) {
861
+ border-color: rgb(59 130 246); /* blue-500 */
862
+ }
863
+
864
+ /* Modal FilePond styling */
865
+ :global(.filepond-wrapper) {
866
+ border: 2px dashed rgb(209 213 219); /* gray-300 */
867
+ border-radius: 0.5rem; /* rounded-lg */
868
+ background-color: rgb(249 250 251); /* gray-50 */
869
+ transition-property: color, background-color, border-color;
870
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
871
+ transition-duration: 150ms;
872
+ }
873
+
874
+ :global(.dark .filepond-wrapper) {
875
+ border-color: rgb(75 85 99); /* gray-600 */
876
+ background-color: rgb(31 41 55); /* gray-800 */
877
+ }
878
+
879
+ :global(.filepond-wrapper:hover) {
880
+ border-color: rgb(96 165 250); /* blue-400 */
881
+ }
882
+
883
+ /* Single mode dropzone styling */
884
+ .filepond-wrapper-single {
885
+ border: 2px dashed rgb(209 213 219); /* gray-300 */
886
+ border-radius: 0.75rem; /* rounded-xl */
887
+ background-color: rgb(249 250 251); /* gray-50 */
888
+ transition-property: color, background-color, border-color;
889
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
890
+ transition-duration: 150ms;
891
+ }
892
+
893
+ :global(.dark) .filepond-wrapper-single {
894
+ border-color: rgb(75 85 99); /* gray-600 */
895
+ background-color: rgb(31 41 55); /* gray-800 */
896
+ }
897
+
898
+ .filepond-wrapper-single:hover {
899
+ border-color: rgb(96 165 250); /* blue-400 */
900
+ background-color: rgb(239 246 255); /* blue-50 */
901
+ }
902
+
903
+ :global(.dark) .filepond-wrapper-single:hover {
904
+ background-color: rgba(30, 58, 138, 0.2); /* blue-900/20 */
905
+ }
906
+
907
+ .filepond-wrapper-single :global(.filepond--root) {
908
+ height: 100%;
909
+ }
910
+
911
+ .filepond-wrapper-single :global(.filepond--root .filepond--drop-label) {
912
+ height: 100%;
913
+ min-height: 0;
914
+ }
915
+
916
+ /* Wide shape specific styling */
917
+ .filepond-wide {
918
+ aspect-ratio: 16 / 9;
919
+ }
920
+
921
+ .filepond-wide :global(.filepond--root .filepond--drop-label label) {
922
+ display: flex;
923
+ flex-direction: column;
924
+ align-items: center;
925
+ justify-content: center;
926
+ height: 100%;
927
+ padding-top: 2rem;
928
+ padding-bottom: 2rem;
929
+ }
930
+
931
+ /* Direct drag-over state for dropzone */
932
+ .dropzone-drag-over {
933
+ border-color: rgb(59 130 246); /* blue-500 */
934
+ background-color: rgb(219 234 254); /* blue-100 */
935
+ }
936
+
937
+ :global(.dark) .dropzone-drag-over {
938
+ background-color: rgba(30, 58, 138, 0.4); /* blue-900/40 */
939
+ }
940
+
941
+ /* Custom label styling inside FilePond */
942
+ :global(.filepond-custom-label) {
943
+ display: flex;
944
+ flex-direction: column;
945
+ align-items: center;
946
+ justify-content: center;
947
+ }
948
+
949
+ /* Cursor for draggable items */
950
+ .cursor-grab {
951
+ cursor: grab;
952
+ }
953
+
954
+ .cursor-grab:active {
955
+ cursor: grabbing;
956
+ }
957
+
958
+ /* SortableJS classes - must be single class names, not space-separated */
959
+ :global(.sortable-ghost) {
960
+ opacity: 0.5;
961
+ }
962
+
963
+ :global(.sortable-chosen) {
964
+ box-shadow: 0 0 0 2px rgb(59 130 246); /* ring-2 ring-blue-500 */
965
+ }
966
+
967
+ :global(.sortable-drag) {
968
+ cursor: grabbing;
969
+ }
970
+ </style>