@getmicdrop/svelte-components 5.3.10 → 5.3.13

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