@getmicdrop/svelte-components 5.5.5 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar/AboutShow/AboutShow.svelte +172 -172
- package/dist/calendar/Calendar/MiniMonthCalendar.svelte +782 -782
- package/dist/calendar/FAQs/FAQs.svelte +75 -75
- package/dist/calendar/MonthSwitcher/MonthSwitcher.svelte +126 -126
- package/dist/calendar/OrderSummary/OrderSummary.svelte +367 -367
- package/dist/calendar/PublicCard/PublicCard.svelte +134 -134
- package/dist/calendar/ShowCard/ShowCard.svelte +157 -157
- package/dist/calendar/ShowTimeCard/ShowTimeCard.svelte +61 -61
- package/dist/components/Layout/AppShell.svelte +104 -0
- package/dist/components/Layout/AppShell.svelte.d.ts +26 -0
- package/dist/components/Layout/AppShell.svelte.d.ts.map +1 -0
- package/dist/components/Layout/ContentSection.svelte +80 -0
- package/dist/components/Layout/ContentSection.svelte.d.ts +23 -0
- package/dist/components/Layout/ContentSection.svelte.d.ts.map +1 -0
- package/dist/components/Layout/Grid.svelte +4 -4
- package/dist/components/Layout/Heading.svelte +81 -0
- package/dist/components/Layout/Heading.svelte.d.ts +24 -0
- package/dist/components/Layout/Heading.svelte.d.ts.map +1 -0
- package/dist/components/Layout/PageContainer.svelte +69 -0
- package/dist/components/Layout/PageContainer.svelte.d.ts +23 -0
- package/dist/components/Layout/PageContainer.svelte.d.ts.map +1 -0
- package/dist/components/Layout/Responsive.svelte +75 -0
- package/dist/components/Layout/Responsive.svelte.d.ts +19 -0
- package/dist/components/Layout/Responsive.svelte.d.ts.map +1 -0
- package/dist/components/Layout/Section.svelte +80 -80
- package/dist/components/Layout/ShowOnDesktop.svelte +37 -0
- package/dist/components/Layout/ShowOnDesktop.svelte.d.ts +16 -0
- package/dist/components/Layout/ShowOnDesktop.svelte.d.ts.map +1 -0
- package/dist/components/Layout/ShowOnMobile.svelte +37 -0
- package/dist/components/Layout/ShowOnMobile.svelte.d.ts +16 -0
- package/dist/components/Layout/ShowOnMobile.svelte.d.ts.map +1 -0
- package/dist/components/Layout/Sidebar.svelte +108 -108
- package/dist/components/Layout/Stack.spec.js +1 -1
- package/dist/components/Layout/Stack.svelte +6 -6
- package/dist/components/Layout/Text.svelte +87 -0
- package/dist/components/Layout/Text.svelte.d.ts +28 -0
- package/dist/components/Layout/Text.svelte.d.ts.map +1 -0
- package/dist/components/Layout/TwoColumn.svelte +108 -0
- package/dist/components/Layout/TwoColumn.svelte.d.ts +28 -0
- package/dist/components/Layout/TwoColumn.svelte.d.ts.map +1 -0
- package/dist/components/Layout/__tests__/Heading.test.d.ts +2 -0
- package/dist/components/Layout/__tests__/Heading.test.d.ts.map +1 -0
- package/dist/components/Layout/__tests__/Heading.test.js +123 -0
- package/dist/components/Layout/__tests__/ShowOnDesktop.test.d.ts +2 -0
- package/dist/components/Layout/__tests__/ShowOnDesktop.test.d.ts.map +1 -0
- package/dist/components/Layout/__tests__/ShowOnDesktop.test.js +84 -0
- package/dist/components/Layout/__tests__/ShowOnMobile.test.d.ts +2 -0
- package/dist/components/Layout/__tests__/ShowOnMobile.test.d.ts.map +1 -0
- package/dist/components/Layout/__tests__/ShowOnMobile.test.js +80 -0
- package/dist/components/Layout/__tests__/Text.test.d.ts +2 -0
- package/dist/components/Layout/__tests__/Text.test.d.ts.map +1 -0
- package/dist/components/Layout/__tests__/Text.test.js +146 -0
- package/dist/components/Layout/__tests__/TwoColumn.test.d.ts +2 -0
- package/dist/components/Layout/__tests__/TwoColumn.test.d.ts.map +1 -0
- package/dist/components/Layout/__tests__/TwoColumn.test.js +129 -0
- package/dist/constants/validation.js +91 -91
- package/dist/constants/validation.spec.js +64 -64
- package/dist/datetime/__tests__/format.test.js +1 -1
- package/dist/datetime/__tests__/parse.test.js +1 -1
- package/dist/datetime/__tests__/timezone.test.js +1 -1
- package/dist/datetime/parse.js +1 -1
- package/dist/forms/createFormStore.svelte.js +1 -0
- package/dist/index.js +40 -40
- package/dist/patterns/data/DataGrid.svelte +45 -45
- package/dist/patterns/data/DataList.svelte +24 -24
- package/dist/patterns/data/DataTable.svelte +36 -36
- package/dist/patterns/forms/FormActions.spec.js +95 -95
- package/dist/patterns/forms/FormActions.stories.svelte +97 -97
- package/dist/patterns/forms/FormActions.svelte +46 -46
- package/dist/patterns/forms/FormGrid.svelte +33 -33
- package/dist/patterns/forms/FormSection.svelte +32 -32
- package/dist/patterns/forms/FormValidationSummary.stories.svelte +83 -83
- package/dist/patterns/forms/FormValidationSummary.svelte +74 -74
- package/dist/patterns/layout/Sidebar.svelte +39 -39
- package/dist/patterns/layout/index.d.ts +9 -0
- package/dist/patterns/layout/index.js +22 -0
- package/dist/patterns/navigation/BottomNav.stories.svelte +117 -117
- package/dist/patterns/navigation/BottomNav.svelte +64 -64
- package/dist/patterns/navigation/Header.stories.svelte +77 -77
- package/dist/patterns/navigation/Header.svelte +193 -193
- package/dist/patterns/page/PageHeader.svelte +18 -18
- package/dist/patterns/page/PageLayout.svelte +40 -40
- package/dist/patterns/page/PageLoader.spec.js +57 -57
- package/dist/patterns/page/PageLoader.stories.svelte +137 -137
- package/dist/patterns/page/PageLoader.svelte +24 -24
- package/dist/patterns/page/SectionHeader.svelte +29 -29
- package/dist/presets/badges.js +112 -112
- package/dist/presets/buttons.js +76 -76
- package/dist/presets/index.js +9 -9
- package/dist/primitives/Accordion/Accordion.stories.svelte +75 -75
- package/dist/primitives/Accordion/Accordion.svelte +42 -42
- package/dist/primitives/Accordion/AccordionItem.svelte +95 -95
- package/dist/primitives/Alert/Alert.spec.js +173 -173
- package/dist/primitives/Alert/Alert.stories.svelte +88 -88
- package/dist/primitives/Alert/Alert.svelte +27 -27
- package/dist/primitives/Avatar/Avatar.stories.svelte +94 -94
- package/dist/primitives/Avatar/Avatar.svelte +66 -66
- package/dist/primitives/Badges/Badge.spec.js +144 -144
- package/dist/primitives/Badges/Badge.stories.svelte +86 -86
- package/dist/primitives/Badges/Badge.svelte +79 -79
- package/dist/primitives/BottomSheet/BottomSheet.spec.js +136 -136
- package/dist/primitives/BottomSheet/BottomSheet.stories.svelte +83 -83
- package/dist/primitives/BottomSheet/BottomSheet.svelte +100 -100
- package/dist/primitives/Breadcrumb/Breadcrumb.spec.js +122 -122
- package/dist/primitives/Breadcrumb/Breadcrumb.stories.svelte +23 -23
- package/dist/primitives/Breadcrumb/Breadcrumb.svelte +89 -89
- package/dist/primitives/Button/Button.spec.js +223 -223
- package/dist/primitives/Button/Button.stories.svelte +76 -76
- package/dist/primitives/Button/Button.svelte +270 -270
- package/dist/primitives/Button/ButtonSaveDemo.spec.js +146 -146
- package/dist/primitives/Button/ButtonSaveDemo.svelte +25 -25
- package/dist/primitives/Button/ButtonVariantShowcase.svelte +129 -129
- package/dist/primitives/Card.spec.js +49 -49
- package/dist/primitives/Card.stories.svelte +22 -22
- package/dist/primitives/Card.svelte +28 -28
- package/dist/primitives/Checkbox/Checkbox.stories.svelte +84 -84
- package/dist/primitives/Checkbox/Checkbox.svelte +88 -88
- package/dist/primitives/DarkModeToggle.spec.js +390 -390
- package/dist/primitives/DarkModeToggle.stories.svelte +57 -57
- package/dist/primitives/DarkModeToggle.svelte +136 -136
- package/dist/primitives/Drawer/Drawer.stories.svelte +80 -80
- package/dist/primitives/Drawer/Drawer.svelte +120 -120
- package/dist/primitives/Dropdown/Dropdown.stories.svelte +137 -137
- package/dist/primitives/Dropdown/Dropdown.svelte +14 -14
- package/dist/primitives/Dropdown/DropdownItem.svelte +80 -80
- package/dist/primitives/Icons/ArrowLeft.svelte +8 -8
- package/dist/primitives/Icons/ArrowRight.svelte +8 -8
- package/dist/primitives/Icons/Availability.svelte +14 -14
- package/dist/primitives/Icons/Back.svelte +14 -14
- package/dist/primitives/Icons/CheckCircle.svelte +6 -6
- package/dist/primitives/Icons/CheckCircleOutline.svelte +15 -15
- package/dist/primitives/Icons/ChevronLeft.svelte +4 -4
- package/dist/primitives/Icons/ChevronRight.svelte +4 -4
- package/dist/primitives/Icons/Copy.svelte +15 -15
- package/dist/primitives/Icons/Cross.svelte +5 -5
- package/dist/primitives/Icons/DownArrow.svelte +8 -8
- package/dist/primitives/Icons/ErrorCircle.svelte +6 -6
- package/dist/primitives/Icons/FacebookIcon.svelte +2 -2
- package/dist/primitives/Icons/Home.svelte +15 -15
- package/dist/primitives/Icons/Icon.spec.js +169 -169
- package/dist/primitives/Icons/Icon.stories.svelte +100 -100
- package/dist/primitives/Icons/Icon.svelte +52 -52
- package/dist/primitives/Icons/IconGallery.stories.svelte +235 -235
- package/dist/primitives/Icons/Info.svelte +7 -7
- package/dist/primitives/Icons/InstagramIcon.svelte +4 -4
- package/dist/primitives/Icons/LogoInstagram.svelte +2 -2
- package/dist/primitives/Icons/Message.svelte +15 -15
- package/dist/primitives/Icons/MoonIcon.svelte +5 -5
- package/dist/primitives/Icons/More.svelte +21 -21
- package/dist/primitives/Icons/MoreHori.spec.js +61 -61
- package/dist/primitives/Icons/MoreHori.svelte +22 -22
- package/dist/primitives/Icons/Notification.svelte +14 -14
- package/dist/primitives/Icons/Payment.svelte +14 -14
- package/dist/primitives/Icons/Profile.svelte +21 -21
- package/dist/primitives/Icons/Reload.svelte +29 -29
- package/dist/primitives/Icons/Shows.svelte +21 -21
- package/dist/primitives/Icons/Signout.svelte +21 -21
- package/dist/primitives/Icons/SunIcon.svelte +8 -8
- package/dist/primitives/Icons/TiktokIcon.svelte +2 -2
- package/dist/primitives/Icons/TwitterIcon.svelte +2 -2
- package/dist/primitives/Icons/WarningIcon.spec.js +18 -18
- package/dist/primitives/Icons/WarningIcon.svelte +5 -5
- package/dist/primitives/Input/Input.spec.js +573 -573
- package/dist/primitives/Input/Input.stories.svelte +139 -139
- package/dist/primitives/Input/Input.svelte +417 -417
- package/dist/primitives/Input/Select.spec.js +212 -212
- package/dist/primitives/Input/Select.stories.svelte +112 -112
- package/dist/primitives/Input/Select.svelte +128 -128
- package/dist/primitives/Input/Textarea.stories.svelte +137 -137
- package/dist/primitives/Input/Textarea.svelte +35 -35
- package/dist/primitives/Label/Label.svelte +37 -37
- package/dist/primitives/Modal/Modal.spec.js +99 -99
- package/dist/primitives/Modal/Modal.stories.svelte +86 -86
- package/dist/primitives/Modal/Modal.svelte +158 -158
- package/dist/primitives/NumberInput/NumberInput.svelte +106 -106
- package/dist/primitives/Pagination/Pagination.stories.svelte +76 -76
- package/dist/primitives/Pagination/Pagination.svelte +261 -261
- package/dist/primitives/Radio/Radio.stories.svelte +80 -80
- package/dist/primitives/Radio/Radio.svelte +67 -67
- package/dist/primitives/Skeleton/CardPlaceholder.svelte +87 -87
- package/dist/primitives/Skeleton/ImagePlaceholder.svelte +59 -59
- package/dist/primitives/Skeleton/ListPlaceholder.svelte +76 -76
- package/dist/primitives/Skeleton/Skeleton.stories.svelte +151 -151
- package/dist/primitives/Skeleton/Skeleton.svelte +26 -26
- package/dist/primitives/Spinner/Spinner.spec.js +71 -71
- package/dist/primitives/Spinner/Spinner.stories.svelte +29 -29
- package/dist/primitives/Spinner/Spinner.svelte +20 -20
- package/dist/primitives/Tabs/TabItem.svelte +49 -49
- package/dist/primitives/Tabs/Tabs.stories.svelte +112 -112
- package/dist/primitives/Tabs/Tabs.svelte +123 -123
- package/dist/primitives/Toggle.spec.js +143 -143
- package/dist/primitives/Toggle.stories.svelte +92 -92
- package/dist/primitives/Toggle.svelte +71 -71
- package/dist/primitives/Typography/Typography.svelte +53 -53
- package/dist/primitives/ValidationError.spec.js +103 -103
- package/dist/primitives/ValidationError.stories.svelte +69 -69
- package/dist/primitives/ValidationError.svelte +29 -29
- package/dist/primitives/index.js +84 -84
- package/dist/recipes/CropImage/CropImage.spec.js +208 -208
- package/dist/recipes/CropImage/CropImage.stories.svelte +104 -104
- package/dist/recipes/CropImage/CropImage.svelte +238 -238
- package/dist/recipes/ImageUploader/ImageUploader.stories.svelte +125 -125
- package/dist/recipes/ImageUploader/ImageUploader.svelte +804 -804
- package/dist/recipes/SuperLogin/SuperLogin.spec.js +17 -17
- package/dist/recipes/Toaster/Toaster.stories.svelte +62 -62
- package/dist/recipes/feedback/EmptyState/EmptyState.svelte +1 -1
- package/dist/recipes/feedback/ErrorDisplay.spec.js +69 -69
- package/dist/recipes/feedback/ErrorDisplay.stories.svelte +101 -101
- package/dist/recipes/feedback/ErrorDisplay.svelte +1 -1
- package/dist/recipes/feedback/StatusIndicator/StatusIndicator.spec.js +133 -133
- package/dist/recipes/feedback/StatusIndicator/StatusIndicator.svelte +157 -157
- package/dist/recipes/fields/CheckboxField.svelte +85 -85
- package/dist/recipes/fields/FormField.svelte +58 -58
- package/dist/recipes/fields/RadioGroup.svelte +95 -95
- package/dist/recipes/fields/SelectField.svelte +80 -80
- package/dist/recipes/fields/TextareaField.svelte +97 -97
- package/dist/recipes/fields/ToggleField.svelte +60 -60
- package/dist/recipes/fields/index.js +7 -7
- package/dist/recipes/inputs/MultiSelect.spec.js +258 -258
- package/dist/recipes/inputs/MultiSelect.stories.svelte +133 -133
- package/dist/recipes/inputs/MultiSelect.svelte +256 -256
- package/dist/recipes/inputs/OTPInput.spec.js +251 -251
- package/dist/recipes/inputs/OTPInput.stories.svelte +162 -162
- package/dist/recipes/inputs/OTPInput.svelte +29 -29
- package/dist/recipes/inputs/PasswordInput.svelte +22 -22
- package/dist/recipes/inputs/PasswordStrengthIndicator/PasswordStrengthIndicator.svelte +117 -117
- package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.stories.svelte +123 -123
- package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.svelte +326 -326
- package/dist/recipes/inputs/Search.svelte +37 -37
- package/dist/recipes/inputs/SelectDropdown.svelte +57 -57
- package/dist/recipes/modals/AlertModal.svelte +130 -130
- package/dist/recipes/modals/ConfirmationModal.spec.js +206 -206
- package/dist/recipes/modals/ConfirmationModal.stories.svelte +119 -119
- package/dist/recipes/modals/ConfirmationModal.svelte +152 -152
- package/dist/recipes/modals/InputModal.svelte +182 -182
- package/dist/recipes/modals/ModalStateManager.spec.js +100 -100
- package/dist/recipes/modals/ModalStateManager.svelte +77 -77
- package/dist/recipes/modals/ModalTestWrapper.svelte +65 -65
- package/dist/recipes/modals/StatusModal.svelte +206 -206
- package/dist/services/EventService.js +75 -75
- package/dist/services/EventService.spec.js +217 -217
- package/dist/services/ShowService.spec.js +345 -345
- package/dist/stores/auth.js +36 -36
- package/dist/stores/auth.spec.js +139 -139
- package/dist/stores/toaster.js +13 -13
- package/dist/stories/ButtonAuditReview.stories.svelte +14 -14
- package/dist/stories/ButtonAuditReview.svelte +427 -427
- package/dist/stories/PatternsGallery.stories.svelte +19 -19
- package/dist/stories/PatternsGallery.svelte +206 -206
- package/dist/stories/PrimitivesGallery.stories.svelte +19 -19
- package/dist/stories/PrimitivesGallery.svelte +725 -725
- package/dist/stories/RecipesGallery.stories.svelte +19 -19
- package/dist/stories/RecipesGallery.svelte +271 -271
- package/dist/stories/button-audit-manifest.json +11186 -11186
- package/dist/tailwind/preset.cjs +82 -82
- package/dist/telemetry.js +405 -405
- package/dist/telemetry.spec.js +1144 -1144
- package/dist/tokens/__tests__/typography-base.test.d.ts +2 -0
- package/dist/tokens/__tests__/typography-base.test.d.ts.map +1 -0
- package/dist/tokens/__tests__/typography-base.test.js +138 -0
- package/dist/tokens/tokens.css +87 -87
- package/dist/tokens/typography-base.css +163 -0
- package/dist/utils/apiConfig.spec.js +219 -219
- package/dist/utils/transitions.js +62 -62
- package/dist/utils/utils.js +354 -354
- package/package.json +292 -291
|
@@ -1,132 +1,132 @@
|
|
|
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
|
-
|
|
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
130
|
// FilePond state
|
|
131
131
|
let filePondFiles = $state<unknown[]>([]);
|
|
132
132
|
let filePondInstance = $state<unknown>(undefined);
|
|
@@ -153,100 +153,100 @@
|
|
|
153
153
|
|
|
154
154
|
// Single-mode file input for replace functionality
|
|
155
155
|
let singleModeFileInput = $state<HTMLInputElement | undefined>(undefined);
|
|
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
|
-
|
|
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
250
|
// Handle FilePond file add
|
|
251
251
|
function handleFilePondAddFile(err: unknown, fileItem: unknown) {
|
|
252
252
|
if (err) {
|
|
@@ -256,586 +256,586 @@
|
|
|
256
256
|
|
|
257
257
|
const item = fileItem as { file: File } | undefined;
|
|
258
258
|
const file = item?.file;
|
|
259
|
-
if (!file) return;
|
|
260
|
-
|
|
261
|
-
if (enableCrop) {
|
|
262
|
-
// Show crop modal
|
|
263
|
-
const objectUrl = URL.createObjectURL(file);
|
|
264
|
-
imageForCrop = objectUrl;
|
|
265
|
-
showCropModal = true;
|
|
266
|
-
} else {
|
|
267
|
-
// Directly upload
|
|
268
|
-
handleUpload(file);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Clear FilePond
|
|
272
|
-
setTimeout(() => {
|
|
273
|
-
if (filePondInstance && typeof (filePondInstance as { removeFiles: () => void }).removeFiles === 'function') {
|
|
274
|
-
(filePondInstance as { removeFiles: () => void }).removeFiles();
|
|
275
|
-
}
|
|
276
|
-
}, 100);
|
|
277
|
-
|
|
278
|
-
showFilePondModal = false;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Handle crop save
|
|
282
|
-
async function handleCropSave(croppedFile: File) {
|
|
283
|
-
showCropModal = false;
|
|
284
|
-
if (imageForCrop.startsWith('blob:')) {
|
|
285
|
-
URL.revokeObjectURL(imageForCrop);
|
|
286
|
-
}
|
|
287
|
-
imageForCrop = '';
|
|
288
|
-
await handleUpload(croppedFile);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Handle crop cancel
|
|
292
|
-
function handleCropCancel() {
|
|
293
|
-
showCropModal = false;
|
|
294
|
-
if (imageForCrop.startsWith('blob:')) {
|
|
295
|
-
URL.revokeObjectURL(imageForCrop);
|
|
296
|
-
}
|
|
297
|
-
imageForCrop = '';
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Handle upload
|
|
301
|
-
// Supports two modes:
|
|
302
|
-
// 1. Self-managed: onUpload returns URL, component adds to images
|
|
303
|
-
// 2. Controlled: onUpload returns void, parent manages images array
|
|
304
|
-
async function handleUpload(file: File) {
|
|
305
|
-
if (!onUpload) {
|
|
306
|
-
console.warn('ImageUploader: onUpload callback not provided');
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
isUploading = true;
|
|
311
|
-
|
|
312
|
-
try {
|
|
313
|
-
const result = await onUpload(file);
|
|
314
|
-
|
|
315
|
-
// If onUpload returns a URL, component manages adding to images (self-managed mode)
|
|
316
|
-
// If onUpload returns void/null, parent is expected to manage images (controlled mode)
|
|
317
|
-
if (typeof result === 'string' && result) {
|
|
318
|
-
const url = result;
|
|
319
|
-
const newImages = [...imageArray];
|
|
320
|
-
if (targetSlotIndex !== null && targetSlotIndex < newImages.length) {
|
|
321
|
-
// Replace existing image
|
|
322
|
-
newImages[targetSlotIndex] = url;
|
|
323
|
-
} else {
|
|
324
|
-
// Add new image
|
|
325
|
-
newImages.push(url);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Update images and notify
|
|
329
|
-
if (Array.isArray(images)) {
|
|
330
|
-
images = newImages;
|
|
331
|
-
} else {
|
|
332
|
-
images = newImages[0] || '';
|
|
333
|
-
}
|
|
334
|
-
onchange?.(newImages);
|
|
335
|
-
}
|
|
336
|
-
// If result is void/null/undefined, parent handles adding to images
|
|
337
|
-
} catch (err) {
|
|
338
|
-
console.error('Upload failed:', err);
|
|
339
|
-
} finally {
|
|
340
|
-
isUploading = false;
|
|
341
|
-
targetSlotIndex = null;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Handle remove
|
|
346
|
-
// In controlled mode (onReorderIndices provided), just fire callback
|
|
347
|
-
// In self-managed mode, update images internally
|
|
348
|
-
function handleRemoveImage(index: number) {
|
|
349
|
-
// Always fire the callback
|
|
350
|
-
onRemove?.(index);
|
|
351
|
-
|
|
352
|
-
// Only manage state internally if not in controlled mode
|
|
353
|
-
if (!onReorderIndices) {
|
|
354
|
-
const newImages = [...imageArray];
|
|
355
|
-
newImages.splice(index, 1);
|
|
356
|
-
|
|
357
|
-
if (Array.isArray(images)) {
|
|
358
|
-
images = newImages;
|
|
359
|
-
} else {
|
|
360
|
-
images = newImages[0] || '';
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
onchange?.(newImages);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
gridRenderKey++;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Open upload modal/dropzone
|
|
370
|
-
function openUploadModal(slotIndex?: number) {
|
|
371
|
-
if (disabled) return;
|
|
372
|
-
targetSlotIndex = slotIndex ?? null;
|
|
373
|
-
if (isMultiMode) {
|
|
374
|
-
showFilePondModal = true;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Handle direct drag-drop on dropzone (bypasses modal)
|
|
379
|
-
function handleDropzoneDragOver(e: DragEvent) {
|
|
380
|
-
e.preventDefault();
|
|
381
|
-
e.stopPropagation();
|
|
382
|
-
if (disabled || !canAddMore) return;
|
|
383
|
-
isDropzoneDragOver = true;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function handleDropzoneDragLeave(e: DragEvent) {
|
|
387
|
-
e.preventDefault();
|
|
388
|
-
e.stopPropagation();
|
|
389
|
-
isDropzoneDragOver = false;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function handleDropzoneDrop(e: DragEvent, slotIndex?: number) {
|
|
393
|
-
e.preventDefault();
|
|
394
|
-
e.stopPropagation();
|
|
395
|
-
isDropzoneDragOver = false;
|
|
396
|
-
|
|
397
|
-
if (disabled || !canAddMore) return;
|
|
398
|
-
|
|
399
|
-
const files = e.dataTransfer?.files;
|
|
400
|
-
if (!files || files.length === 0) return;
|
|
401
|
-
|
|
402
|
-
const file = files[0];
|
|
403
|
-
|
|
404
|
-
// Check if it's an accepted image type
|
|
405
|
-
if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
|
|
406
|
-
console.warn('File type not accepted:', file.type);
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
targetSlotIndex = slotIndex ?? null;
|
|
411
|
-
|
|
412
|
-
if (enableCrop) {
|
|
413
|
-
// Show crop modal
|
|
414
|
-
const objectUrl = URL.createObjectURL(file);
|
|
415
|
-
imageForCrop = objectUrl;
|
|
416
|
-
showCropModal = true;
|
|
417
|
-
} else {
|
|
418
|
-
// Directly upload
|
|
419
|
-
handleUpload(file);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Handle modal dropzone drop
|
|
424
|
-
function handleModalDrop(e: DragEvent) {
|
|
425
|
-
e.preventDefault();
|
|
426
|
-
e.stopPropagation();
|
|
427
|
-
isModalDragOver = false;
|
|
428
|
-
showFilePondModal = false;
|
|
429
|
-
|
|
430
|
-
if (disabled || !canAddMore) return;
|
|
431
|
-
|
|
432
|
-
const files = e.dataTransfer?.files;
|
|
433
|
-
if (!files || files.length === 0) return;
|
|
434
|
-
|
|
435
|
-
const file = files[0];
|
|
436
|
-
|
|
437
|
-
// Check if it's an accepted image type
|
|
438
|
-
if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
|
|
439
|
-
console.warn('File type not accepted:', file.type);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (enableCrop) {
|
|
444
|
-
const objectUrl = URL.createObjectURL(file);
|
|
445
|
-
imageForCrop = objectUrl;
|
|
446
|
-
showCropModal = true;
|
|
447
|
-
} else {
|
|
448
|
-
handleUpload(file);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Handle modal file input selection
|
|
453
|
-
function handleModalFileSelect(e: Event) {
|
|
454
|
-
const input = e.target as HTMLInputElement;
|
|
455
|
-
const file = input?.files?.[0];
|
|
456
|
-
if (!file) return;
|
|
457
|
-
|
|
458
|
-
showFilePondModal = false;
|
|
459
|
-
|
|
460
|
-
if (enableCrop) {
|
|
461
|
-
const objectUrl = URL.createObjectURL(file);
|
|
462
|
-
imageForCrop = objectUrl;
|
|
463
|
-
showCropModal = true;
|
|
464
|
-
} else {
|
|
465
|
-
handleUpload(file);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Clear input for next selection
|
|
469
|
-
input.value = '';
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Handle single-mode file input selection (for replace)
|
|
473
|
-
function handleSingleModeFileSelect(e: Event) {
|
|
474
|
-
const input = e.target as HTMLInputElement;
|
|
475
|
-
const file = input?.files?.[0];
|
|
476
|
-
if (!file) return;
|
|
477
|
-
|
|
478
|
-
targetSlotIndex = 0; // Replace the existing image
|
|
479
|
-
|
|
480
|
-
if (enableCrop) {
|
|
481
|
-
const objectUrl = URL.createObjectURL(file);
|
|
482
|
-
imageForCrop = objectUrl;
|
|
483
|
-
showCropModal = true;
|
|
484
|
-
} else {
|
|
485
|
-
handleUpload(file);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Clear input for next selection
|
|
489
|
-
input.value = '';
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Initialize sortable for multi-image grid
|
|
493
|
-
function initSortable() {
|
|
494
|
-
if (!browser || !gridContainer || !enableReorder || !isMultiMode) return;
|
|
495
|
-
|
|
496
|
-
if (sortableInstance) {
|
|
497
|
-
sortableInstance.destroy();
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
sortableInstance = new Sortable(gridContainer, {
|
|
501
|
-
animation: 200,
|
|
502
|
-
ghostClass: 'sortable-ghost',
|
|
503
|
-
chosenClass: 'sortable-chosen',
|
|
504
|
-
dragClass: 'sortable-drag',
|
|
505
|
-
delay: 150,
|
|
506
|
-
delayOnTouchOnly: true,
|
|
507
|
-
touchStartThreshold: 5,
|
|
508
|
-
filter: '.empty-slot, .remove-btn',
|
|
509
|
-
preventOnFilter: false,
|
|
510
|
-
onStart: () => {
|
|
511
|
-
isDragging = true;
|
|
512
|
-
},
|
|
513
|
-
onEnd: (evt) => {
|
|
514
|
-
isDragging = false;
|
|
515
|
-
const { oldIndex, newIndex } = evt;
|
|
516
|
-
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
|
|
517
|
-
|
|
518
|
-
// Fire index-based callback for store integration (controlled mode)
|
|
519
|
-
onReorderIndices?.({ from: oldIndex, to: newIndex });
|
|
520
|
-
|
|
521
|
-
// For self-managed mode, update images internally
|
|
522
|
-
if (!onReorderIndices) {
|
|
523
|
-
const newImages = [...imageArray];
|
|
524
|
-
const [moved] = newImages.splice(oldIndex, 1);
|
|
525
|
-
newImages.splice(newIndex, 0, moved);
|
|
526
|
-
|
|
527
|
-
if (Array.isArray(images)) {
|
|
528
|
-
images = newImages;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
onReorder?.(newImages);
|
|
532
|
-
onchange?.(newImages);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
gridRenderKey++;
|
|
536
|
-
|
|
537
|
-
// Re-init after state update
|
|
538
|
-
if (sortableInstance) {
|
|
539
|
-
sortableInstance.destroy();
|
|
540
|
-
sortableInstance = null;
|
|
541
|
-
}
|
|
542
|
-
},
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Setup sortable on mount and when images change
|
|
547
|
-
$effect(() => {
|
|
548
|
-
if (browser && gridContainer && !isDragging && isMultiMode && enableReorder) {
|
|
549
|
-
// Use setTimeout to ensure DOM is ready
|
|
550
|
-
setTimeout(initSortable, 0);
|
|
551
|
-
}
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
// Cleanup
|
|
555
|
-
$effect(() => {
|
|
556
|
-
return () => {
|
|
557
|
-
if (sortableInstance) {
|
|
558
|
-
sortableInstance.destroy();
|
|
559
|
-
}
|
|
560
|
-
};
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
// Generate slots with progressive disclosure
|
|
564
|
-
// Only show filled images + 1 empty slot (up to maxImages)
|
|
565
|
-
let slots = $derived.by(() => {
|
|
566
|
-
const slotCount = Math.min(imageArray.length + 1, maxImages);
|
|
567
|
-
return Array.from({ length: slotCount }, (_, i) => ({
|
|
568
|
-
id: `slot-${i}`,
|
|
569
|
-
image: imageArray[i] || null,
|
|
570
|
-
isNext: i === imageArray.length,
|
|
571
|
-
}));
|
|
572
|
-
});
|
|
573
|
-
</script>
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
<!-- Single Image Mode -->
|
|
577
|
-
{#if !isMultiMode}
|
|
578
|
-
<div class="image-uploader-single {className}">
|
|
579
|
-
{#if imageArray.length > 0}
|
|
580
|
-
<!-- Show existing image with remove option -->
|
|
581
|
-
<div class="relative {shapeAspects[shape]} bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden group">
|
|
582
|
-
<img
|
|
583
|
-
src={imageArray[0]}
|
|
584
|
-
alt="Uploaded"
|
|
585
|
-
class="w-full h-full object-cover"
|
|
586
|
-
/>
|
|
587
|
-
{#if !disabled}
|
|
588
|
-
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
|
589
|
-
<Button
|
|
590
|
-
variant="default"
|
|
591
|
-
size="sm"
|
|
592
|
-
onclick={() => singleModeFileInput?.click()}
|
|
593
|
-
>
|
|
594
|
-
<ImageOutline class="w-4 h-4 mr-1.5" />
|
|
595
|
-
Replace
|
|
596
|
-
</Button>
|
|
597
|
-
<Button
|
|
598
|
-
variant="red"
|
|
599
|
-
size="sm"
|
|
600
|
-
onclick={() => handleRemoveImage(0)}
|
|
601
|
-
>
|
|
602
|
-
<TrashBinOutline class="w-4 h-4 mr-1.5" />
|
|
603
|
-
Delete
|
|
604
|
-
</Button>
|
|
605
|
-
</div>
|
|
606
|
-
<!-- Hidden file input for replace -->
|
|
607
|
-
<input
|
|
608
|
-
bind:this={singleModeFileInput}
|
|
609
|
-
type="file"
|
|
610
|
-
accept={acceptedTypes.join(',')}
|
|
611
|
-
class="hidden"
|
|
612
|
-
onchange={handleSingleModeFileSelect}
|
|
613
|
-
/>
|
|
614
|
-
{/if}
|
|
615
|
-
</div>
|
|
616
|
-
{:else}
|
|
617
|
-
<!-- Empty dropzone with direct drag-drop support -->
|
|
618
|
-
<div
|
|
619
|
-
class="{shapeAspects[shape]} filepond-wrapper-single {shape === 'wide' ? 'filepond-wide' : ''}
|
|
620
|
-
{isDropzoneDragOver ? 'dropzone-drag-over' : ''}"
|
|
621
|
-
role="button"
|
|
622
|
-
tabindex="0"
|
|
623
|
-
ondragover={handleDropzoneDragOver}
|
|
624
|
-
ondragleave={handleDropzoneDragLeave}
|
|
625
|
-
ondrop={(e) => handleDropzoneDrop(e, 0)}
|
|
626
|
-
>
|
|
627
|
-
<FilePond
|
|
628
|
-
bind:this={filePondInstance}
|
|
629
|
-
bind:files={filePondFiles}
|
|
630
|
-
{...filePondOptions}
|
|
631
|
-
onaddfile={handleFilePondAddFile}
|
|
632
|
-
/>
|
|
633
|
-
</div>
|
|
634
|
-
{/if}
|
|
635
|
-
|
|
636
|
-
{#if helperText}
|
|
637
|
-
<p class={`${typography.smMuted} mt-2`}>{helperText}</p>
|
|
638
|
-
{/if}
|
|
639
|
-
|
|
640
|
-
{#if error}
|
|
641
|
-
<p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
|
|
642
|
-
{/if}
|
|
643
|
-
</div>
|
|
644
|
-
|
|
645
|
-
<!-- Multi Image Mode (Grid) -->
|
|
646
|
-
{:else}
|
|
647
|
-
<div class="image-uploader-multi {className}">
|
|
648
|
-
{#key gridRenderKey}
|
|
649
|
-
<div
|
|
650
|
-
bind:this={gridContainer}
|
|
651
|
-
class="{currentSizeConfig.containerClass}"
|
|
652
|
-
>
|
|
653
|
-
{#each slots as slot, index (slot.id)}
|
|
654
|
-
<div
|
|
655
|
-
class="relative {size === 'lg' ? shapeAspects[shape] : ''} {currentSizeConfig.slotClass} rounded-lg overflow-hidden"
|
|
656
|
-
class:cursor-grab={slot.image && enableReorder && !disabled}
|
|
657
|
-
class:empty-slot={!slot.image}
|
|
658
|
-
data-slot-id={slot.id}
|
|
659
|
-
>
|
|
660
|
-
{#if slot.image}
|
|
661
|
-
<!-- Filled slot -->
|
|
662
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
663
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
664
|
-
<div
|
|
665
|
-
class="relative w-full h-full bg-gray-100 dark:bg-gray-800 group"
|
|
666
|
-
class:cursor-pointer={onSetMain && index !== 0 && !disabled}
|
|
667
|
-
onclick={() => {
|
|
668
|
-
if (onSetMain && index !== 0 && !disabled) {
|
|
669
|
-
onSetMain({ index });
|
|
670
|
-
}
|
|
671
|
-
}}
|
|
672
|
-
>
|
|
673
|
-
<img
|
|
674
|
-
src={slot.image}
|
|
675
|
-
alt="Uploaded"
|
|
676
|
-
class="w-full h-full object-cover pointer-events-none"
|
|
677
|
-
draggable="false"
|
|
678
|
-
/>
|
|
679
|
-
{#if showMainBadge && index === 0}
|
|
680
|
-
<div class="absolute bottom-1 left-1 z-10 pointer-events-none">
|
|
681
|
-
<Badge variant="info" size={currentSizeConfig.badgeSize}>Main</Badge>
|
|
682
|
-
</div>
|
|
683
|
-
{/if}
|
|
684
|
-
{#if !disabled}
|
|
685
|
-
<button
|
|
686
|
-
type="button"
|
|
687
|
-
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"
|
|
688
|
-
onclick={(e) => { e.stopPropagation(); handleRemoveImage(index); }}
|
|
689
|
-
aria-label="Remove photo"
|
|
690
|
-
>
|
|
691
|
-
<CloseOutline class="w-3 h-3 text-gray-700 dark:text-gray-300" />
|
|
692
|
-
</button>
|
|
693
|
-
{/if}
|
|
694
|
-
</div>
|
|
695
|
-
{:else if slot.isNext && canAddMore && !disabled}
|
|
696
|
-
<!-- Next available slot - dropzone with direct drag-drop support -->
|
|
697
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
698
|
-
<div
|
|
699
|
-
class="w-full h-full border-2 border-dashed rounded-lg transition-colors flex items-center justify-center cursor-pointer
|
|
700
|
-
{isDropzoneDragOver
|
|
701
|
-
? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40'
|
|
702
|
-
: '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'}"
|
|
703
|
-
role="button"
|
|
704
|
-
tabindex="0"
|
|
705
|
-
onclick={() => openUploadModal(index)}
|
|
706
|
-
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openUploadModal(index); }}
|
|
707
|
-
ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
|
|
708
|
-
ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
|
|
709
|
-
ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = false; }}
|
|
710
|
-
ondrop={(e) => handleDropzoneDrop(e, index)}
|
|
711
|
-
aria-label="Add photo - drag and drop or click to upload"
|
|
712
|
-
>
|
|
713
|
-
<PlusOutline class={currentSizeConfig.iconClass + ' text-gray-400 dark:text-gray-500'} />
|
|
714
|
-
</div>
|
|
715
|
-
{/if}
|
|
716
|
-
</div>
|
|
717
|
-
{/each}
|
|
718
|
-
</div>
|
|
719
|
-
{/key}
|
|
720
|
-
|
|
721
|
-
{#if helperText}
|
|
722
|
-
<p class={`${typography.smMuted} mt-3`}>{helperText}</p>
|
|
723
|
-
{/if}
|
|
724
|
-
|
|
725
|
-
{#if error}
|
|
726
|
-
<p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
|
|
727
|
-
{/if}
|
|
728
|
-
</div>
|
|
729
|
-
|
|
730
|
-
<!-- Upload Modal for multi-image (native dropzone - more reliable than FilePond) -->
|
|
731
|
-
{#if showFilePondModal}
|
|
732
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
733
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
734
|
-
<div
|
|
735
|
-
class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4"
|
|
736
|
-
onclick={() => (showFilePondModal = false)}
|
|
737
|
-
ondragover={(e) => e.preventDefault()}
|
|
738
|
-
ondrop={(e) => e.preventDefault()}
|
|
739
|
-
>
|
|
740
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
741
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
742
|
-
<div
|
|
743
|
-
class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"
|
|
744
|
-
onclick={(e) => e.stopPropagation()}
|
|
745
|
-
>
|
|
746
|
-
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
747
|
-
<h3 class={typography.h3}>Upload photo</h3>
|
|
748
|
-
<button
|
|
749
|
-
type="button"
|
|
750
|
-
class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
751
|
-
onclick={() => (showFilePondModal = false)}
|
|
752
|
-
aria-label="Close"
|
|
753
|
-
>
|
|
754
|
-
<CloseOutline class="w-5 h-5" />
|
|
755
|
-
</button>
|
|
756
|
-
</div>
|
|
757
|
-
<div class="p-4">
|
|
758
|
-
<!-- Native dropzone with file input -->
|
|
759
|
-
<div
|
|
760
|
-
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
|
|
761
|
-
{isModalDragOver
|
|
762
|
-
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
|
763
|
-
: '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'}"
|
|
764
|
-
role="button"
|
|
765
|
-
tabindex="0"
|
|
766
|
-
onclick={() => modalFileInput?.click()}
|
|
767
|
-
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') modalFileInput?.click(); }}
|
|
768
|
-
ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
|
|
769
|
-
ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
|
|
770
|
-
ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = false; }}
|
|
771
|
-
ondrop={(e) => handleModalDrop(e)}
|
|
772
|
-
>
|
|
773
|
-
<PlusOutline class="w-8 h-8 mb-2 text-gray-400" />
|
|
774
|
-
<span class={typography.smMuted}>{emptyLabel}</span>
|
|
775
|
-
<span class={`${typography.xsMuted} mt-1`}>JPG, PNG, WebP</span>
|
|
776
|
-
</div>
|
|
777
|
-
<input
|
|
778
|
-
bind:this={modalFileInput}
|
|
779
|
-
type="file"
|
|
780
|
-
accept={acceptedTypes.join(',')}
|
|
781
|
-
class="hidden"
|
|
782
|
-
onchange={handleModalFileSelect}
|
|
783
|
-
/>
|
|
784
|
-
</div>
|
|
785
|
-
</div>
|
|
786
|
-
</div>
|
|
787
|
-
{/if}
|
|
788
|
-
{/if}
|
|
789
|
-
|
|
790
|
-
<!-- Crop Modal -->
|
|
791
|
-
{#if enableCrop}
|
|
792
|
-
<CropImage
|
|
793
|
-
bind:showModal={showCropModal}
|
|
794
|
-
imageSrc={imageForCrop}
|
|
795
|
-
onSave={handleCropSave}
|
|
796
|
-
onCancel={handleCropCancel}
|
|
797
|
-
isUploadingImage={isUploading}
|
|
798
|
-
/>
|
|
799
|
-
{/if}
|
|
800
|
-
|
|
801
|
-
<style>
|
|
802
|
-
/* FilePond customizations */
|
|
803
|
-
:global(.filepond-wrapper .filepond--root) {
|
|
804
|
-
font-family: inherit;
|
|
805
|
-
min-height: 180px;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
:global(.filepond-wrapper .filepond--panel-root) {
|
|
809
|
-
background-color: transparent;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
:global(.filepond-wrapper .filepond--drop-label) {
|
|
813
|
-
color: inherit;
|
|
814
|
-
min-height: 180px;
|
|
815
|
-
display: flex;
|
|
816
|
-
align-items: center;
|
|
817
|
-
justify-content: center;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/* Note: FilePond dark mode is handled via runtime JS injection
|
|
821
|
-
because Svelte's scoped :global() can't reliably override
|
|
822
|
-
FilePond's CSS that loads from node_modules */
|
|
823
|
-
|
|
824
|
-
:global(.filepond-wrapper .filepond--drop-label label) {
|
|
825
|
-
display: flex;
|
|
826
|
-
flex-direction: column;
|
|
827
|
-
align-items: center;
|
|
828
|
-
justify-content: center;
|
|
829
|
-
width: 100%;
|
|
830
|
-
height: 100%;
|
|
831
|
-
cursor: pointer;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
:global(.filepond-wrapper .filepond--label-action) {
|
|
835
|
-
text-decoration: none;
|
|
836
|
-
color: inherit;
|
|
837
|
-
}
|
|
838
|
-
|
|
259
|
+
if (!file) return;
|
|
260
|
+
|
|
261
|
+
if (enableCrop) {
|
|
262
|
+
// Show crop modal
|
|
263
|
+
const objectUrl = URL.createObjectURL(file);
|
|
264
|
+
imageForCrop = objectUrl;
|
|
265
|
+
showCropModal = true;
|
|
266
|
+
} else {
|
|
267
|
+
// Directly upload
|
|
268
|
+
handleUpload(file);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Clear FilePond
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
if (filePondInstance && typeof (filePondInstance as { removeFiles: () => void }).removeFiles === 'function') {
|
|
274
|
+
(filePondInstance as { removeFiles: () => void }).removeFiles();
|
|
275
|
+
}
|
|
276
|
+
}, 100);
|
|
277
|
+
|
|
278
|
+
showFilePondModal = false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Handle crop save
|
|
282
|
+
async function handleCropSave(croppedFile: File) {
|
|
283
|
+
showCropModal = false;
|
|
284
|
+
if (imageForCrop.startsWith('blob:')) {
|
|
285
|
+
URL.revokeObjectURL(imageForCrop);
|
|
286
|
+
}
|
|
287
|
+
imageForCrop = '';
|
|
288
|
+
await handleUpload(croppedFile);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Handle crop cancel
|
|
292
|
+
function handleCropCancel() {
|
|
293
|
+
showCropModal = false;
|
|
294
|
+
if (imageForCrop.startsWith('blob:')) {
|
|
295
|
+
URL.revokeObjectURL(imageForCrop);
|
|
296
|
+
}
|
|
297
|
+
imageForCrop = '';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Handle upload
|
|
301
|
+
// Supports two modes:
|
|
302
|
+
// 1. Self-managed: onUpload returns URL, component adds to images
|
|
303
|
+
// 2. Controlled: onUpload returns void, parent manages images array
|
|
304
|
+
async function handleUpload(file: File) {
|
|
305
|
+
if (!onUpload) {
|
|
306
|
+
console.warn('ImageUploader: onUpload callback not provided');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
isUploading = true;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const result = await onUpload(file);
|
|
314
|
+
|
|
315
|
+
// If onUpload returns a URL, component manages adding to images (self-managed mode)
|
|
316
|
+
// If onUpload returns void/null, parent is expected to manage images (controlled mode)
|
|
317
|
+
if (typeof result === 'string' && result) {
|
|
318
|
+
const url = result;
|
|
319
|
+
const newImages = [...imageArray];
|
|
320
|
+
if (targetSlotIndex !== null && targetSlotIndex < newImages.length) {
|
|
321
|
+
// Replace existing image
|
|
322
|
+
newImages[targetSlotIndex] = url;
|
|
323
|
+
} else {
|
|
324
|
+
// Add new image
|
|
325
|
+
newImages.push(url);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Update images and notify
|
|
329
|
+
if (Array.isArray(images)) {
|
|
330
|
+
images = newImages;
|
|
331
|
+
} else {
|
|
332
|
+
images = newImages[0] || '';
|
|
333
|
+
}
|
|
334
|
+
onchange?.(newImages);
|
|
335
|
+
}
|
|
336
|
+
// If result is void/null/undefined, parent handles adding to images
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error('Upload failed:', err);
|
|
339
|
+
} finally {
|
|
340
|
+
isUploading = false;
|
|
341
|
+
targetSlotIndex = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Handle remove
|
|
346
|
+
// In controlled mode (onReorderIndices provided), just fire callback
|
|
347
|
+
// In self-managed mode, update images internally
|
|
348
|
+
function handleRemoveImage(index: number) {
|
|
349
|
+
// Always fire the callback
|
|
350
|
+
onRemove?.(index);
|
|
351
|
+
|
|
352
|
+
// Only manage state internally if not in controlled mode
|
|
353
|
+
if (!onReorderIndices) {
|
|
354
|
+
const newImages = [...imageArray];
|
|
355
|
+
newImages.splice(index, 1);
|
|
356
|
+
|
|
357
|
+
if (Array.isArray(images)) {
|
|
358
|
+
images = newImages;
|
|
359
|
+
} else {
|
|
360
|
+
images = newImages[0] || '';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
onchange?.(newImages);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
gridRenderKey++;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Open upload modal/dropzone
|
|
370
|
+
function openUploadModal(slotIndex?: number) {
|
|
371
|
+
if (disabled) return;
|
|
372
|
+
targetSlotIndex = slotIndex ?? null;
|
|
373
|
+
if (isMultiMode) {
|
|
374
|
+
showFilePondModal = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Handle direct drag-drop on dropzone (bypasses modal)
|
|
379
|
+
function handleDropzoneDragOver(e: DragEvent) {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
e.stopPropagation();
|
|
382
|
+
if (disabled || !canAddMore) return;
|
|
383
|
+
isDropzoneDragOver = true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function handleDropzoneDragLeave(e: DragEvent) {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
e.stopPropagation();
|
|
389
|
+
isDropzoneDragOver = false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function handleDropzoneDrop(e: DragEvent, slotIndex?: number) {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
e.stopPropagation();
|
|
395
|
+
isDropzoneDragOver = false;
|
|
396
|
+
|
|
397
|
+
if (disabled || !canAddMore) return;
|
|
398
|
+
|
|
399
|
+
const files = e.dataTransfer?.files;
|
|
400
|
+
if (!files || files.length === 0) return;
|
|
401
|
+
|
|
402
|
+
const file = files[0];
|
|
403
|
+
|
|
404
|
+
// Check if it's an accepted image type
|
|
405
|
+
if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
|
|
406
|
+
console.warn('File type not accepted:', file.type);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
targetSlotIndex = slotIndex ?? null;
|
|
411
|
+
|
|
412
|
+
if (enableCrop) {
|
|
413
|
+
// Show crop modal
|
|
414
|
+
const objectUrl = URL.createObjectURL(file);
|
|
415
|
+
imageForCrop = objectUrl;
|
|
416
|
+
showCropModal = true;
|
|
417
|
+
} else {
|
|
418
|
+
// Directly upload
|
|
419
|
+
handleUpload(file);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Handle modal dropzone drop
|
|
424
|
+
function handleModalDrop(e: DragEvent) {
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
e.stopPropagation();
|
|
427
|
+
isModalDragOver = false;
|
|
428
|
+
showFilePondModal = false;
|
|
429
|
+
|
|
430
|
+
if (disabled || !canAddMore) return;
|
|
431
|
+
|
|
432
|
+
const files = e.dataTransfer?.files;
|
|
433
|
+
if (!files || files.length === 0) return;
|
|
434
|
+
|
|
435
|
+
const file = files[0];
|
|
436
|
+
|
|
437
|
+
// Check if it's an accepted image type
|
|
438
|
+
if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
|
|
439
|
+
console.warn('File type not accepted:', file.type);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (enableCrop) {
|
|
444
|
+
const objectUrl = URL.createObjectURL(file);
|
|
445
|
+
imageForCrop = objectUrl;
|
|
446
|
+
showCropModal = true;
|
|
447
|
+
} else {
|
|
448
|
+
handleUpload(file);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Handle modal file input selection
|
|
453
|
+
function handleModalFileSelect(e: Event) {
|
|
454
|
+
const input = e.target as HTMLInputElement;
|
|
455
|
+
const file = input?.files?.[0];
|
|
456
|
+
if (!file) return;
|
|
457
|
+
|
|
458
|
+
showFilePondModal = false;
|
|
459
|
+
|
|
460
|
+
if (enableCrop) {
|
|
461
|
+
const objectUrl = URL.createObjectURL(file);
|
|
462
|
+
imageForCrop = objectUrl;
|
|
463
|
+
showCropModal = true;
|
|
464
|
+
} else {
|
|
465
|
+
handleUpload(file);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Clear input for next selection
|
|
469
|
+
input.value = '';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Handle single-mode file input selection (for replace)
|
|
473
|
+
function handleSingleModeFileSelect(e: Event) {
|
|
474
|
+
const input = e.target as HTMLInputElement;
|
|
475
|
+
const file = input?.files?.[0];
|
|
476
|
+
if (!file) return;
|
|
477
|
+
|
|
478
|
+
targetSlotIndex = 0; // Replace the existing image
|
|
479
|
+
|
|
480
|
+
if (enableCrop) {
|
|
481
|
+
const objectUrl = URL.createObjectURL(file);
|
|
482
|
+
imageForCrop = objectUrl;
|
|
483
|
+
showCropModal = true;
|
|
484
|
+
} else {
|
|
485
|
+
handleUpload(file);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Clear input for next selection
|
|
489
|
+
input.value = '';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Initialize sortable for multi-image grid
|
|
493
|
+
function initSortable() {
|
|
494
|
+
if (!browser || !gridContainer || !enableReorder || !isMultiMode) return;
|
|
495
|
+
|
|
496
|
+
if (sortableInstance) {
|
|
497
|
+
sortableInstance.destroy();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
sortableInstance = new Sortable(gridContainer, {
|
|
501
|
+
animation: 200,
|
|
502
|
+
ghostClass: 'sortable-ghost',
|
|
503
|
+
chosenClass: 'sortable-chosen',
|
|
504
|
+
dragClass: 'sortable-drag',
|
|
505
|
+
delay: 150,
|
|
506
|
+
delayOnTouchOnly: true,
|
|
507
|
+
touchStartThreshold: 5,
|
|
508
|
+
filter: '.empty-slot, .remove-btn',
|
|
509
|
+
preventOnFilter: false,
|
|
510
|
+
onStart: () => {
|
|
511
|
+
isDragging = true;
|
|
512
|
+
},
|
|
513
|
+
onEnd: (evt) => {
|
|
514
|
+
isDragging = false;
|
|
515
|
+
const { oldIndex, newIndex } = evt;
|
|
516
|
+
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
|
|
517
|
+
|
|
518
|
+
// Fire index-based callback for store integration (controlled mode)
|
|
519
|
+
onReorderIndices?.({ from: oldIndex, to: newIndex });
|
|
520
|
+
|
|
521
|
+
// For self-managed mode, update images internally
|
|
522
|
+
if (!onReorderIndices) {
|
|
523
|
+
const newImages = [...imageArray];
|
|
524
|
+
const [moved] = newImages.splice(oldIndex, 1);
|
|
525
|
+
newImages.splice(newIndex, 0, moved);
|
|
526
|
+
|
|
527
|
+
if (Array.isArray(images)) {
|
|
528
|
+
images = newImages;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
onReorder?.(newImages);
|
|
532
|
+
onchange?.(newImages);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
gridRenderKey++;
|
|
536
|
+
|
|
537
|
+
// Re-init after state update
|
|
538
|
+
if (sortableInstance) {
|
|
539
|
+
sortableInstance.destroy();
|
|
540
|
+
sortableInstance = null;
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Setup sortable on mount and when images change
|
|
547
|
+
$effect(() => {
|
|
548
|
+
if (browser && gridContainer && !isDragging && isMultiMode && enableReorder) {
|
|
549
|
+
// Use setTimeout to ensure DOM is ready
|
|
550
|
+
setTimeout(initSortable, 0);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Cleanup
|
|
555
|
+
$effect(() => {
|
|
556
|
+
return () => {
|
|
557
|
+
if (sortableInstance) {
|
|
558
|
+
sortableInstance.destroy();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Generate slots with progressive disclosure
|
|
564
|
+
// Only show filled images + 1 empty slot (up to maxImages)
|
|
565
|
+
let slots = $derived.by(() => {
|
|
566
|
+
const slotCount = Math.min(imageArray.length + 1, maxImages);
|
|
567
|
+
return Array.from({ length: slotCount }, (_, i) => ({
|
|
568
|
+
id: `slot-${i}`,
|
|
569
|
+
image: imageArray[i] || null,
|
|
570
|
+
isNext: i === imageArray.length,
|
|
571
|
+
}));
|
|
572
|
+
});
|
|
573
|
+
</script>
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
<!-- Single Image Mode -->
|
|
577
|
+
{#if !isMultiMode}
|
|
578
|
+
<div class="image-uploader-single {className}">
|
|
579
|
+
{#if imageArray.length > 0}
|
|
580
|
+
<!-- Show existing image with remove option -->
|
|
581
|
+
<div class="relative {shapeAspects[shape]} bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden group">
|
|
582
|
+
<img
|
|
583
|
+
src={imageArray[0]}
|
|
584
|
+
alt="Uploaded"
|
|
585
|
+
class="w-full h-full object-cover"
|
|
586
|
+
/>
|
|
587
|
+
{#if !disabled}
|
|
588
|
+
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
|
589
|
+
<Button
|
|
590
|
+
variant="default"
|
|
591
|
+
size="sm"
|
|
592
|
+
onclick={() => singleModeFileInput?.click()}
|
|
593
|
+
>
|
|
594
|
+
<ImageOutline class="w-4 h-4 mr-1.5" />
|
|
595
|
+
Replace
|
|
596
|
+
</Button>
|
|
597
|
+
<Button
|
|
598
|
+
variant="red"
|
|
599
|
+
size="sm"
|
|
600
|
+
onclick={() => handleRemoveImage(0)}
|
|
601
|
+
>
|
|
602
|
+
<TrashBinOutline class="w-4 h-4 mr-1.5" />
|
|
603
|
+
Delete
|
|
604
|
+
</Button>
|
|
605
|
+
</div>
|
|
606
|
+
<!-- Hidden file input for replace -->
|
|
607
|
+
<input
|
|
608
|
+
bind:this={singleModeFileInput}
|
|
609
|
+
type="file"
|
|
610
|
+
accept={acceptedTypes.join(',')}
|
|
611
|
+
class="hidden"
|
|
612
|
+
onchange={handleSingleModeFileSelect}
|
|
613
|
+
/>
|
|
614
|
+
{/if}
|
|
615
|
+
</div>
|
|
616
|
+
{:else}
|
|
617
|
+
<!-- Empty dropzone with direct drag-drop support -->
|
|
618
|
+
<div
|
|
619
|
+
class="{shapeAspects[shape]} filepond-wrapper-single {shape === 'wide' ? 'filepond-wide' : ''}
|
|
620
|
+
{isDropzoneDragOver ? 'dropzone-drag-over' : ''}"
|
|
621
|
+
role="button"
|
|
622
|
+
tabindex="0"
|
|
623
|
+
ondragover={handleDropzoneDragOver}
|
|
624
|
+
ondragleave={handleDropzoneDragLeave}
|
|
625
|
+
ondrop={(e) => handleDropzoneDrop(e, 0)}
|
|
626
|
+
>
|
|
627
|
+
<FilePond
|
|
628
|
+
bind:this={filePondInstance}
|
|
629
|
+
bind:files={filePondFiles}
|
|
630
|
+
{...filePondOptions}
|
|
631
|
+
onaddfile={handleFilePondAddFile}
|
|
632
|
+
/>
|
|
633
|
+
</div>
|
|
634
|
+
{/if}
|
|
635
|
+
|
|
636
|
+
{#if helperText}
|
|
637
|
+
<p class={`${typography.smMuted} mt-2`}>{helperText}</p>
|
|
638
|
+
{/if}
|
|
639
|
+
|
|
640
|
+
{#if error}
|
|
641
|
+
<p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
|
|
642
|
+
{/if}
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
<!-- Multi Image Mode (Grid) -->
|
|
646
|
+
{:else}
|
|
647
|
+
<div class="image-uploader-multi {className}">
|
|
648
|
+
{#key gridRenderKey}
|
|
649
|
+
<div
|
|
650
|
+
bind:this={gridContainer}
|
|
651
|
+
class="{currentSizeConfig.containerClass}"
|
|
652
|
+
>
|
|
653
|
+
{#each slots as slot, index (slot.id)}
|
|
654
|
+
<div
|
|
655
|
+
class="relative {size === 'lg' ? shapeAspects[shape] : ''} {currentSizeConfig.slotClass} rounded-lg overflow-hidden"
|
|
656
|
+
class:cursor-grab={slot.image && enableReorder && !disabled}
|
|
657
|
+
class:empty-slot={!slot.image}
|
|
658
|
+
data-slot-id={slot.id}
|
|
659
|
+
>
|
|
660
|
+
{#if slot.image}
|
|
661
|
+
<!-- Filled slot -->
|
|
662
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
663
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
664
|
+
<div
|
|
665
|
+
class="relative w-full h-full bg-gray-100 dark:bg-gray-800 group"
|
|
666
|
+
class:cursor-pointer={onSetMain && index !== 0 && !disabled}
|
|
667
|
+
onclick={() => {
|
|
668
|
+
if (onSetMain && index !== 0 && !disabled) {
|
|
669
|
+
onSetMain({ index });
|
|
670
|
+
}
|
|
671
|
+
}}
|
|
672
|
+
>
|
|
673
|
+
<img
|
|
674
|
+
src={slot.image}
|
|
675
|
+
alt="Uploaded"
|
|
676
|
+
class="w-full h-full object-cover pointer-events-none"
|
|
677
|
+
draggable="false"
|
|
678
|
+
/>
|
|
679
|
+
{#if showMainBadge && index === 0}
|
|
680
|
+
<div class="absolute bottom-1 left-1 z-10 pointer-events-none">
|
|
681
|
+
<Badge variant="info" size={currentSizeConfig.badgeSize}>Main</Badge>
|
|
682
|
+
</div>
|
|
683
|
+
{/if}
|
|
684
|
+
{#if !disabled}
|
|
685
|
+
<button
|
|
686
|
+
type="button"
|
|
687
|
+
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"
|
|
688
|
+
onclick={(e) => { e.stopPropagation(); handleRemoveImage(index); }}
|
|
689
|
+
aria-label="Remove photo"
|
|
690
|
+
>
|
|
691
|
+
<CloseOutline class="w-3 h-3 text-gray-700 dark:text-gray-300" />
|
|
692
|
+
</button>
|
|
693
|
+
{/if}
|
|
694
|
+
</div>
|
|
695
|
+
{:else if slot.isNext && canAddMore && !disabled}
|
|
696
|
+
<!-- Next available slot - dropzone with direct drag-drop support -->
|
|
697
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
698
|
+
<div
|
|
699
|
+
class="w-full h-full border-2 border-dashed rounded-lg transition-colors flex items-center justify-center cursor-pointer
|
|
700
|
+
{isDropzoneDragOver
|
|
701
|
+
? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40'
|
|
702
|
+
: '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'}"
|
|
703
|
+
role="button"
|
|
704
|
+
tabindex="0"
|
|
705
|
+
onclick={() => openUploadModal(index)}
|
|
706
|
+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openUploadModal(index); }}
|
|
707
|
+
ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
|
|
708
|
+
ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
|
|
709
|
+
ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = false; }}
|
|
710
|
+
ondrop={(e) => handleDropzoneDrop(e, index)}
|
|
711
|
+
aria-label="Add photo - drag and drop or click to upload"
|
|
712
|
+
>
|
|
713
|
+
<PlusOutline class={currentSizeConfig.iconClass + ' text-gray-400 dark:text-gray-500'} />
|
|
714
|
+
</div>
|
|
715
|
+
{/if}
|
|
716
|
+
</div>
|
|
717
|
+
{/each}
|
|
718
|
+
</div>
|
|
719
|
+
{/key}
|
|
720
|
+
|
|
721
|
+
{#if helperText}
|
|
722
|
+
<p class={`${typography.smMuted} mt-3`}>{helperText}</p>
|
|
723
|
+
{/if}
|
|
724
|
+
|
|
725
|
+
{#if error}
|
|
726
|
+
<p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
|
|
727
|
+
{/if}
|
|
728
|
+
</div>
|
|
729
|
+
|
|
730
|
+
<!-- Upload Modal for multi-image (native dropzone - more reliable than FilePond) -->
|
|
731
|
+
{#if showFilePondModal}
|
|
732
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
733
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
734
|
+
<div
|
|
735
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4"
|
|
736
|
+
onclick={() => (showFilePondModal = false)}
|
|
737
|
+
ondragover={(e) => e.preventDefault()}
|
|
738
|
+
ondrop={(e) => e.preventDefault()}
|
|
739
|
+
>
|
|
740
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
741
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
742
|
+
<div
|
|
743
|
+
class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"
|
|
744
|
+
onclick={(e) => e.stopPropagation()}
|
|
745
|
+
>
|
|
746
|
+
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
747
|
+
<h3 class={typography.h3}>Upload photo</h3>
|
|
748
|
+
<button
|
|
749
|
+
type="button"
|
|
750
|
+
class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
751
|
+
onclick={() => (showFilePondModal = false)}
|
|
752
|
+
aria-label="Close"
|
|
753
|
+
>
|
|
754
|
+
<CloseOutline class="w-5 h-5" />
|
|
755
|
+
</button>
|
|
756
|
+
</div>
|
|
757
|
+
<div class="p-4">
|
|
758
|
+
<!-- Native dropzone with file input -->
|
|
759
|
+
<div
|
|
760
|
+
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
|
|
761
|
+
{isModalDragOver
|
|
762
|
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
|
763
|
+
: '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'}"
|
|
764
|
+
role="button"
|
|
765
|
+
tabindex="0"
|
|
766
|
+
onclick={() => modalFileInput?.click()}
|
|
767
|
+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') modalFileInput?.click(); }}
|
|
768
|
+
ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
|
|
769
|
+
ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
|
|
770
|
+
ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = false; }}
|
|
771
|
+
ondrop={(e) => handleModalDrop(e)}
|
|
772
|
+
>
|
|
773
|
+
<PlusOutline class="w-8 h-8 mb-2 text-gray-400" />
|
|
774
|
+
<span class={typography.smMuted}>{emptyLabel}</span>
|
|
775
|
+
<span class={`${typography.xsMuted} mt-1`}>JPG, PNG, WebP</span>
|
|
776
|
+
</div>
|
|
777
|
+
<input
|
|
778
|
+
bind:this={modalFileInput}
|
|
779
|
+
type="file"
|
|
780
|
+
accept={acceptedTypes.join(',')}
|
|
781
|
+
class="hidden"
|
|
782
|
+
onchange={handleModalFileSelect}
|
|
783
|
+
/>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
{/if}
|
|
788
|
+
{/if}
|
|
789
|
+
|
|
790
|
+
<!-- Crop Modal -->
|
|
791
|
+
{#if enableCrop}
|
|
792
|
+
<CropImage
|
|
793
|
+
bind:showModal={showCropModal}
|
|
794
|
+
imageSrc={imageForCrop}
|
|
795
|
+
onSave={handleCropSave}
|
|
796
|
+
onCancel={handleCropCancel}
|
|
797
|
+
isUploadingImage={isUploading}
|
|
798
|
+
/>
|
|
799
|
+
{/if}
|
|
800
|
+
|
|
801
|
+
<style>
|
|
802
|
+
/* FilePond customizations */
|
|
803
|
+
:global(.filepond-wrapper .filepond--root) {
|
|
804
|
+
font-family: inherit;
|
|
805
|
+
min-height: 180px;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
:global(.filepond-wrapper .filepond--panel-root) {
|
|
809
|
+
background-color: transparent;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
:global(.filepond-wrapper .filepond--drop-label) {
|
|
813
|
+
color: inherit;
|
|
814
|
+
min-height: 180px;
|
|
815
|
+
display: flex;
|
|
816
|
+
align-items: center;
|
|
817
|
+
justify-content: center;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/* Note: FilePond dark mode is handled via runtime JS injection
|
|
821
|
+
because Svelte's scoped :global() can't reliably override
|
|
822
|
+
FilePond's CSS that loads from node_modules */
|
|
823
|
+
|
|
824
|
+
:global(.filepond-wrapper .filepond--drop-label label) {
|
|
825
|
+
display: flex;
|
|
826
|
+
flex-direction: column;
|
|
827
|
+
align-items: center;
|
|
828
|
+
justify-content: center;
|
|
829
|
+
width: 100%;
|
|
830
|
+
height: 100%;
|
|
831
|
+
cursor: pointer;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
:global(.filepond-wrapper .filepond--label-action) {
|
|
835
|
+
text-decoration: none;
|
|
836
|
+
color: inherit;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
839
|
/* FilePond drag hover state */
|
|
840
840
|
:global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
|
|
841
841
|
border-color: rgb(59 130 246); /* blue-500 */
|
|
@@ -956,4 +956,4 @@
|
|
|
956
956
|
:global(.sortable-drag) {
|
|
957
957
|
cursor: grabbing;
|
|
958
958
|
}
|
|
959
|
-
</style>
|
|
959
|
+
</style>
|