@object-ui/fields 3.3.2 → 3.4.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.
@@ -1,6 +1,7 @@
1
1
  import React, { useRef, useState, useCallback } from 'react';
2
2
  import { Button, EmptyValue } from '@object-ui/components';
3
- import { Upload, X, File as FileIcon, ImageIcon } from 'lucide-react';
3
+ import { useUpload } from '@object-ui/providers';
4
+ import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
4
5
  import { FieldWidgetProps } from './types';
5
6
 
6
7
  /**
@@ -10,20 +11,37 @@ import { FieldWidgetProps } from './types';
10
11
  */
11
12
  export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
12
13
  const inputRef = useRef<HTMLInputElement>(null);
14
+ const cameraRef = useRef<HTMLInputElement>(null);
13
15
  const fileField = (field || (props as any).schema) as any;
14
16
  const multiple = fileField?.multiple || false;
15
17
  const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
16
18
  const maxSize = fileField?.maxSize as number | undefined; // bytes
19
+ /**
20
+ * Camera capture mode for mobile devices.
21
+ * - `'environment'` (back camera): photos of receipts, documents, products
22
+ * - `'user'` (front camera): selfies, profile pictures
23
+ * - `false`: disable the camera button entirely
24
+ * @default 'environment' when accept includes image/* on a touch device
25
+ */
26
+ const captureMode = (fileField?.capture ?? null) as 'environment' | 'user' | false | null;
27
+ const acceptsImages = !accept || accept.split(',').some((t: string) =>
28
+ t.trim().startsWith('image/') || t.trim() === 'image/*' || t.trim().startsWith('.jp') || t.trim().startsWith('.png') || t.trim().startsWith('.gif') || t.trim().startsWith('.webp'),
29
+ );
30
+ // Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
31
+ const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
32
+ const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
17
33
  const [isDragOver, setIsDragOver] = useState(false);
18
34
  const [errors, setErrors] = useState<string[]>([]);
35
+ const { upload } = useUpload();
36
+ const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
37
+ const [uploading, setUploading] = useState(false);
19
38
 
20
39
  const files = value ? (Array.isArray(value) ? value : [value]) : [];
21
40
 
22
- const processFiles = useCallback((selectedFiles: File[]) => {
41
+ const processFiles = useCallback(async (selectedFiles: File[]) => {
23
42
  if (selectedFiles.length === 0) return;
24
43
  const newErrors: string[] = [];
25
44
 
26
- // Validate file sizes
27
45
  const validFiles = selectedFiles.filter(file => {
28
46
  if (maxSize && file.size > maxSize) {
29
47
  const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
@@ -36,21 +54,42 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
36
54
 
37
55
  if (validFiles.length === 0) return;
38
56
 
39
- const fileObjects = validFiles.map(file => ({
40
- name: file.name,
41
- original_name: file.name,
42
- size: file.size,
43
- mime_type: file.type,
44
- // In a real implementation, this would upload the file and return a URL
45
- url: URL.createObjectURL(file),
46
- }));
57
+ setUploading(true);
58
+ try {
59
+ const fileObjects = await Promise.all(
60
+ validFiles.map(async (file) => {
61
+ try {
62
+ const result = await upload(file, {
63
+ onProgress: (ratio) =>
64
+ setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
65
+ });
66
+ return {
67
+ name: result.name,
68
+ original_name: file.name,
69
+ size: result.size,
70
+ mime_type: result.mimeType,
71
+ url: result.url,
72
+ };
73
+ } catch (err) {
74
+ newErrors.push(`Failed to upload "${file.name}": ${(err as Error).message}`);
75
+ setErrors([...newErrors]);
76
+ return null;
77
+ }
78
+ }),
79
+ );
80
+ const successful = fileObjects.filter(Boolean) as any[];
81
+ if (successful.length === 0) return;
47
82
 
48
- if (multiple) {
49
- onChange([...files, ...fileObjects]);
50
- } else {
51
- onChange(fileObjects[0]);
83
+ if (multiple) {
84
+ onChange([...files, ...successful]);
85
+ } else {
86
+ onChange(successful[0]);
87
+ }
88
+ } finally {
89
+ setUploading(false);
90
+ setUploadProgress({});
52
91
  }
53
- }, [files, multiple, onChange, maxSize]);
92
+ }, [files, multiple, onChange, maxSize, upload]);
54
93
 
55
94
  const handleDragOver = useCallback((e: React.DragEvent) => {
56
95
  e.preventDefault();
@@ -128,6 +167,18 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
128
167
  onChange={handleFileChange}
129
168
  className="hidden"
130
169
  />
170
+ {cameraEnabled && (
171
+ <input
172
+ ref={cameraRef}
173
+ type="file"
174
+ accept="image/*"
175
+ capture={cameraEnabled}
176
+ onChange={handleFileChange}
177
+ className="hidden"
178
+ aria-label="Camera capture"
179
+ data-testid="file-field-camera-input"
180
+ />
181
+ )}
131
182
 
132
183
  <div className="space-y-2">
133
184
  {/* Drag-and-drop zone */}
@@ -159,11 +210,43 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
159
210
  {isDragOver ? 'Drop files here' : 'Drag & drop files here'}
160
211
  </p>
161
212
  <p className="text-xs text-muted-foreground mt-1">
162
- or click to browse
213
+ or click to browse{cameraEnabled ? ' • use the camera button below' : ''}
163
214
  </p>
164
215
  </div>
165
216
  </div>
166
217
 
218
+ {cameraEnabled && (
219
+ <Button
220
+ type="button"
221
+ variant="outline"
222
+ size="sm"
223
+ className="w-full"
224
+ onClick={(e) => {
225
+ e.stopPropagation();
226
+ cameraRef.current?.click();
227
+ }}
228
+ data-testid="file-field-camera-button"
229
+ >
230
+ <Camera className="size-4 mr-2" />
231
+ {cameraEnabled === 'user' ? 'Take selfie' : 'Take photo'}
232
+ </Button>
233
+ )}
234
+
235
+ {/* Upload progress indicator */}
236
+ {uploading && (
237
+ <div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
238
+ <Loader2 className="size-3 animate-spin" />
239
+ <span>
240
+ Uploading…
241
+ {Object.keys(uploadProgress).length > 0 &&
242
+ ` (${Math.round(
243
+ (Object.values(uploadProgress).reduce((s, v) => s + v, 0) /
244
+ Object.keys(uploadProgress).length) * 100,
245
+ )}%)`}
246
+ </span>
247
+ </div>
248
+ )}
249
+
167
250
  {/* Validation errors */}
168
251
  {errors.length > 0 && (
169
252
  <div className="space-y-0.5">
@@ -0,0 +1,245 @@
1
+ /**
2
+ * ImageCropperDialog - Lightweight crop/rotate dialog for ImageField.
3
+ *
4
+ * Implementation notes:
5
+ * - Uses a Canvas + native pointer events (no extra dependency) so the bundle
6
+ * stays small and the widget works in all SDUI contexts.
7
+ * - Exposes a rectangular crop selection that can be dragged around the image
8
+ * and a rotate-by-90° button.
9
+ * - On confirm, returns a `Blob` of the cropped image (PNG by default) so callers
10
+ * can wire it into upload providers or `URL.createObjectURL()`.
11
+ */
12
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
13
+ import {
14
+ Button,
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from '@object-ui/components';
22
+ import { RotateCw, Crop as CropIcon } from 'lucide-react';
23
+
24
+ export interface ImageCropperDialogProps {
25
+ open: boolean;
26
+ onOpenChange: (open: boolean) => void;
27
+ /** Source image URL or data URL */
28
+ src: string;
29
+ /** MIME type for the cropped output. Defaults to image/png. */
30
+ outputType?: string;
31
+ /** Suggested filename (passed back via onConfirm). Defaults to "cropped.png". */
32
+ outputName?: string;
33
+ /** Called when the user confirms the crop. */
34
+ onConfirm: (blob: Blob, name: string) => void;
35
+ }
36
+
37
+ interface CropRect {
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ height: number;
42
+ }
43
+
44
+ export const ImageCropperDialog: React.FC<ImageCropperDialogProps> = ({
45
+ open,
46
+ onOpenChange,
47
+ src,
48
+ outputType = 'image/png',
49
+ outputName = 'cropped.png',
50
+ onConfirm,
51
+ }) => {
52
+ const canvasRef = useRef<HTMLCanvasElement>(null);
53
+ const imgRef = useRef<HTMLImageElement | null>(null);
54
+ const [rotation, setRotation] = useState(0); // 0, 90, 180, 270
55
+ const [crop, setCrop] = useState<CropRect>({ x: 0, y: 0, width: 100, height: 100 });
56
+ const [drag, setDrag] = useState<{ ox: number; oy: number; cx: number; cy: number } | null>(null);
57
+ const [imageReady, setImageReady] = useState(false);
58
+
59
+ // Load the image when the dialog opens
60
+ useEffect(() => {
61
+ if (!open || !src) return;
62
+ setImageReady(false);
63
+ const img = new Image();
64
+ img.crossOrigin = 'anonymous';
65
+ img.onload = () => {
66
+ imgRef.current = img;
67
+ // Default crop = centred 80% box
68
+ const w = img.naturalWidth * 0.8;
69
+ const h = img.naturalHeight * 0.8;
70
+ setCrop({
71
+ x: (img.naturalWidth - w) / 2,
72
+ y: (img.naturalHeight - h) / 2,
73
+ width: w,
74
+ height: h,
75
+ });
76
+ setRotation(0);
77
+ setImageReady(true);
78
+ };
79
+ img.onerror = () => setImageReady(false);
80
+ img.src = src;
81
+ }, [open, src]);
82
+
83
+ // Render image + crop overlay
84
+ useEffect(() => {
85
+ if (!imageReady || !imgRef.current || !canvasRef.current) return;
86
+ const img = imgRef.current;
87
+ const canvas = canvasRef.current;
88
+ const ctx = canvas.getContext('2d');
89
+ if (!ctx) return;
90
+
91
+ // Scale image to fit a 480px-wide preview while preserving aspect
92
+ const previewW = 480;
93
+ const scale = previewW / img.naturalWidth;
94
+ const previewH = img.naturalHeight * scale;
95
+ canvas.width = previewW;
96
+ canvas.height = previewH;
97
+ ctx.clearRect(0, 0, previewW, previewH);
98
+
99
+ // Apply rotation to the image (purely visual — actual rotation is baked in on confirm)
100
+ ctx.save();
101
+ ctx.translate(previewW / 2, previewH / 2);
102
+ ctx.rotate((rotation * Math.PI) / 180);
103
+ const drawW = rotation % 180 === 0 ? previewW : previewH;
104
+ const drawH = rotation % 180 === 0 ? previewH : previewW;
105
+ ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
106
+ ctx.restore();
107
+
108
+ // Draw crop overlay (in preview coordinates)
109
+ const px = crop.x * scale;
110
+ const py = crop.y * scale;
111
+ const pw = crop.width * scale;
112
+ const ph = crop.height * scale;
113
+ ctx.fillStyle = 'rgba(0,0,0,0.45)';
114
+ ctx.fillRect(0, 0, previewW, py);
115
+ ctx.fillRect(0, py + ph, previewW, previewH - py - ph);
116
+ ctx.fillRect(0, py, px, ph);
117
+ ctx.fillRect(px + pw, py, previewW - px - pw, ph);
118
+ ctx.strokeStyle = '#3b82f6';
119
+ ctx.lineWidth = 2;
120
+ ctx.strokeRect(px, py, pw, ph);
121
+ }, [imageReady, crop, rotation]);
122
+
123
+ const handlePointerDown = useCallback(
124
+ (e: React.PointerEvent<HTMLCanvasElement>) => {
125
+ if (!imgRef.current || !canvasRef.current) return;
126
+ const rect = canvasRef.current.getBoundingClientRect();
127
+ const scale = imgRef.current.naturalWidth / rect.width;
128
+ setDrag({
129
+ ox: (e.clientX - rect.left) * scale,
130
+ oy: (e.clientY - rect.top) * scale,
131
+ cx: crop.x,
132
+ cy: crop.y,
133
+ });
134
+ (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
135
+ },
136
+ [crop],
137
+ );
138
+
139
+ const handlePointerMove = useCallback(
140
+ (e: React.PointerEvent<HTMLCanvasElement>) => {
141
+ if (!drag || !imgRef.current || !canvasRef.current) return;
142
+ const rect = canvasRef.current.getBoundingClientRect();
143
+ const scale = imgRef.current.naturalWidth / rect.width;
144
+ const dx = (e.clientX - rect.left) * scale - drag.ox;
145
+ const dy = (e.clientY - rect.top) * scale - drag.oy;
146
+ const nextX = Math.max(0, Math.min(drag.cx + dx, imgRef.current.naturalWidth - crop.width));
147
+ const nextY = Math.max(0, Math.min(drag.cy + dy, imgRef.current.naturalHeight - crop.height));
148
+ setCrop((c) => ({ ...c, x: nextX, y: nextY }));
149
+ },
150
+ [drag, crop.width, crop.height],
151
+ );
152
+
153
+ const handlePointerUp = useCallback(() => setDrag(null), []);
154
+
155
+ const rotate = useCallback(() => setRotation((r) => (r + 90) % 360), []);
156
+
157
+ const handleConfirm = useCallback(async () => {
158
+ if (!imgRef.current) return;
159
+ const img = imgRef.current;
160
+ const out = document.createElement('canvas');
161
+ // After rotation, swap width/height when needed
162
+ const cw = crop.width;
163
+ const ch = crop.height;
164
+ out.width = rotation % 180 === 0 ? cw : ch;
165
+ out.height = rotation % 180 === 0 ? ch : cw;
166
+ const ctx = out.getContext('2d');
167
+ if (!ctx) return;
168
+ ctx.save();
169
+ ctx.translate(out.width / 2, out.height / 2);
170
+ ctx.rotate((rotation * Math.PI) / 180);
171
+ ctx.drawImage(img, crop.x, crop.y, cw, ch, -cw / 2, -ch / 2, cw, ch);
172
+ ctx.restore();
173
+ const blob: Blob | null = await new Promise((resolve) => out.toBlob(resolve, outputType));
174
+ if (blob) {
175
+ onConfirm(blob, outputName);
176
+ onOpenChange(false);
177
+ }
178
+ }, [crop, rotation, outputName, outputType, onConfirm, onOpenChange]);
179
+
180
+ return (
181
+ <Dialog open={open} onOpenChange={onOpenChange}>
182
+ <DialogContent className="max-w-xl">
183
+ <DialogHeader>
184
+ <DialogTitle>Crop & rotate image</DialogTitle>
185
+ <DialogDescription>
186
+ Drag the highlighted box to reposition the crop. Use the rotate button to spin the image
187
+ in 90° increments.
188
+ </DialogDescription>
189
+ </DialogHeader>
190
+
191
+ <div className="flex flex-col items-center gap-3">
192
+ {imageReady ? (
193
+ <canvas
194
+ ref={canvasRef}
195
+ data-testid="image-cropper-canvas"
196
+ role="img"
197
+ aria-label="Image crop preview"
198
+ className="border rounded-md cursor-move touch-none max-w-full"
199
+ onPointerDown={handlePointerDown}
200
+ onPointerMove={handlePointerMove}
201
+ onPointerUp={handlePointerUp}
202
+ onPointerCancel={handlePointerUp}
203
+ />
204
+ ) : (
205
+ <div className="flex h-[270px] w-full items-center justify-center text-sm text-muted-foreground">
206
+ Loading image…
207
+ </div>
208
+ )}
209
+ <div className="flex w-full items-center justify-between text-xs text-muted-foreground">
210
+ <span>
211
+ Crop: {Math.round(crop.width)} × {Math.round(crop.height)} px
212
+ </span>
213
+ <span>Rotation: {rotation}°</span>
214
+ </div>
215
+ </div>
216
+
217
+ <DialogFooter className="flex justify-between sm:justify-between">
218
+ <Button
219
+ type="button"
220
+ variant="outline"
221
+ onClick={rotate}
222
+ data-testid="image-cropper-rotate"
223
+ >
224
+ <RotateCw className="size-4 mr-2" />
225
+ Rotate 90°
226
+ </Button>
227
+ <div className="flex gap-2">
228
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
229
+ Cancel
230
+ </Button>
231
+ <Button
232
+ type="button"
233
+ onClick={handleConfirm}
234
+ disabled={!imageReady}
235
+ data-testid="image-cropper-confirm"
236
+ >
237
+ <CropIcon className="size-4 mr-2" />
238
+ Apply crop
239
+ </Button>
240
+ </div>
241
+ </DialogFooter>
242
+ </DialogContent>
243
+ </Dialog>
244
+ );
245
+ };
@@ -1,8 +1,15 @@
1
- import React, { useRef } from 'react';
1
+ import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
2
2
  import { Button, EmptyValue } from '@object-ui/components';
3
- import { Upload, X, Image as ImageIcon } from 'lucide-react';
3
+ import { useUpload } from '@object-ui/providers';
4
+ import { Upload, X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
4
5
  import { FieldWidgetProps } from './types';
5
6
 
7
+ // Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
8
+ // ImageField bundle. Consumers that never crop pay zero cost.
9
+ const ImageCropperDialog = lazy(() =>
10
+ import('./ImageCropperDialog').then((m) => ({ default: m.ImageCropperDialog })),
11
+ );
12
+
6
13
  /**
7
14
  * ImageField - Image upload widget with preview thumbnails
8
15
  * Supports single and multiple image uploads with drag-and-drop and preview display
@@ -12,6 +19,13 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
12
19
  const imageField = (field || (props as any).schema) as any;
13
20
  const multiple = imageField?.multiple || false;
14
21
  const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
22
+ /**
23
+ * Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
24
+ */
25
+ const cropEnabled = imageField?.crop !== false;
26
+ const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
27
+ const { upload } = useUpload();
28
+ const [uploading, setUploading] = useState(false);
15
29
 
16
30
  if (readonly) {
17
31
  if (!value) return <EmptyValue />;
@@ -33,23 +47,34 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
33
47
 
34
48
  const images = value ? (Array.isArray(value) ? value : [value]) : [];
35
49
 
36
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
50
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
37
51
  const selectedFiles = Array.from(e.target.files || []);
38
52
  if (selectedFiles.length === 0) return;
39
53
 
40
- const imageObjects = selectedFiles.map(file => ({
41
- name: file.name,
42
- original_name: file.name,
43
- size: file.size,
44
- mime_type: file.type,
45
- // In a real implementation, this would upload the image and return a URL
46
- url: URL.createObjectURL(file),
47
- }));
54
+ setUploading(true);
55
+ try {
56
+ const imageObjects = await Promise.all(
57
+ selectedFiles.map(async (file) => {
58
+ const result = await upload(file);
59
+ return {
60
+ name: result.name,
61
+ original_name: file.name,
62
+ size: result.size,
63
+ mime_type: result.mimeType,
64
+ url: result.url,
65
+ };
66
+ }),
67
+ );
48
68
 
49
- if (multiple) {
50
- onChange([...images, ...imageObjects]);
51
- } else {
52
- onChange(imageObjects[0]);
69
+ if (multiple) {
70
+ onChange([...images, ...imageObjects]);
71
+ } else {
72
+ onChange(imageObjects[0]);
73
+ }
74
+ } finally {
75
+ setUploading(false);
76
+ // Reset input so picking the same file again still triggers change.
77
+ if (inputRef.current) inputRef.current.value = '';
53
78
  }
54
79
  };
55
80
 
@@ -62,6 +87,43 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
62
87
  }
63
88
  };
64
89
 
90
+ const handleCropConfirm = useCallback(
91
+ async (blob: Blob, name: string) => {
92
+ if (!cropTarget) return;
93
+ setUploading(true);
94
+ try {
95
+ const result = await upload(blob);
96
+ const next = {
97
+ name: result.name || name,
98
+ original_name: name,
99
+ size: result.size,
100
+ mime_type: result.mimeType,
101
+ url: result.url,
102
+ };
103
+ if (multiple) {
104
+ const updated = [...images];
105
+ updated[cropTarget.index] = next;
106
+ onChange(updated);
107
+ } else {
108
+ onChange(next);
109
+ }
110
+ } finally {
111
+ setUploading(false);
112
+ setCropTarget(null);
113
+ }
114
+ },
115
+ [cropTarget, images, multiple, onChange, upload],
116
+ );
117
+
118
+ const openCropper = useCallback(
119
+ (index: number) => {
120
+ const img = images[index];
121
+ if (!img?.url) return;
122
+ setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
123
+ },
124
+ [images],
125
+ );
126
+
65
127
  return (
66
128
  <div className={props.className}>
67
129
  <input
@@ -83,15 +145,30 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
83
145
  alt={img.name || `Image ${idx + 1}`}
84
146
  className="size-20 rounded-md object-cover border border-gray-200"
85
147
  />
86
- <Button
87
- type="button"
88
- variant="destructive"
89
- size="sm"
90
- onClick={() => handleRemove(idx)}
91
- className="absolute top-1 right-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
92
- >
93
- <X className="size-3" />
94
- </Button>
148
+ <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
149
+ {cropEnabled && (
150
+ <Button
151
+ type="button"
152
+ variant="secondary"
153
+ size="sm"
154
+ onClick={() => openCropper(idx)}
155
+ className="h-6 w-6 p-0"
156
+ aria-label={`Crop image ${idx + 1}`}
157
+ data-testid={`image-field-crop-${idx}`}
158
+ >
159
+ <CropIcon className="size-3" />
160
+ </Button>
161
+ )}
162
+ <Button
163
+ type="button"
164
+ variant="destructive"
165
+ size="sm"
166
+ onClick={() => handleRemove(idx)}
167
+ className="h-6 w-6 p-0"
168
+ >
169
+ <X className="size-3" />
170
+ </Button>
171
+ </div>
95
172
  </div>
96
173
  ))}
97
174
  </div>
@@ -102,11 +179,29 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
102
179
  variant="outline"
103
180
  onClick={() => inputRef.current?.click()}
104
181
  className="w-full"
182
+ disabled={uploading}
183
+ data-testid="image-field-upload-button"
105
184
  >
106
- <ImageIcon className="size-4 mr-2" />
107
- {images.length > 0 ? 'Add More Images' : 'Upload Image'}
185
+ {uploading ? (
186
+ <Loader2 className="size-4 mr-2 animate-spin" />
187
+ ) : (
188
+ <ImageIcon className="size-4 mr-2" />
189
+ )}
190
+ {uploading ? 'Uploading…' : images.length > 0 ? 'Add More Images' : 'Upload Image'}
108
191
  </Button>
109
192
  </div>
193
+
194
+ {cropEnabled && cropTarget && (
195
+ <Suspense fallback={null}>
196
+ <ImageCropperDialog
197
+ open
198
+ onOpenChange={(o) => !o && setCropTarget(null)}
199
+ src={cropTarget.src}
200
+ outputName={cropTarget.name}
201
+ onConfirm={handleCropConfirm}
202
+ />
203
+ </Suspense>
204
+ )}
110
205
  </div>
111
206
  );
112
207
  }