@getmicdrop/svelte-components 5.4.0 → 5.4.1

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