@getmicdrop/svelte-components 5.3.12 → 5.3.14

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