@getmicdrop/svelte-components 5.3.12 → 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 (248) 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 +145 -145
  7. package/dist/calendar/ShowCard/ShowCard.svelte +157 -157
  8. package/dist/calendar/ShowTimeCard/ShowTimeCard.svelte +61 -61
  9. package/dist/components/Layout/Grid.svelte +109 -109
  10. package/dist/components/Layout/Section.svelte +80 -80
  11. package/dist/components/Layout/Sidebar.svelte +108 -108
  12. package/dist/components/Layout/Stack.svelte +90 -90
  13. package/dist/constants/formOptions.js +26 -26
  14. package/dist/constants/validation.js +91 -91
  15. package/dist/constants/validation.spec.js +64 -64
  16. package/dist/datetime/__tests__/format.test.d.ts +2 -0
  17. package/dist/datetime/__tests__/format.test.d.ts.map +1 -0
  18. package/dist/datetime/__tests__/format.test.js +268 -0
  19. package/dist/datetime/__tests__/integration.test.d.ts +2 -0
  20. package/dist/datetime/__tests__/integration.test.d.ts.map +1 -0
  21. package/dist/datetime/__tests__/integration.test.js +243 -0
  22. package/dist/datetime/__tests__/parse.test.d.ts +2 -0
  23. package/dist/datetime/__tests__/parse.test.d.ts.map +1 -0
  24. package/dist/datetime/__tests__/parse.test.js +261 -0
  25. package/dist/datetime/__tests__/timezone.test.d.ts +2 -0
  26. package/dist/datetime/__tests__/timezone.test.d.ts.map +1 -0
  27. package/dist/datetime/__tests__/timezone.test.js +214 -0
  28. package/dist/datetime/constants.d.ts +133 -0
  29. package/dist/datetime/constants.d.ts.map +1 -0
  30. package/dist/datetime/constants.js +112 -0
  31. package/dist/datetime/format.d.ts +158 -0
  32. package/dist/datetime/format.d.ts.map +1 -0
  33. package/dist/datetime/format.js +315 -0
  34. package/dist/datetime/index.d.ts +42 -0
  35. package/dist/datetime/index.d.ts.map +1 -0
  36. package/dist/datetime/index.js +44 -0
  37. package/dist/datetime/parse.d.ts +149 -0
  38. package/dist/datetime/parse.d.ts.map +1 -0
  39. package/dist/datetime/parse.js +276 -0
  40. package/dist/datetime/timezone.d.ts +95 -0
  41. package/dist/datetime/timezone.d.ts.map +1 -0
  42. package/dist/datetime/timezone.js +241 -0
  43. package/dist/datetime/types.d.ts +105 -0
  44. package/dist/datetime/types.d.ts.map +1 -0
  45. package/dist/datetime/types.js +31 -0
  46. package/dist/index.d.ts +10 -0
  47. package/dist/index.js +232 -218
  48. package/dist/patterns/data/DataGrid.svelte +45 -45
  49. package/dist/patterns/data/DataList.svelte +24 -24
  50. package/dist/patterns/data/DataTable.svelte +40 -40
  51. package/dist/patterns/forms/FormActions.spec.js +88 -88
  52. package/dist/patterns/forms/FormActions.stories.svelte +97 -97
  53. package/dist/patterns/forms/FormActions.svelte +46 -46
  54. package/dist/patterns/forms/FormGrid.svelte +33 -33
  55. package/dist/patterns/forms/FormSection.svelte +32 -32
  56. package/dist/patterns/forms/FormValidationSummary.spec.js +203 -203
  57. package/dist/patterns/forms/FormValidationSummary.stories.svelte +97 -97
  58. package/dist/patterns/forms/FormValidationSummary.svelte +67 -67
  59. package/dist/patterns/layout/Grid.svelte +35 -35
  60. package/dist/patterns/layout/Sidebar.svelte +39 -39
  61. package/dist/patterns/layout/Stack.svelte +45 -45
  62. package/dist/patterns/navigation/BottomNav.spec.js +130 -130
  63. package/dist/patterns/navigation/BottomNav.stories.svelte +117 -117
  64. package/dist/patterns/navigation/BottomNav.svelte +54 -54
  65. package/dist/patterns/navigation/Header.spec.js +203 -203
  66. package/dist/patterns/navigation/Header.stories.svelte +77 -77
  67. package/dist/patterns/navigation/Header.svelte +240 -240
  68. package/dist/patterns/page/PageHeader.svelte +36 -36
  69. package/dist/patterns/page/PageLayout.svelte +40 -40
  70. package/dist/patterns/page/PageLoader.spec.js +54 -54
  71. package/dist/patterns/page/PageLoader.stories.svelte +137 -137
  72. package/dist/patterns/page/PageLoader.svelte +41 -41
  73. package/dist/patterns/page/SectionHeader.svelte +41 -41
  74. package/dist/presets/badges.js +112 -112
  75. package/dist/presets/buttons.js +76 -76
  76. package/dist/presets/index.js +9 -9
  77. package/dist/primitives/Accordion/Accordion.stories.svelte +75 -75
  78. package/dist/primitives/Accordion/Accordion.svelte +61 -61
  79. package/dist/primitives/Accordion/AccordionItem.svelte +95 -95
  80. package/dist/primitives/Alert/Alert.spec.js +170 -170
  81. package/dist/primitives/Alert/Alert.stories.svelte +88 -88
  82. package/dist/primitives/Alert/Alert.svelte +65 -65
  83. package/dist/primitives/Avatar/Avatar.stories.svelte +94 -94
  84. package/dist/primitives/Avatar/Avatar.svelte +66 -66
  85. package/dist/primitives/Badges/Badge.spec.js +103 -103
  86. package/dist/primitives/Badges/Badge.stories.svelte +86 -86
  87. package/dist/primitives/Badges/Badge.svelte +142 -142
  88. package/dist/primitives/BottomSheet/BottomSheet.spec.js +127 -127
  89. package/dist/primitives/BottomSheet/BottomSheet.stories.svelte +83 -83
  90. package/dist/primitives/BottomSheet/BottomSheet.svelte +100 -100
  91. package/dist/primitives/Breadcrumb/Breadcrumb.spec.js +120 -120
  92. package/dist/primitives/Breadcrumb/Breadcrumb.stories.svelte +23 -23
  93. package/dist/primitives/Breadcrumb/Breadcrumb.svelte +89 -89
  94. package/dist/primitives/Button/Button.spec.js +211 -211
  95. package/dist/primitives/Button/Button.stories.svelte +76 -76
  96. package/dist/primitives/Button/Button.svelte +301 -301
  97. package/dist/primitives/Button/ButtonSaveDemo.spec.js +48 -48
  98. package/dist/primitives/Button/ButtonSaveDemo.svelte +25 -25
  99. package/dist/primitives/Button/ButtonVariantShowcase.svelte +129 -129
  100. package/dist/primitives/Card.spec.js +49 -49
  101. package/dist/primitives/Card.stories.svelte +22 -22
  102. package/dist/primitives/Card.svelte +28 -28
  103. package/dist/primitives/Checkbox/Checkbox.stories.svelte +84 -84
  104. package/dist/primitives/Checkbox/Checkbox.svelte +88 -88
  105. package/dist/primitives/DarkModeToggle.spec.js +357 -357
  106. package/dist/primitives/DarkModeToggle.stories.svelte +57 -57
  107. package/dist/primitives/DarkModeToggle.svelte +136 -136
  108. package/dist/primitives/Drawer/Drawer.stories.svelte +100 -100
  109. package/dist/primitives/Drawer/Drawer.svelte +214 -214
  110. package/dist/primitives/Dropdown/Dropdown.stories.svelte +137 -137
  111. package/dist/primitives/Dropdown/Dropdown.svelte +148 -148
  112. package/dist/primitives/Dropdown/DropdownItem.svelte +80 -80
  113. package/dist/primitives/Icons/ArrowLeft.svelte +20 -20
  114. package/dist/primitives/Icons/ArrowRight.svelte +20 -20
  115. package/dist/primitives/Icons/Availability.svelte +26 -26
  116. package/dist/primitives/Icons/Back.svelte +26 -26
  117. package/dist/primitives/Icons/CheckCircle.svelte +18 -18
  118. package/dist/primitives/Icons/CheckCircleOutline.svelte +27 -27
  119. package/dist/primitives/Icons/ChevronLeft.svelte +16 -16
  120. package/dist/primitives/Icons/ChevronRight.svelte +16 -16
  121. package/dist/primitives/Icons/Copy.svelte +27 -27
  122. package/dist/primitives/Icons/Cross.svelte +17 -17
  123. package/dist/primitives/Icons/DownArrow.svelte +20 -20
  124. package/dist/primitives/Icons/ErrorCircle.svelte +18 -18
  125. package/dist/primitives/Icons/FacebookIcon.svelte +13 -13
  126. package/dist/primitives/Icons/Home.svelte +27 -27
  127. package/dist/primitives/Icons/Icon.spec.js +175 -175
  128. package/dist/primitives/Icons/Icon.stories.svelte +100 -100
  129. package/dist/primitives/Icons/Icon.svelte +63 -63
  130. package/dist/primitives/Icons/IconGallery.stories.svelte +235 -235
  131. package/dist/primitives/Icons/ImageOutline.svelte +19 -19
  132. package/dist/primitives/Icons/Info.svelte +19 -19
  133. package/dist/primitives/Icons/InstagramIcon.svelte +19 -19
  134. package/dist/primitives/Icons/LogoInstagram.svelte +15 -15
  135. package/dist/primitives/Icons/Message.svelte +27 -27
  136. package/dist/primitives/Icons/MoonIcon.svelte +16 -16
  137. package/dist/primitives/Icons/More.svelte +33 -33
  138. package/dist/primitives/Icons/MoreHori.spec.js +67 -67
  139. package/dist/primitives/Icons/MoreHori.svelte +34 -34
  140. package/dist/primitives/Icons/Notification.svelte +26 -26
  141. package/dist/primitives/Icons/Payment.svelte +26 -26
  142. package/dist/primitives/Icons/Profile.svelte +33 -33
  143. package/dist/primitives/Icons/Reload.svelte +41 -41
  144. package/dist/primitives/Icons/Shows.svelte +33 -33
  145. package/dist/primitives/Icons/Signout.svelte +33 -33
  146. package/dist/primitives/Icons/SunIcon.svelte +19 -19
  147. package/dist/primitives/Icons/TiktokIcon.svelte +13 -13
  148. package/dist/primitives/Icons/TrashBinOutline.svelte +19 -19
  149. package/dist/primitives/Icons/TwitterIcon.svelte +13 -13
  150. package/dist/primitives/Icons/WarningIcon.spec.js +30 -30
  151. package/dist/primitives/Icons/WarningIcon.svelte +24 -24
  152. package/dist/primitives/Input/Input.spec.js +573 -573
  153. package/dist/primitives/Input/Input.stories.svelte +139 -139
  154. package/dist/primitives/Input/Input.svelte +444 -444
  155. package/dist/primitives/Input/Select.spec.js +218 -218
  156. package/dist/primitives/Input/Select.stories.svelte +112 -112
  157. package/dist/primitives/Input/Select.svelte +232 -232
  158. package/dist/primitives/Input/Textarea.stories.svelte +137 -137
  159. package/dist/primitives/Input/Textarea.svelte +79 -79
  160. package/dist/primitives/Label/Label.svelte +37 -37
  161. package/dist/primitives/Modal/Modal.spec.js +95 -95
  162. package/dist/primitives/Modal/Modal.stories.svelte +86 -86
  163. package/dist/primitives/Modal/Modal.svelte +158 -158
  164. package/dist/primitives/Pagination/Pagination.stories.svelte +76 -76
  165. package/dist/primitives/Pagination/Pagination.svelte +261 -261
  166. package/dist/primitives/Radio/Radio.stories.svelte +80 -80
  167. package/dist/primitives/Radio/Radio.svelte +67 -67
  168. package/dist/primitives/Skeleton/CardPlaceholder.svelte +87 -87
  169. package/dist/primitives/Skeleton/ImagePlaceholder.svelte +59 -59
  170. package/dist/primitives/Skeleton/ListPlaceholder.svelte +76 -76
  171. package/dist/primitives/Skeleton/Skeleton.stories.svelte +151 -151
  172. package/dist/primitives/Skeleton/Skeleton.svelte +52 -52
  173. package/dist/primitives/Spinner/Spinner.spec.js +75 -75
  174. package/dist/primitives/Spinner/Spinner.stories.svelte +29 -29
  175. package/dist/primitives/Spinner/Spinner.svelte +57 -57
  176. package/dist/primitives/Tabs/TabItem.svelte +51 -51
  177. package/dist/primitives/Tabs/Tabs.stories.svelte +112 -112
  178. package/dist/primitives/Tabs/Tabs.svelte +128 -128
  179. package/dist/primitives/Toggle.spec.js +127 -127
  180. package/dist/primitives/Toggle.stories.svelte +92 -92
  181. package/dist/primitives/Toggle.svelte +71 -71
  182. package/dist/primitives/Typography/Typography.svelte +53 -53
  183. package/dist/primitives/ValidationError.spec.js +103 -103
  184. package/dist/primitives/ValidationError.stories.svelte +111 -111
  185. package/dist/primitives/ValidationError.svelte +29 -29
  186. package/dist/recipes/CropImage/CropImage.spec.js +216 -216
  187. package/dist/recipes/CropImage/CropImage.stories.svelte +104 -104
  188. package/dist/recipes/CropImage/CropImage.svelte +238 -238
  189. package/dist/recipes/ImageUploader/ImageUploader.stories.svelte +125 -125
  190. package/dist/recipes/ImageUploader/ImageUploader.svelte +980 -980
  191. package/dist/recipes/Toaster/Toaster.stories.svelte +62 -62
  192. package/dist/recipes/feedback/EmptyState/EmptyState.svelte +47 -47
  193. package/dist/recipes/feedback/ErrorDisplay.spec.js +69 -69
  194. package/dist/recipes/feedback/ErrorDisplay.stories.svelte +112 -112
  195. package/dist/recipes/feedback/ErrorDisplay.svelte +38 -38
  196. package/dist/recipes/feedback/StatusIndicator/StatusIndicator.spec.js +129 -129
  197. package/dist/recipes/feedback/StatusIndicator/StatusIndicator.svelte +167 -167
  198. package/dist/recipes/fields/CheckboxField.svelte +85 -85
  199. package/dist/recipes/fields/FormField.svelte +58 -58
  200. package/dist/recipes/fields/RadioGroup.svelte +95 -95
  201. package/dist/recipes/fields/SelectField.svelte +82 -82
  202. package/dist/recipes/fields/TextareaField.svelte +101 -101
  203. package/dist/recipes/fields/ToggleField.svelte +60 -60
  204. package/dist/recipes/fields/index.js +7 -7
  205. package/dist/recipes/inputs/MultiSelect.spec.js +257 -257
  206. package/dist/recipes/inputs/MultiSelect.stories.svelte +133 -133
  207. package/dist/recipes/inputs/MultiSelect.svelte +244 -244
  208. package/dist/recipes/inputs/OTPInput.spec.js +238 -238
  209. package/dist/recipes/inputs/OTPInput.stories.svelte +162 -162
  210. package/dist/recipes/inputs/OTPInput.svelte +102 -102
  211. package/dist/recipes/inputs/PasswordInput.svelte +100 -100
  212. package/dist/recipes/inputs/PasswordStrengthIndicator/PasswordStrengthIndicator.spec.js +173 -173
  213. package/dist/recipes/inputs/PasswordStrengthIndicator/PasswordStrengthIndicator.svelte +108 -108
  214. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.spec.js +300 -300
  215. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.stories.svelte +165 -165
  216. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.svelte +337 -337
  217. package/dist/recipes/inputs/Search.svelte +85 -85
  218. package/dist/recipes/inputs/SelectDropdown.svelte +161 -161
  219. package/dist/recipes/modals/AlertModal.svelte +130 -130
  220. package/dist/recipes/modals/ConfirmationModal.spec.js +191 -191
  221. package/dist/recipes/modals/ConfirmationModal.stories.svelte +119 -119
  222. package/dist/recipes/modals/ConfirmationModal.svelte +152 -152
  223. package/dist/recipes/modals/InputModal.svelte +182 -182
  224. package/dist/recipes/modals/ModalStateManager.spec.js +100 -100
  225. package/dist/recipes/modals/ModalStateManager.svelte +77 -77
  226. package/dist/recipes/modals/ModalTestWrapper.svelte +65 -65
  227. package/dist/recipes/modals/StatusModal.svelte +206 -206
  228. package/dist/services/EventService.js +75 -75
  229. package/dist/services/EventService.spec.js +217 -217
  230. package/dist/services/ShowService.spec.js +342 -342
  231. package/dist/stores/auth.js +93 -6
  232. package/dist/stores/auth.spec.js +310 -2
  233. package/dist/stores/toaster.js +13 -13
  234. package/dist/stories/ButtonAuditReview.stories.svelte +14 -14
  235. package/dist/stories/ButtonAuditReview.svelte +427 -427
  236. package/dist/stories/PatternsGallery.stories.svelte +19 -19
  237. package/dist/stories/PatternsGallery.svelte +388 -388
  238. package/dist/stories/PrimitivesGallery.stories.svelte +19 -19
  239. package/dist/stories/PrimitivesGallery.svelte +752 -752
  240. package/dist/stories/RecipesGallery.stories.svelte +19 -19
  241. package/dist/stories/RecipesGallery.svelte +441 -441
  242. package/dist/stories/button-audit-manifest.json +11186 -11186
  243. package/dist/tailwind/preset.cjs +82 -82
  244. package/dist/telemetry.js +357 -357
  245. package/dist/tokens/tokens.css +87 -87
  246. package/dist/utils/apiConfig.js +49 -49
  247. package/dist/utils/utils.js +9 -1
  248. package/package.json +233 -191
@@ -1,980 +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 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>
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>