@ankhorage/zora 1.0.10 → 1.1.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/CHANGELOG.md +6 -0
- package/README.md +107 -0
- package/dist/components/image/Image.d.ts +4 -0
- package/dist/components/image/Image.d.ts.map +1 -0
- package/dist/components/image/Image.js +8 -0
- package/dist/components/image/Image.js.map +1 -0
- package/dist/components/image/index.d.ts +3 -0
- package/dist/components/image/index.d.ts.map +1 -0
- package/dist/components/image/index.js +2 -0
- package/dist/components/image/index.js.map +1 -0
- package/dist/components/image/types.d.ts +6 -0
- package/dist/components/image/types.d.ts.map +1 -0
- package/dist/components/image/types.js +2 -0
- package/dist/components/image/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/patterns/image-preview/ImagePreview.d.ts +4 -0
- package/dist/patterns/image-preview/ImagePreview.d.ts.map +1 -0
- package/dist/patterns/image-preview/ImagePreview.js +41 -0
- package/dist/patterns/image-preview/ImagePreview.js.map +1 -0
- package/dist/patterns/image-preview/index.d.ts +3 -0
- package/dist/patterns/image-preview/index.d.ts.map +1 -0
- package/dist/patterns/image-preview/index.js +2 -0
- package/dist/patterns/image-preview/index.js.map +1 -0
- package/dist/patterns/image-preview/types.d.ts +36 -0
- package/dist/patterns/image-preview/types.d.ts.map +1 -0
- package/dist/patterns/image-preview/types.js +2 -0
- package/dist/patterns/image-preview/types.js.map +1 -0
- package/dist/patterns/image-upload-field/ImageUploadField.d.ts +4 -0
- package/dist/patterns/image-upload-field/ImageUploadField.d.ts.map +1 -0
- package/dist/patterns/image-upload-field/ImageUploadField.js +211 -0
- package/dist/patterns/image-upload-field/ImageUploadField.js.map +1 -0
- package/dist/patterns/image-upload-field/index.d.ts +3 -0
- package/dist/patterns/image-upload-field/index.d.ts.map +1 -0
- package/dist/patterns/image-upload-field/index.js +2 -0
- package/dist/patterns/image-upload-field/index.js.map +1 -0
- package/dist/patterns/image-upload-field/types.d.ts +35 -0
- package/dist/patterns/image-upload-field/types.d.ts.map +1 -0
- package/dist/patterns/image-upload-field/types.js +2 -0
- package/dist/patterns/image-upload-field/types.js.map +1 -0
- package/dist/patterns/image-upload-field/uploadFlow.d.ts +18 -0
- package/dist/patterns/image-upload-field/uploadFlow.d.ts.map +1 -0
- package/dist/patterns/image-upload-field/uploadFlow.js +106 -0
- package/dist/patterns/image-upload-field/uploadFlow.js.map +1 -0
- package/package.json +3 -3
- package/src/components/image/Image.tsx +11 -0
- package/src/components/image/index.ts +2 -0
- package/src/components/image/types.ts +7 -0
- package/src/index.ts +14 -0
- package/src/patterns/image-preview/ImagePreview.tsx +76 -0
- package/src/patterns/image-preview/index.ts +2 -0
- package/src/patterns/image-preview/types.ts +41 -0
- package/src/patterns/image-upload-field/ImageUploadField.tsx +293 -0
- package/src/patterns/image-upload-field/index.ts +2 -0
- package/src/patterns/image-upload-field/types.ts +41 -0
- package/src/patterns/image-upload-field/uploadFlow.test.ts +117 -0
- package/src/patterns/image-upload-field/uploadFlow.ts +145 -0
- package/src/showcaseCoverage.test.ts +3 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ImageFit } from '../../components/image';
|
|
4
|
+
import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
|
|
5
|
+
|
|
6
|
+
export interface ZoraImageMetadata {
|
|
7
|
+
fileName?: string;
|
|
8
|
+
sizeBytes?: number;
|
|
9
|
+
createdAt?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ZoraImageAsset =
|
|
13
|
+
| {
|
|
14
|
+
kind: 'url';
|
|
15
|
+
url: string;
|
|
16
|
+
alt?: string;
|
|
17
|
+
width?: number;
|
|
18
|
+
height?: number;
|
|
19
|
+
contentType?: string;
|
|
20
|
+
metadata?: ZoraImageMetadata;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
kind: 'storage';
|
|
24
|
+
storageId?: string;
|
|
25
|
+
bucket: string;
|
|
26
|
+
path: string;
|
|
27
|
+
publicUrl?: string;
|
|
28
|
+
alt?: string;
|
|
29
|
+
width?: number;
|
|
30
|
+
height?: number;
|
|
31
|
+
contentType?: string;
|
|
32
|
+
metadata?: ZoraImageMetadata;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface ImagePreviewProps extends ZoraBaseProps {
|
|
36
|
+
asset?: ZoraImageAsset | null;
|
|
37
|
+
aspectRatio?: number;
|
|
38
|
+
fit?: ImageFit;
|
|
39
|
+
emptyTitle?: React.ReactNode;
|
|
40
|
+
emptyDescription?: React.ReactNode;
|
|
41
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Button } from '../../components/button';
|
|
4
|
+
import { Modal } from '../../components/modal';
|
|
5
|
+
import { Progress } from '../../components/progress';
|
|
6
|
+
import { Text } from '../../components/text';
|
|
7
|
+
import { Box, Stack } from '../../foundation';
|
|
8
|
+
import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
|
|
9
|
+
import { FormField } from '../form-field';
|
|
10
|
+
import { ImagePreview } from '../image-preview';
|
|
11
|
+
import type { ImageUploadFieldProps } from './types';
|
|
12
|
+
import {
|
|
13
|
+
createOptimisticAssetFromPicked,
|
|
14
|
+
formatAcceptHint,
|
|
15
|
+
formatMaxSizeHint,
|
|
16
|
+
resolveRenderableUrl,
|
|
17
|
+
validatePickedImage,
|
|
18
|
+
} from './uploadFlow';
|
|
19
|
+
|
|
20
|
+
function clampProgress(value: number | null): number | null {
|
|
21
|
+
if (value === null) return null;
|
|
22
|
+
if (!Number.isFinite(value)) return null;
|
|
23
|
+
return Math.max(0, Math.min(1, value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatUnknownError(error: unknown): string {
|
|
27
|
+
if (error instanceof Error && error.message) return error.message;
|
|
28
|
+
if (typeof error === 'string' && error.trim().length > 0) return error;
|
|
29
|
+
return 'Something went wrong.';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ImageUploadFieldInner({
|
|
33
|
+
themeId: _themeId,
|
|
34
|
+
mode: _mode,
|
|
35
|
+
testID,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
label,
|
|
39
|
+
description,
|
|
40
|
+
helperText,
|
|
41
|
+
errorText,
|
|
42
|
+
required,
|
|
43
|
+
disabled = false,
|
|
44
|
+
readOnly = false,
|
|
45
|
+
accept,
|
|
46
|
+
maxSizeBytes,
|
|
47
|
+
validatePicked,
|
|
48
|
+
onPick,
|
|
49
|
+
onUpload,
|
|
50
|
+
onRemove,
|
|
51
|
+
aspectRatio = 1,
|
|
52
|
+
previewTitle = 'Image preview',
|
|
53
|
+
previewDescription,
|
|
54
|
+
}: ImageUploadFieldProps) {
|
|
55
|
+
const [internalError, setInternalError] = React.useState<string | undefined>(undefined);
|
|
56
|
+
const [uploading, setUploading] = React.useState(false);
|
|
57
|
+
const [removing, setRemoving] = React.useState(false);
|
|
58
|
+
const [progress, setProgress] = React.useState<number | null>(null);
|
|
59
|
+
const [previewOpen, setPreviewOpen] = React.useState(false);
|
|
60
|
+
const isMountedRef = React.useRef(true);
|
|
61
|
+
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
return () => {
|
|
64
|
+
isMountedRef.current = false;
|
|
65
|
+
};
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const renderableUrl = resolveRenderableUrl(value);
|
|
69
|
+
const isRenderable = renderableUrl !== null;
|
|
70
|
+
const actionsDisabled = disabled || readOnly;
|
|
71
|
+
|
|
72
|
+
const effectiveError = errorText ?? internalError;
|
|
73
|
+
const invalid = Boolean(effectiveError);
|
|
74
|
+
|
|
75
|
+
const acceptHint = formatAcceptHint(accept);
|
|
76
|
+
const maxSizeHint = formatMaxSizeHint(maxSizeBytes);
|
|
77
|
+
|
|
78
|
+
const setProgressSafe = React.useCallback((next: number | null) => {
|
|
79
|
+
if (!isMountedRef.current) return;
|
|
80
|
+
setProgress(clampProgress(next));
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const clearTransientState = React.useCallback(() => {
|
|
84
|
+
if (!isMountedRef.current) return;
|
|
85
|
+
setUploading(false);
|
|
86
|
+
setRemoving(false);
|
|
87
|
+
setProgress(null);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const handleReplace = React.useCallback(async () => {
|
|
91
|
+
if (actionsDisabled || uploading || removing) return;
|
|
92
|
+
|
|
93
|
+
setInternalError(undefined);
|
|
94
|
+
let picked;
|
|
95
|
+
try {
|
|
96
|
+
picked = await onPick();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!isMountedRef.current) return;
|
|
99
|
+
setInternalError(formatUnknownError(error));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!picked) return;
|
|
103
|
+
|
|
104
|
+
const validationError = validatePickedImage({
|
|
105
|
+
picked,
|
|
106
|
+
accept,
|
|
107
|
+
maxSizeBytes,
|
|
108
|
+
validatePicked,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (validationError) {
|
|
112
|
+
setInternalError(validationError);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!onUpload) {
|
|
117
|
+
onChange(createOptimisticAssetFromPicked(picked));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const optimisticAsset = createOptimisticAssetFromPicked(picked);
|
|
122
|
+
onChange(optimisticAsset);
|
|
123
|
+
setUploading(true);
|
|
124
|
+
setProgressSafe(0);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const uploaded = await onUpload(picked, { setProgress: setProgressSafe });
|
|
128
|
+
if (!isMountedRef.current) return;
|
|
129
|
+
onChange(uploaded);
|
|
130
|
+
setInternalError(undefined);
|
|
131
|
+
clearTransientState();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!isMountedRef.current) return;
|
|
134
|
+
setInternalError(formatUnknownError(error));
|
|
135
|
+
setUploading(false);
|
|
136
|
+
setProgress(null);
|
|
137
|
+
}
|
|
138
|
+
}, [
|
|
139
|
+
accept,
|
|
140
|
+
actionsDisabled,
|
|
141
|
+
clearTransientState,
|
|
142
|
+
maxSizeBytes,
|
|
143
|
+
onChange,
|
|
144
|
+
onPick,
|
|
145
|
+
onUpload,
|
|
146
|
+
removing,
|
|
147
|
+
setProgressSafe,
|
|
148
|
+
uploading,
|
|
149
|
+
validatePicked,
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const handleRemove = React.useCallback(async () => {
|
|
153
|
+
if (actionsDisabled || uploading || removing) return;
|
|
154
|
+
if (!value) {
|
|
155
|
+
onChange(null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setInternalError(undefined);
|
|
160
|
+
|
|
161
|
+
if (!onRemove) {
|
|
162
|
+
onChange(null);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setRemoving(true);
|
|
167
|
+
try {
|
|
168
|
+
await onRemove(value);
|
|
169
|
+
if (!isMountedRef.current) return;
|
|
170
|
+
onChange(null);
|
|
171
|
+
setRemoving(false);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (!isMountedRef.current) return;
|
|
174
|
+
setInternalError(formatUnknownError(error));
|
|
175
|
+
setRemoving(false);
|
|
176
|
+
}
|
|
177
|
+
}, [actionsDisabled, onChange, onRemove, removing, uploading, value]);
|
|
178
|
+
|
|
179
|
+
const handlePreview = React.useCallback(() => {
|
|
180
|
+
if (!isRenderable) return;
|
|
181
|
+
setPreviewOpen(true);
|
|
182
|
+
}, [isRenderable]);
|
|
183
|
+
|
|
184
|
+
const closePreview = React.useCallback(() => setPreviewOpen(false), []);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<>
|
|
188
|
+
<FormField
|
|
189
|
+
description={description}
|
|
190
|
+
disabled={disabled}
|
|
191
|
+
errorText={effectiveError}
|
|
192
|
+
helperText={helperText}
|
|
193
|
+
invalid={invalid}
|
|
194
|
+
label={label}
|
|
195
|
+
readOnly={readOnly}
|
|
196
|
+
required={required}
|
|
197
|
+
testID={testID}
|
|
198
|
+
>
|
|
199
|
+
<Stack gap="m">
|
|
200
|
+
<ImagePreview
|
|
201
|
+
aspectRatio={aspectRatio}
|
|
202
|
+
asset={value}
|
|
203
|
+
emptyDescription={actionsDisabled ? 'No image available.' : undefined}
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
{acceptHint || maxSizeHint ? (
|
|
207
|
+
<Stack gap="xs">
|
|
208
|
+
{acceptHint ? (
|
|
209
|
+
<Text tone="muted" variant="caption">
|
|
210
|
+
{acceptHint}
|
|
211
|
+
</Text>
|
|
212
|
+
) : null}
|
|
213
|
+
{maxSizeHint ? (
|
|
214
|
+
<Text tone="muted" variant="caption">
|
|
215
|
+
{maxSizeHint}
|
|
216
|
+
</Text>
|
|
217
|
+
) : null}
|
|
218
|
+
</Stack>
|
|
219
|
+
) : null}
|
|
220
|
+
|
|
221
|
+
{uploading ? (
|
|
222
|
+
<Stack gap="xs">
|
|
223
|
+
<Text tone="muted" variant="caption">
|
|
224
|
+
Uploading…
|
|
225
|
+
</Text>
|
|
226
|
+
{progress !== null ? <Progress max={1} value={progress} /> : null}
|
|
227
|
+
</Stack>
|
|
228
|
+
) : null}
|
|
229
|
+
|
|
230
|
+
<Stack direction={{ base: 'column', md: 'row' }} gap="s">
|
|
231
|
+
<Button
|
|
232
|
+
disabled={actionsDisabled || uploading || removing}
|
|
233
|
+
onPress={() => {
|
|
234
|
+
void handleReplace();
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{value ? 'Replace image' : 'Select image'}
|
|
238
|
+
</Button>
|
|
239
|
+
|
|
240
|
+
{value ? (
|
|
241
|
+
<Button
|
|
242
|
+
disabled={actionsDisabled || uploading || removing}
|
|
243
|
+
emphasis="outline"
|
|
244
|
+
loading={removing}
|
|
245
|
+
tone="danger"
|
|
246
|
+
onPress={() => {
|
|
247
|
+
void handleRemove();
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
Remove
|
|
251
|
+
</Button>
|
|
252
|
+
) : null}
|
|
253
|
+
|
|
254
|
+
{isRenderable ? (
|
|
255
|
+
<Button disabled={false} emphasis="soft" tone="neutral" onPress={handlePreview}>
|
|
256
|
+
Preview
|
|
257
|
+
</Button>
|
|
258
|
+
) : null}
|
|
259
|
+
</Stack>
|
|
260
|
+
|
|
261
|
+
{!isRenderable && value?.kind === 'storage' && value.publicUrl === undefined ? (
|
|
262
|
+
<Box>
|
|
263
|
+
<Text tone="muted" variant="caption">
|
|
264
|
+
This image is stored, but no preview URL is available yet.
|
|
265
|
+
</Text>
|
|
266
|
+
</Box>
|
|
267
|
+
) : null}
|
|
268
|
+
</Stack>
|
|
269
|
+
</FormField>
|
|
270
|
+
|
|
271
|
+
{isRenderable ? (
|
|
272
|
+
<Modal
|
|
273
|
+
closeOnBackdrop
|
|
274
|
+
description={previewDescription}
|
|
275
|
+
title={previewTitle}
|
|
276
|
+
visible={previewOpen}
|
|
277
|
+
onDismiss={closePreview}
|
|
278
|
+
>
|
|
279
|
+
<Stack gap="m">
|
|
280
|
+
<ImagePreview asset={value} aspectRatio={aspectRatio} />
|
|
281
|
+
<Stack direction="row" justify="flex-end">
|
|
282
|
+
<Button emphasis="soft" tone="neutral" onPress={closePreview}>
|
|
283
|
+
Close
|
|
284
|
+
</Button>
|
|
285
|
+
</Stack>
|
|
286
|
+
</Stack>
|
|
287
|
+
</Modal>
|
|
288
|
+
) : null}
|
|
289
|
+
</>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const ImageUploadField = withZoraThemeScope(ImageUploadFieldInner);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
|
|
4
|
+
import type { ZoraImageAsset } from '../image-preview';
|
|
5
|
+
|
|
6
|
+
export interface ZoraPickedImage {
|
|
7
|
+
uri: string;
|
|
8
|
+
fileName?: string;
|
|
9
|
+
sizeBytes?: number;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ImageUploadProgressContext {
|
|
16
|
+
setProgress: (progress: number | null) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ImageUploadFieldProps extends ZoraBaseProps {
|
|
20
|
+
value: ZoraImageAsset | null;
|
|
21
|
+
onChange: (next: ZoraImageAsset | null) => void;
|
|
22
|
+
label: React.ReactNode;
|
|
23
|
+
description?: React.ReactNode;
|
|
24
|
+
helperText?: React.ReactNode;
|
|
25
|
+
errorText?: React.ReactNode;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
accept?: string;
|
|
30
|
+
maxSizeBytes?: number;
|
|
31
|
+
validatePicked?: (picked: ZoraPickedImage) => string | undefined;
|
|
32
|
+
onPick: () => Promise<ZoraPickedImage | null>;
|
|
33
|
+
onUpload?: (
|
|
34
|
+
picked: ZoraPickedImage,
|
|
35
|
+
context: ImageUploadProgressContext,
|
|
36
|
+
) => Promise<ZoraImageAsset>;
|
|
37
|
+
onRemove?: (current: ZoraImageAsset) => void | Promise<void>;
|
|
38
|
+
aspectRatio?: number;
|
|
39
|
+
previewTitle?: React.ReactNode;
|
|
40
|
+
previewDescription?: React.ReactNode;
|
|
41
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { ZoraPickedImage } from './types';
|
|
4
|
+
import {
|
|
5
|
+
createOptimisticAssetFromPicked,
|
|
6
|
+
formatAcceptHint,
|
|
7
|
+
formatMaxSizeHint,
|
|
8
|
+
isAccepted,
|
|
9
|
+
resolveRenderableUrl,
|
|
10
|
+
validatePickedImage,
|
|
11
|
+
} from './uploadFlow';
|
|
12
|
+
|
|
13
|
+
describe('uploadFlow', () => {
|
|
14
|
+
test('formatAcceptHint returns null for empty input', () => {
|
|
15
|
+
expect(formatAcceptHint(undefined)).toBeNull();
|
|
16
|
+
expect(formatAcceptHint('')).toBeNull();
|
|
17
|
+
expect(formatAcceptHint(' ')).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('formatMaxSizeHint returns null for invalid sizes', () => {
|
|
21
|
+
expect(formatMaxSizeHint(undefined)).toBeNull();
|
|
22
|
+
expect(formatMaxSizeHint(0)).toBeNull();
|
|
23
|
+
expect(formatMaxSizeHint(-5)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('isAccepted supports wildcard image/* matching', () => {
|
|
27
|
+
expect(isAccepted({ accept: 'image/*', contentType: 'image/png', fileName: undefined })).toBe(
|
|
28
|
+
true,
|
|
29
|
+
);
|
|
30
|
+
expect(
|
|
31
|
+
isAccepted({ accept: 'image/*', contentType: 'application/json', fileName: undefined }),
|
|
32
|
+
).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('isAccepted supports exact MIME matching', () => {
|
|
36
|
+
expect(isAccepted({ accept: 'image/png', contentType: 'image/png', fileName: undefined })).toBe(
|
|
37
|
+
true,
|
|
38
|
+
);
|
|
39
|
+
expect(
|
|
40
|
+
isAccepted({ accept: 'image/png', contentType: 'image/jpeg', fileName: undefined }),
|
|
41
|
+
).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('isAccepted supports extension matching', () => {
|
|
45
|
+
expect(isAccepted({ accept: '.png', contentType: undefined, fileName: 'photo.png' })).toBe(
|
|
46
|
+
true,
|
|
47
|
+
);
|
|
48
|
+
expect(isAccepted({ accept: '.png', contentType: undefined, fileName: 'photo.jpg' })).toBe(
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('isAccepted does not block when it cannot validate', () => {
|
|
54
|
+
expect(isAccepted({ accept: 'image/*', contentType: undefined, fileName: undefined })).toBe(
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('validatePickedImage rejects by accept when metadata allows checking', () => {
|
|
60
|
+
const picked: ZoraPickedImage = { uri: 'file://a', contentType: 'application/json' };
|
|
61
|
+
expect(
|
|
62
|
+
validatePickedImage({
|
|
63
|
+
picked,
|
|
64
|
+
accept: 'image/*',
|
|
65
|
+
maxSizeBytes: undefined,
|
|
66
|
+
validatePicked: undefined,
|
|
67
|
+
}),
|
|
68
|
+
).toContain('File type not accepted');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('validatePickedImage rejects oversize files when sizeBytes is present', () => {
|
|
72
|
+
const picked: ZoraPickedImage = { uri: 'file://a', sizeBytes: 2_000, contentType: 'image/png' };
|
|
73
|
+
const error = validatePickedImage({
|
|
74
|
+
picked,
|
|
75
|
+
accept: 'image/*',
|
|
76
|
+
maxSizeBytes: 1_000,
|
|
77
|
+
validatePicked: undefined,
|
|
78
|
+
});
|
|
79
|
+
expect(error).toContain('File is too large');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('validatePickedImage allows oversize when sizeBytes is missing', () => {
|
|
83
|
+
const picked: ZoraPickedImage = { uri: 'file://a', contentType: 'image/png' };
|
|
84
|
+
expect(
|
|
85
|
+
validatePickedImage({
|
|
86
|
+
picked,
|
|
87
|
+
accept: 'image/*',
|
|
88
|
+
maxSizeBytes: 1_000,
|
|
89
|
+
validatePicked: undefined,
|
|
90
|
+
}),
|
|
91
|
+
).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('createOptimisticAssetFromPicked maps uri and metadata correctly', () => {
|
|
95
|
+
const picked: ZoraPickedImage = {
|
|
96
|
+
uri: 'file://picked',
|
|
97
|
+
fileName: 'picked.png',
|
|
98
|
+
sizeBytes: 123,
|
|
99
|
+
contentType: 'image/png',
|
|
100
|
+
};
|
|
101
|
+
const asset = createOptimisticAssetFromPicked(picked);
|
|
102
|
+
expect(asset.kind).toBe('url');
|
|
103
|
+
expect(asset.url).toBe('file://picked');
|
|
104
|
+
expect(asset.contentType).toBe('image/png');
|
|
105
|
+
expect(asset.metadata?.fileName).toBe('picked.png');
|
|
106
|
+
expect(asset.metadata?.sizeBytes).toBe(123);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('resolveRenderableUrl renders url assets directly and storage only with publicUrl', () => {
|
|
110
|
+
expect(resolveRenderableUrl(null)).toBeNull();
|
|
111
|
+
expect(resolveRenderableUrl({ kind: 'url', url: 'https://x' })).toBe('https://x');
|
|
112
|
+
expect(resolveRenderableUrl({ kind: 'storage', bucket: 'b', path: 'p' })).toBeNull();
|
|
113
|
+
expect(
|
|
114
|
+
resolveRenderableUrl({ kind: 'storage', bucket: 'b', path: 'p', publicUrl: 'https://y' }),
|
|
115
|
+
).toBe('https://y');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ZoraImageAsset } from '../image-preview';
|
|
2
|
+
import type { ZoraPickedImage } from './types';
|
|
3
|
+
|
|
4
|
+
function parseAccept(accept: string | undefined): readonly string[] {
|
|
5
|
+
if (!accept) return [];
|
|
6
|
+
|
|
7
|
+
return accept
|
|
8
|
+
.split(',')
|
|
9
|
+
.map((entry) => entry.trim())
|
|
10
|
+
.filter((entry) => entry.length > 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function matchesAcceptToken({
|
|
14
|
+
token,
|
|
15
|
+
contentType,
|
|
16
|
+
fileName,
|
|
17
|
+
}: {
|
|
18
|
+
token: string;
|
|
19
|
+
contentType: string | undefined;
|
|
20
|
+
fileName: string | undefined;
|
|
21
|
+
}): boolean | null {
|
|
22
|
+
const normalizedToken = token.toLowerCase();
|
|
23
|
+
const normalizedContentType = contentType?.toLowerCase();
|
|
24
|
+
const normalizedFileName = fileName?.toLowerCase();
|
|
25
|
+
|
|
26
|
+
if (normalizedToken.startsWith('.')) {
|
|
27
|
+
if (!normalizedFileName) return null;
|
|
28
|
+
return normalizedFileName.endsWith(normalizedToken);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (normalizedToken.endsWith('/*')) {
|
|
32
|
+
if (!normalizedContentType) return null;
|
|
33
|
+
const prefix = normalizedToken.slice(0, Math.max(0, normalizedToken.length - 1));
|
|
34
|
+
return normalizedContentType.startsWith(prefix);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!normalizedContentType) return null;
|
|
38
|
+
return normalizedContentType === normalizedToken;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isAccepted({
|
|
42
|
+
accept,
|
|
43
|
+
contentType,
|
|
44
|
+
fileName,
|
|
45
|
+
}: {
|
|
46
|
+
accept: string | undefined;
|
|
47
|
+
contentType: string | undefined;
|
|
48
|
+
fileName: string | undefined;
|
|
49
|
+
}): boolean {
|
|
50
|
+
const tokens = parseAccept(accept);
|
|
51
|
+
if (tokens.length === 0) return true;
|
|
52
|
+
|
|
53
|
+
let hadSignal = false;
|
|
54
|
+
|
|
55
|
+
for (const token of tokens) {
|
|
56
|
+
const matches = matchesAcceptToken({ token, contentType, fileName });
|
|
57
|
+
if (matches === null) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hadSignal = true;
|
|
62
|
+
if (matches) return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// If we cannot validate due to missing metadata, do not block.
|
|
66
|
+
return !hadSignal;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatBytes(value: number): string {
|
|
70
|
+
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
|
71
|
+
|
|
72
|
+
const units = ['B', 'KB', 'MB', 'GB'] as const;
|
|
73
|
+
let size = value;
|
|
74
|
+
let unitIndex = 0;
|
|
75
|
+
|
|
76
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
77
|
+
size /= 1024;
|
|
78
|
+
unitIndex += 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const unit = units[unitIndex] ?? 'B';
|
|
82
|
+
const rounded = unitIndex === 0 ? Math.round(size) : Math.round(size * 10) / 10;
|
|
83
|
+
return `${rounded} ${unit}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatAcceptHint(accept: string | undefined): string | null {
|
|
87
|
+
const tokens = parseAccept(accept);
|
|
88
|
+
if (tokens.length === 0) return null;
|
|
89
|
+
return `Accepted: ${tokens.join(', ')}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatMaxSizeHint(maxSizeBytes: number | undefined): string | null {
|
|
93
|
+
if (maxSizeBytes === undefined) return null;
|
|
94
|
+
if (!Number.isFinite(maxSizeBytes) || maxSizeBytes <= 0) return null;
|
|
95
|
+
return `Max size: ${formatBytes(maxSizeBytes)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createOptimisticAssetFromPicked(picked: ZoraPickedImage): ZoraImageAsset {
|
|
99
|
+
return {
|
|
100
|
+
kind: 'url',
|
|
101
|
+
url: picked.uri,
|
|
102
|
+
width: picked.width,
|
|
103
|
+
height: picked.height,
|
|
104
|
+
contentType: picked.contentType,
|
|
105
|
+
metadata: {
|
|
106
|
+
fileName: picked.fileName,
|
|
107
|
+
sizeBytes: picked.sizeBytes,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveRenderableUrl(asset: ZoraImageAsset | null): string | null {
|
|
113
|
+
if (!asset) return null;
|
|
114
|
+
if (asset.kind === 'url') return asset.url;
|
|
115
|
+
return asset.publicUrl ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function validatePickedImage({
|
|
119
|
+
picked,
|
|
120
|
+
accept,
|
|
121
|
+
maxSizeBytes,
|
|
122
|
+
validatePicked,
|
|
123
|
+
}: {
|
|
124
|
+
picked: ZoraPickedImage;
|
|
125
|
+
accept: string | undefined;
|
|
126
|
+
maxSizeBytes: number | undefined;
|
|
127
|
+
validatePicked: ((picked: ZoraPickedImage) => string | undefined) | undefined;
|
|
128
|
+
}): string | undefined {
|
|
129
|
+
if (!isAccepted({ accept, contentType: picked.contentType, fileName: picked.fileName })) {
|
|
130
|
+
return accept ? `File type not accepted. Expected ${accept}.` : 'File type not accepted.';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
maxSizeBytes !== undefined &&
|
|
135
|
+
Number.isFinite(maxSizeBytes) &&
|
|
136
|
+
maxSizeBytes > 0 &&
|
|
137
|
+
picked.sizeBytes !== undefined &&
|
|
138
|
+
Number.isFinite(picked.sizeBytes) &&
|
|
139
|
+
picked.sizeBytes > maxSizeBytes
|
|
140
|
+
) {
|
|
141
|
+
return `File is too large (${formatBytes(picked.sizeBytes)}). Max ${formatBytes(maxSizeBytes)}.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return validatePicked?.(picked);
|
|
145
|
+
}
|
|
@@ -34,6 +34,7 @@ const REQUIRED_SHOWCASE_COVERAGE = {
|
|
|
34
34
|
'Heading',
|
|
35
35
|
'Icon',
|
|
36
36
|
'IconButton',
|
|
37
|
+
'Image',
|
|
37
38
|
'Input',
|
|
38
39
|
'MediaCard',
|
|
39
40
|
'MetricCard',
|
|
@@ -88,6 +89,8 @@ const REQUIRED_SHOWCASE_COVERAGE = {
|
|
|
88
89
|
'ListRow',
|
|
89
90
|
'ListSection',
|
|
90
91
|
'InspectorField',
|
|
92
|
+
'ImagePreview',
|
|
93
|
+
'ImageUploadField',
|
|
91
94
|
'Notice',
|
|
92
95
|
'Panel',
|
|
93
96
|
'ResponsivePanel',
|