@mgonza02/goal-image-gallery 1.0.2 → 1.0.5
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/DOCUMENTATION_UPDATE_SUMMARY.md +311 -0
- package/IMPLEMENTATION_GUIDE.md +1392 -0
- package/PACKAGE_CLEANUP_SUMMARY.md +146 -0
- package/QUICK_REFERENCE.md +46 -0
- package/README.md +850 -85
- package/lib/GoalImageGallery.js +200 -40
- package/lib/components/ImageGalleryEmptyState.js +3 -8
- package/lib/components/ImageGalleryGrid.js +7 -16
- package/lib/components/ImageGalleryItem.js +5 -12
- package/lib/components/ImageGalleryModal.js +0 -3
- package/lib/examples.js +192 -1
- package/lib/index.d.ts +6 -0
- package/lib/styles/styled-components.js +2 -2
- package/package.json +7 -20
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
# GoalImageGallery Implementation Guide
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
1. [Quick Start](#quick-start)
|
|
6
|
+
2. [Basic Implementation](#basic-implementation)
|
|
7
|
+
3. [Advanced Configuration](#advanced-configuration)
|
|
8
|
+
4. [API Integration](#api-integration)
|
|
9
|
+
5. [Customization](#customization)
|
|
10
|
+
6. [Performance Optimization](#performance-optimization)
|
|
11
|
+
7. [Error Handling](#error-handling)
|
|
12
|
+
8. [Testing](#testing)
|
|
13
|
+
9. [Deployment](#deployment)
|
|
14
|
+
10. [Troubleshooting](#troubleshooting)
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
### Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Install the package
|
|
22
|
+
npm install @mgonza02/goal-image-gallery
|
|
23
|
+
|
|
24
|
+
# Install peer dependencies
|
|
25
|
+
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled react react-dom
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Basic Usage
|
|
29
|
+
|
|
30
|
+
```javascript
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
33
|
+
|
|
34
|
+
const App = () => {
|
|
35
|
+
return (
|
|
36
|
+
<GoalImageGallery
|
|
37
|
+
imageCodes={['img1', 'img2', 'img3']}
|
|
38
|
+
canEdit={false}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default App;
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Basic Implementation
|
|
47
|
+
|
|
48
|
+
### 1. Read-Only Gallery
|
|
49
|
+
|
|
50
|
+
Perfect for displaying images without editing capabilities:
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
import React from 'react';
|
|
54
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
55
|
+
|
|
56
|
+
const ProductImages = ({ product }) => {
|
|
57
|
+
return (
|
|
58
|
+
<GoalImageGallery
|
|
59
|
+
imageCodes={product.images}
|
|
60
|
+
canEdit={false}
|
|
61
|
+
emptyMessage="No product images available"
|
|
62
|
+
showImageInfo={true}
|
|
63
|
+
allowDownload={true}
|
|
64
|
+
getImageUrl={({ imageCode }) => `https://api.example.com/products/images/${imageCode}`}
|
|
65
|
+
noImageUrl="https://api.example.com/images/no-image.png"
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 2. Editable Gallery
|
|
72
|
+
|
|
73
|
+
Basic editable gallery with upload capabilities:
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import React, { useState } from 'react';
|
|
77
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
78
|
+
|
|
79
|
+
const EditableGallery = ({ initialImages, productId }) => {
|
|
80
|
+
const [images, setImages] = useState(initialImages);
|
|
81
|
+
|
|
82
|
+
const handleImageUpload = async (imageData) => {
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch('/api/upload', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: imageData.formData
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await response.json();
|
|
90
|
+
|
|
91
|
+
if (result.success) {
|
|
92
|
+
return {
|
|
93
|
+
success: true,
|
|
94
|
+
data: result.images,
|
|
95
|
+
message: 'Upload successful'
|
|
96
|
+
};
|
|
97
|
+
} else {
|
|
98
|
+
throw new Error(result.message);
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
message: error.message
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleAfterUpload = (result) => {
|
|
109
|
+
setImages(prev => [...prev, ...result.data.map(img => img.code)]);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<GoalImageGallery
|
|
114
|
+
imageCodes={images}
|
|
115
|
+
canEdit={true}
|
|
116
|
+
ownerEntity={{ id: productId, type: 'product' }}
|
|
117
|
+
imageHandlerApi={handleImageUpload}
|
|
118
|
+
afterUpload={handleAfterUpload}
|
|
119
|
+
multiple={true}
|
|
120
|
+
emptyMessage="No images yet. Add some!"
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Advanced Configuration
|
|
127
|
+
|
|
128
|
+
### 1. Full-Featured Gallery
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
import React, { useState, useCallback } from 'react';
|
|
132
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
133
|
+
import { useAuth } from './hooks/useAuth';
|
|
134
|
+
import { toast } from 'react-toastify';
|
|
135
|
+
|
|
136
|
+
const AdvancedGallery = ({ entity, entityType }) => {
|
|
137
|
+
const [images, setImages] = useState(entity.images || []);
|
|
138
|
+
const { user, hasPermission } = useAuth();
|
|
139
|
+
|
|
140
|
+
const handleImageUpload = useCallback(async (imageData) => {
|
|
141
|
+
try {
|
|
142
|
+
// Validate files
|
|
143
|
+
const validFiles = imageData.files.filter(file => {
|
|
144
|
+
if (file.size > 25 * 1024 * 1024) {
|
|
145
|
+
toast.error(`File ${file.name} is too large (max 25MB)`);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (!file.type.startsWith('image/')) {
|
|
149
|
+
toast.error(`File ${file.name} is not an image`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (validFiles.length === 0) {
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
message: 'No valid files to upload'
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Create form data
|
|
163
|
+
const formData = new FormData();
|
|
164
|
+
validFiles.forEach((file, index) => {
|
|
165
|
+
formData.append(`files[${index}]`, file);
|
|
166
|
+
});
|
|
167
|
+
formData.append('entityId', entity.id);
|
|
168
|
+
formData.append('entityType', entityType);
|
|
169
|
+
formData.append('companyId', user.companyId);
|
|
170
|
+
|
|
171
|
+
// Upload with progress
|
|
172
|
+
const response = await fetch('/api/images/upload', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
body: formData,
|
|
175
|
+
headers: {
|
|
176
|
+
'Authorization': `Bearer ${user.token}`
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await response.json();
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw new Error(result.message || 'Upload failed');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
success: true,
|
|
188
|
+
data: result.images,
|
|
189
|
+
message: `${result.images.length} image(s) uploaded successfully`
|
|
190
|
+
};
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('Upload error:', error);
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
message: error.message || 'Upload failed'
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}, [entity.id, entityType, user]);
|
|
199
|
+
|
|
200
|
+
const handleAfterUpload = useCallback((result) => {
|
|
201
|
+
setImages(prev => [...prev, ...result.data.map(img => img.code)]);
|
|
202
|
+
toast.success(result.message);
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
const handleShowError = useCallback((message) => {
|
|
206
|
+
toast.error(message);
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<GoalImageGallery
|
|
211
|
+
imageCodes={images}
|
|
212
|
+
canEdit={hasPermission('EDIT_IMAGES')}
|
|
213
|
+
ownerEntity={{ id: entity.id, type: entityType }}
|
|
214
|
+
imageHandlerApi={handleImageUpload}
|
|
215
|
+
afterUpload={handleAfterUpload}
|
|
216
|
+
multiple={true}
|
|
217
|
+
permission="EDIT_IMAGES"
|
|
218
|
+
emptyMessage={`No ${entityType} images available`}
|
|
219
|
+
showImageInfo={true}
|
|
220
|
+
allowDownload={true}
|
|
221
|
+
getImageUrl={({ imageCode, thumbMail }) =>
|
|
222
|
+
`https://api.example.com/images/${imageCode}${thumbMail ? '?thumb=true' : ''}`
|
|
223
|
+
}
|
|
224
|
+
noImageUrl="https://api.example.com/images/placeholder.png"
|
|
225
|
+
showError={handleShowError}
|
|
226
|
+
hasPermission={hasPermission}
|
|
227
|
+
currentCompany={user.companyId}
|
|
228
|
+
/>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 2. Permission-Based Gallery
|
|
234
|
+
|
|
235
|
+
```javascript
|
|
236
|
+
import React from 'react';
|
|
237
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
238
|
+
import { usePermissions } from './hooks/usePermissions';
|
|
239
|
+
|
|
240
|
+
const PermissionBasedGallery = ({ images, entityId, entityType }) => {
|
|
241
|
+
const { hasPermission, user } = usePermissions();
|
|
242
|
+
|
|
243
|
+
const canEdit = hasPermission('EDIT_IMAGES') || hasPermission(`EDIT_${entityType.toUpperCase()}`);
|
|
244
|
+
const canView = hasPermission('VIEW_IMAGES') || hasPermission(`VIEW_${entityType.toUpperCase()}`);
|
|
245
|
+
|
|
246
|
+
if (!canView) {
|
|
247
|
+
return <div>You don't have permission to view these images.</div>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<GoalImageGallery
|
|
252
|
+
imageCodes={images}
|
|
253
|
+
canEdit={canEdit}
|
|
254
|
+
ownerEntity={{ id: entityId, type: entityType }}
|
|
255
|
+
imageHandlerApi={canEdit ? handleImageUpload : undefined}
|
|
256
|
+
afterUpload={canEdit ? handleAfterUpload : undefined}
|
|
257
|
+
multiple={true}
|
|
258
|
+
permission={`EDIT_${entityType.toUpperCase()}`}
|
|
259
|
+
hasPermission={hasPermission}
|
|
260
|
+
currentCompany={user.companyId}
|
|
261
|
+
emptyMessage={canEdit ? "No images. Add some!" : "No images available"}
|
|
262
|
+
allowDownload={hasPermission('DOWNLOAD_IMAGES')}
|
|
263
|
+
/>
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## API Integration
|
|
269
|
+
|
|
270
|
+
### 1. REST API Integration
|
|
271
|
+
|
|
272
|
+
```javascript
|
|
273
|
+
class ImageAPI {
|
|
274
|
+
constructor(baseURL, authToken) {
|
|
275
|
+
this.baseURL = baseURL;
|
|
276
|
+
this.authToken = authToken;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async uploadImages(files, metadata) {
|
|
280
|
+
const formData = new FormData();
|
|
281
|
+
|
|
282
|
+
files.forEach((file, index) => {
|
|
283
|
+
formData.append(`files[${index}]`, file);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
Object.entries(metadata).forEach(([key, value]) => {
|
|
287
|
+
formData.append(key, value);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const response = await fetch(`${this.baseURL}/images/upload`, {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
body: formData,
|
|
293
|
+
headers: {
|
|
294
|
+
'Authorization': `Bearer ${this.authToken}`
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return response.json();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async deleteImage(imageCode) {
|
|
306
|
+
const response = await fetch(`${this.baseURL}/images/${imageCode}`, {
|
|
307
|
+
method: 'DELETE',
|
|
308
|
+
headers: {
|
|
309
|
+
'Authorization': `Bearer ${this.authToken}`
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return response.json();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Usage
|
|
322
|
+
const api = new ImageAPI('https://api.example.com', userToken);
|
|
323
|
+
|
|
324
|
+
const handleImageUpload = async (imageData) => {
|
|
325
|
+
try {
|
|
326
|
+
const result = await api.uploadImages(imageData.files, {
|
|
327
|
+
entityId: imageData.metadata.entityId,
|
|
328
|
+
entityType: imageData.metadata.entityType,
|
|
329
|
+
companyId: imageData.metadata.companyId
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
success: true,
|
|
334
|
+
data: result.images,
|
|
335
|
+
message: 'Upload successful'
|
|
336
|
+
};
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return {
|
|
339
|
+
success: false,
|
|
340
|
+
message: error.message
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### 2. GraphQL Integration
|
|
347
|
+
|
|
348
|
+
```javascript
|
|
349
|
+
import { useMutation } from '@apollo/client';
|
|
350
|
+
import { gql } from '@apollo/client';
|
|
351
|
+
|
|
352
|
+
const UPLOAD_IMAGES = gql`
|
|
353
|
+
mutation UploadImages($files: [Upload!]!, $input: ImageUploadInput!) {
|
|
354
|
+
uploadImages(files: $files, input: $input) {
|
|
355
|
+
success
|
|
356
|
+
message
|
|
357
|
+
images {
|
|
358
|
+
id
|
|
359
|
+
code
|
|
360
|
+
url
|
|
361
|
+
thumbnailUrl
|
|
362
|
+
filename
|
|
363
|
+
size
|
|
364
|
+
width
|
|
365
|
+
height
|
|
366
|
+
createdAt
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
`;
|
|
371
|
+
|
|
372
|
+
const DELETE_IMAGE = gql`
|
|
373
|
+
mutation DeleteImage($imageCode: String!) {
|
|
374
|
+
deleteImage(imageCode: $imageCode) {
|
|
375
|
+
success
|
|
376
|
+
message
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
`;
|
|
380
|
+
|
|
381
|
+
const GraphQLImageGallery = ({ entityId, entityType, images }) => {
|
|
382
|
+
const [uploadImages] = useMutation(UPLOAD_IMAGES);
|
|
383
|
+
const [deleteImage] = useMutation(DELETE_IMAGE);
|
|
384
|
+
|
|
385
|
+
const handleImageUpload = async (imageData) => {
|
|
386
|
+
try {
|
|
387
|
+
const { data } = await uploadImages({
|
|
388
|
+
variables: {
|
|
389
|
+
files: imageData.files,
|
|
390
|
+
input: {
|
|
391
|
+
entityId,
|
|
392
|
+
entityType,
|
|
393
|
+
companyId: imageData.metadata.companyId
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (data.uploadImages.success) {
|
|
399
|
+
return {
|
|
400
|
+
success: true,
|
|
401
|
+
data: data.uploadImages.images,
|
|
402
|
+
message: data.uploadImages.message
|
|
403
|
+
};
|
|
404
|
+
} else {
|
|
405
|
+
throw new Error(data.uploadImages.message);
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return {
|
|
409
|
+
success: false,
|
|
410
|
+
message: error.message
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<GoalImageGallery
|
|
417
|
+
imageCodes={images}
|
|
418
|
+
canEdit={true}
|
|
419
|
+
ownerEntity={{ id: entityId, type: entityType }}
|
|
420
|
+
imageHandlerApi={handleImageUpload}
|
|
421
|
+
multiple={true}
|
|
422
|
+
/>
|
|
423
|
+
);
|
|
424
|
+
};
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### 3. Firebase Integration
|
|
428
|
+
|
|
429
|
+
```javascript
|
|
430
|
+
import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage';
|
|
431
|
+
import { storage } from './firebaseConfig';
|
|
432
|
+
|
|
433
|
+
const FirebaseImageGallery = ({ entityId, entityType, images }) => {
|
|
434
|
+
const handleImageUpload = async (imageData) => {
|
|
435
|
+
try {
|
|
436
|
+
const uploadPromises = imageData.files.map(async (file) => {
|
|
437
|
+
const filename = `${entityType}/${entityId}/${Date.now()}_${file.name}`;
|
|
438
|
+
const storageRef = ref(storage, filename);
|
|
439
|
+
|
|
440
|
+
const snapshot = await uploadBytes(storageRef, file);
|
|
441
|
+
const downloadURL = await getDownloadURL(snapshot.ref);
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
id: snapshot.ref.fullPath,
|
|
445
|
+
code: snapshot.ref.name,
|
|
446
|
+
url: downloadURL,
|
|
447
|
+
filename: file.name,
|
|
448
|
+
size: file.size,
|
|
449
|
+
mimeType: file.type,
|
|
450
|
+
width: 0, // You might want to get actual dimensions
|
|
451
|
+
height: 0,
|
|
452
|
+
createdAt: new Date().toISOString()
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const uploadedImages = await Promise.all(uploadPromises);
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
success: true,
|
|
460
|
+
data: uploadedImages,
|
|
461
|
+
message: 'Images uploaded successfully'
|
|
462
|
+
};
|
|
463
|
+
} catch (error) {
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
message: error.message
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<GoalImageGallery
|
|
473
|
+
imageCodes={images}
|
|
474
|
+
canEdit={true}
|
|
475
|
+
ownerEntity={{ id: entityId, type: entityType }}
|
|
476
|
+
imageHandlerApi={handleImageUpload}
|
|
477
|
+
multiple={true}
|
|
478
|
+
/>
|
|
479
|
+
);
|
|
480
|
+
};
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
## Customization
|
|
484
|
+
|
|
485
|
+
### 1. Custom Image Selector
|
|
486
|
+
|
|
487
|
+
```javascript
|
|
488
|
+
import React, { useState, useCallback } from 'react';
|
|
489
|
+
import { Box, Typography, Button, LinearProgress } from '@mui/material';
|
|
490
|
+
import { CloudUpload, Close } from '@mui/icons-material';
|
|
491
|
+
|
|
492
|
+
const CustomImageSelector = ({
|
|
493
|
+
afterUpload,
|
|
494
|
+
activate,
|
|
495
|
+
onCancel,
|
|
496
|
+
multiple,
|
|
497
|
+
title,
|
|
498
|
+
uploadMessage,
|
|
499
|
+
maxFileSize = 25 * 1024 * 1024,
|
|
500
|
+
acceptedFormats = ['image/jpeg', 'image/png', 'image/webp'],
|
|
501
|
+
customStyles = {}
|
|
502
|
+
}) => {
|
|
503
|
+
const [dragActive, setDragActive] = useState(false);
|
|
504
|
+
const [uploading, setUploading] = useState(false);
|
|
505
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
506
|
+
|
|
507
|
+
const handleFiles = useCallback(async (files) => {
|
|
508
|
+
setUploading(true);
|
|
509
|
+
setUploadProgress(0);
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
// Validate files
|
|
513
|
+
const validFiles = Array.from(files).filter(file => {
|
|
514
|
+
if (file.size > maxFileSize) {
|
|
515
|
+
console.warn(`File ${file.name} exceeds size limit`);
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
if (!acceptedFormats.includes(file.type)) {
|
|
519
|
+
console.warn(`File ${file.name} has unsupported format`);
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (validFiles.length === 0) {
|
|
526
|
+
throw new Error('No valid files selected');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Simulate upload progress
|
|
530
|
+
const interval = setInterval(() => {
|
|
531
|
+
setUploadProgress(prev => {
|
|
532
|
+
if (prev >= 90) {
|
|
533
|
+
clearInterval(interval);
|
|
534
|
+
return prev;
|
|
535
|
+
}
|
|
536
|
+
return prev + 10;
|
|
537
|
+
});
|
|
538
|
+
}, 200);
|
|
539
|
+
|
|
540
|
+
// Process files
|
|
541
|
+
const processedFiles = validFiles.map(file => ({
|
|
542
|
+
file,
|
|
543
|
+
name: file.name,
|
|
544
|
+
size: file.size,
|
|
545
|
+
type: file.type
|
|
546
|
+
}));
|
|
547
|
+
|
|
548
|
+
// Call afterUpload with processed files
|
|
549
|
+
const result = await afterUpload({ files: processedFiles });
|
|
550
|
+
|
|
551
|
+
setUploadProgress(100);
|
|
552
|
+
clearInterval(interval);
|
|
553
|
+
|
|
554
|
+
return result;
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.error('Upload error:', error);
|
|
557
|
+
throw error;
|
|
558
|
+
} finally {
|
|
559
|
+
setUploading(false);
|
|
560
|
+
setUploadProgress(0);
|
|
561
|
+
}
|
|
562
|
+
}, [afterUpload, maxFileSize, acceptedFormats]);
|
|
563
|
+
|
|
564
|
+
const handleDragOver = useCallback((e) => {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
setDragActive(true);
|
|
567
|
+
}, []);
|
|
568
|
+
|
|
569
|
+
const handleDragLeave = useCallback((e) => {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
setDragActive(false);
|
|
572
|
+
}, []);
|
|
573
|
+
|
|
574
|
+
const handleDrop = useCallback(async (e) => {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
setDragActive(false);
|
|
577
|
+
|
|
578
|
+
const files = e.dataTransfer.files;
|
|
579
|
+
if (files.length > 0) {
|
|
580
|
+
await handleFiles(files);
|
|
581
|
+
}
|
|
582
|
+
}, [handleFiles]);
|
|
583
|
+
|
|
584
|
+
const handleFileSelect = useCallback(async (e) => {
|
|
585
|
+
const files = e.target.files;
|
|
586
|
+
if (files.length > 0) {
|
|
587
|
+
await handleFiles(files);
|
|
588
|
+
}
|
|
589
|
+
}, [handleFiles]);
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<Box
|
|
593
|
+
sx={{
|
|
594
|
+
border: dragActive ? '2px solid #1976d2' : '2px dashed #ccc',
|
|
595
|
+
borderRadius: 2,
|
|
596
|
+
padding: 4,
|
|
597
|
+
textAlign: 'center',
|
|
598
|
+
backgroundColor: dragActive ? '#f3f4f6' : 'background.paper',
|
|
599
|
+
transition: 'all 0.2s ease-in-out',
|
|
600
|
+
minHeight: 200,
|
|
601
|
+
display: 'flex',
|
|
602
|
+
flexDirection: 'column',
|
|
603
|
+
alignItems: 'center',
|
|
604
|
+
justifyContent: 'center',
|
|
605
|
+
...customStyles
|
|
606
|
+
}}
|
|
607
|
+
onDragOver={handleDragOver}
|
|
608
|
+
onDragLeave={handleDragLeave}
|
|
609
|
+
onDrop={handleDrop}
|
|
610
|
+
>
|
|
611
|
+
<CloudUpload sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
|
612
|
+
|
|
613
|
+
<Typography variant="h6" gutterBottom>
|
|
614
|
+
{title}
|
|
615
|
+
</Typography>
|
|
616
|
+
|
|
617
|
+
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
618
|
+
{uploadMessage}
|
|
619
|
+
</Typography>
|
|
620
|
+
|
|
621
|
+
<Typography variant="caption" color="text.secondary" sx={{ mb: 2 }}>
|
|
622
|
+
Max file size: {Math.round(maxFileSize / (1024 * 1024))}MB
|
|
623
|
+
</Typography>
|
|
624
|
+
|
|
625
|
+
{uploading && (
|
|
626
|
+
<Box sx={{ width: '100%', mb: 2 }}>
|
|
627
|
+
<LinearProgress variant="determinate" value={uploadProgress} />
|
|
628
|
+
<Typography variant="caption" color="text.secondary">
|
|
629
|
+
Uploading... {uploadProgress}%
|
|
630
|
+
</Typography>
|
|
631
|
+
</Box>
|
|
632
|
+
)}
|
|
633
|
+
|
|
634
|
+
<input
|
|
635
|
+
type="file"
|
|
636
|
+
multiple={multiple}
|
|
637
|
+
accept={acceptedFormats.join(',')}
|
|
638
|
+
onChange={handleFileSelect}
|
|
639
|
+
style={{ display: 'none' }}
|
|
640
|
+
id="file-input"
|
|
641
|
+
disabled={uploading}
|
|
642
|
+
/>
|
|
643
|
+
|
|
644
|
+
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
|
645
|
+
<Button
|
|
646
|
+
variant="contained"
|
|
647
|
+
component="label"
|
|
648
|
+
htmlFor="file-input"
|
|
649
|
+
disabled={uploading}
|
|
650
|
+
startIcon={<CloudUpload />}
|
|
651
|
+
>
|
|
652
|
+
Choose Files
|
|
653
|
+
</Button>
|
|
654
|
+
|
|
655
|
+
<Button
|
|
656
|
+
variant="outlined"
|
|
657
|
+
onClick={onCancel}
|
|
658
|
+
disabled={uploading}
|
|
659
|
+
startIcon={<Close />}
|
|
660
|
+
>
|
|
661
|
+
Cancel
|
|
662
|
+
</Button>
|
|
663
|
+
</Box>
|
|
664
|
+
</Box>
|
|
665
|
+
);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// Usage
|
|
669
|
+
const GalleryWithCustomSelector = () => {
|
|
670
|
+
return (
|
|
671
|
+
<GoalImageGallery
|
|
672
|
+
imageCodes={images}
|
|
673
|
+
canEdit={true}
|
|
674
|
+
ownerEntity={{ id: 'entity-123' }}
|
|
675
|
+
imageHandlerApi={handleUpload}
|
|
676
|
+
multiple={true}
|
|
677
|
+
slot={{
|
|
678
|
+
selector: CustomImageSelector
|
|
679
|
+
}}
|
|
680
|
+
slotProps={{
|
|
681
|
+
selector: {
|
|
682
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
683
|
+
acceptedFormats: ['image/jpeg', 'image/png'],
|
|
684
|
+
customStyles: {
|
|
685
|
+
backgroundColor: '#f8f9fa',
|
|
686
|
+
borderColor: '#007bff'
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}}
|
|
690
|
+
/>
|
|
691
|
+
);
|
|
692
|
+
};
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### 2. Custom Error Handling
|
|
696
|
+
|
|
697
|
+
```javascript
|
|
698
|
+
import React from 'react';
|
|
699
|
+
import { Snackbar, Alert } from '@mui/material';
|
|
700
|
+
|
|
701
|
+
const GalleryWithErrorHandling = ({ images, entityId }) => {
|
|
702
|
+
const [error, setError] = useState(null);
|
|
703
|
+
|
|
704
|
+
const handleShowError = (message) => {
|
|
705
|
+
setError(message);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const handleCloseError = () => {
|
|
709
|
+
setError(null);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
<>
|
|
714
|
+
<GoalImageGallery
|
|
715
|
+
imageCodes={images}
|
|
716
|
+
canEdit={true}
|
|
717
|
+
ownerEntity={{ id: entityId }}
|
|
718
|
+
imageHandlerApi={handleUpload}
|
|
719
|
+
showError={handleShowError}
|
|
720
|
+
multiple={true}
|
|
721
|
+
/>
|
|
722
|
+
|
|
723
|
+
<Snackbar
|
|
724
|
+
open={!!error}
|
|
725
|
+
autoHideDuration={6000}
|
|
726
|
+
onClose={handleCloseError}
|
|
727
|
+
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
728
|
+
>
|
|
729
|
+
<Alert onClose={handleCloseError} severity="error" sx={{ width: '100%' }}>
|
|
730
|
+
{error}
|
|
731
|
+
</Alert>
|
|
732
|
+
</Snackbar>
|
|
733
|
+
</>
|
|
734
|
+
);
|
|
735
|
+
};
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
## Performance Optimization
|
|
739
|
+
|
|
740
|
+
### 1. Lazy Loading Implementation
|
|
741
|
+
|
|
742
|
+
```javascript
|
|
743
|
+
import React, { useMemo } from 'react';
|
|
744
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
745
|
+
|
|
746
|
+
const OptimizedGallery = ({ images, entityId }) => {
|
|
747
|
+
// Memoize expensive operations
|
|
748
|
+
const memoizedImages = useMemo(() => {
|
|
749
|
+
return images.slice(0, 50); // Limit initial load
|
|
750
|
+
}, [images]);
|
|
751
|
+
|
|
752
|
+
const optimizedGetImageUrl = useMemo(() => {
|
|
753
|
+
return ({ imageCode, thumbMail }) => {
|
|
754
|
+
const baseUrl = 'https://api.example.com';
|
|
755
|
+
const size = thumbMail ? 'thumb' : 'full';
|
|
756
|
+
return `${baseUrl}/images/${imageCode}?size=${size}&format=webp`;
|
|
757
|
+
};
|
|
758
|
+
}, []);
|
|
759
|
+
|
|
760
|
+
return (
|
|
761
|
+
<GoalImageGallery
|
|
762
|
+
imageCodes={memoizedImages}
|
|
763
|
+
canEdit={true}
|
|
764
|
+
ownerEntity={{ id: entityId }}
|
|
765
|
+
imageHandlerApi={handleUpload}
|
|
766
|
+
getImageUrl={optimizedGetImageUrl}
|
|
767
|
+
multiple={true}
|
|
768
|
+
enableLazyLoading={true}
|
|
769
|
+
/>
|
|
770
|
+
);
|
|
771
|
+
};
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### 2. Virtual Scrolling for Large Galleries
|
|
775
|
+
|
|
776
|
+
```javascript
|
|
777
|
+
import React, { useState, useCallback } from 'react';
|
|
778
|
+
import { FixedSizeGrid as Grid } from 'react-window';
|
|
779
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
780
|
+
|
|
781
|
+
const VirtualizedGallery = ({ images, entityId }) => {
|
|
782
|
+
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
|
|
783
|
+
|
|
784
|
+
const visibleImages = useMemo(() => {
|
|
785
|
+
return images.slice(visibleRange.start, visibleRange.end);
|
|
786
|
+
}, [images, visibleRange]);
|
|
787
|
+
|
|
788
|
+
const handleScroll = useCallback((scrollTop) => {
|
|
789
|
+
const itemHeight = 200;
|
|
790
|
+
const start = Math.floor(scrollTop / itemHeight);
|
|
791
|
+
const end = start + 20;
|
|
792
|
+
setVisibleRange({ start, end });
|
|
793
|
+
}, []);
|
|
794
|
+
|
|
795
|
+
return (
|
|
796
|
+
<GoalImageGallery
|
|
797
|
+
imageCodes={visibleImages}
|
|
798
|
+
canEdit={true}
|
|
799
|
+
ownerEntity={{ id: entityId }}
|
|
800
|
+
imageHandlerApi={handleUpload}
|
|
801
|
+
multiple={true}
|
|
802
|
+
onScroll={handleScroll}
|
|
803
|
+
enableVirtualization={true}
|
|
804
|
+
/>
|
|
805
|
+
);
|
|
806
|
+
};
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
## Error Handling
|
|
810
|
+
|
|
811
|
+
### 1. Comprehensive Error Handling
|
|
812
|
+
|
|
813
|
+
```javascript
|
|
814
|
+
import React, { useState, useCallback } from 'react';
|
|
815
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
816
|
+
|
|
817
|
+
const RobustGallery = ({ images, entityId }) => {
|
|
818
|
+
const [errors, setErrors] = useState([]);
|
|
819
|
+
|
|
820
|
+
const handleImageUpload = useCallback(async (imageData) => {
|
|
821
|
+
try {
|
|
822
|
+
// Validate files
|
|
823
|
+
const validationErrors = [];
|
|
824
|
+
const validFiles = imageData.files.filter(file => {
|
|
825
|
+
if (file.size > 25 * 1024 * 1024) {
|
|
826
|
+
validationErrors.push(`File ${file.name} is too large (max 25MB)`);
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
if (!file.type.startsWith('image/')) {
|
|
830
|
+
validationErrors.push(`File ${file.name} is not a valid image`);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
return true;
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
if (validationErrors.length > 0) {
|
|
837
|
+
setErrors(validationErrors);
|
|
838
|
+
return {
|
|
839
|
+
success: false,
|
|
840
|
+
message: 'Some files could not be uploaded'
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Attempt upload with retry logic
|
|
845
|
+
let attempts = 0;
|
|
846
|
+
const maxAttempts = 3;
|
|
847
|
+
|
|
848
|
+
while (attempts < maxAttempts) {
|
|
849
|
+
try {
|
|
850
|
+
const response = await uploadFiles(validFiles);
|
|
851
|
+
return {
|
|
852
|
+
success: true,
|
|
853
|
+
data: response.data,
|
|
854
|
+
message: 'Upload successful'
|
|
855
|
+
};
|
|
856
|
+
} catch (error) {
|
|
857
|
+
attempts++;
|
|
858
|
+
if (attempts >= maxAttempts) {
|
|
859
|
+
throw error;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Exponential backoff
|
|
863
|
+
await new Promise(resolve =>
|
|
864
|
+
setTimeout(resolve, Math.pow(2, attempts) * 1000)
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
} catch (error) {
|
|
869
|
+
console.error('Upload error:', error);
|
|
870
|
+
|
|
871
|
+
// Categorize errors
|
|
872
|
+
let userMessage = 'Upload failed. Please try again.';
|
|
873
|
+
|
|
874
|
+
if (error.name === 'NetworkError') {
|
|
875
|
+
userMessage = 'Network error. Please check your connection.';
|
|
876
|
+
} else if (error.status === 413) {
|
|
877
|
+
userMessage = 'File too large. Please select smaller files.';
|
|
878
|
+
} else if (error.status === 403) {
|
|
879
|
+
userMessage = 'You don\'t have permission to upload files.';
|
|
880
|
+
} else if (error.status === 500) {
|
|
881
|
+
userMessage = 'Server error. Please try again later.';
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return {
|
|
885
|
+
success: false,
|
|
886
|
+
message: userMessage,
|
|
887
|
+
error: error
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
}, []);
|
|
891
|
+
|
|
892
|
+
const handleShowError = useCallback((message) => {
|
|
893
|
+
setErrors(prev => [...prev, message]);
|
|
894
|
+
}, []);
|
|
895
|
+
|
|
896
|
+
const clearErrors = useCallback(() => {
|
|
897
|
+
setErrors([]);
|
|
898
|
+
}, []);
|
|
899
|
+
|
|
900
|
+
return (
|
|
901
|
+
<div>
|
|
902
|
+
{errors.length > 0 && (
|
|
903
|
+
<div style={{
|
|
904
|
+
backgroundColor: '#ffebee',
|
|
905
|
+
padding: '10px',
|
|
906
|
+
borderRadius: '4px',
|
|
907
|
+
marginBottom: '10px'
|
|
908
|
+
}}>
|
|
909
|
+
{errors.map((error, index) => (
|
|
910
|
+
<div key={index} style={{ color: '#c62828' }}>
|
|
911
|
+
{error}
|
|
912
|
+
</div>
|
|
913
|
+
))}
|
|
914
|
+
<button onClick={clearErrors} style={{ marginTop: '5px' }}>
|
|
915
|
+
Clear Errors
|
|
916
|
+
</button>
|
|
917
|
+
</div>
|
|
918
|
+
)}
|
|
919
|
+
|
|
920
|
+
<GoalImageGallery
|
|
921
|
+
imageCodes={images}
|
|
922
|
+
canEdit={true}
|
|
923
|
+
ownerEntity={{ id: entityId }}
|
|
924
|
+
imageHandlerApi={handleImageUpload}
|
|
925
|
+
showError={handleShowError}
|
|
926
|
+
multiple={true}
|
|
927
|
+
/>
|
|
928
|
+
</div>
|
|
929
|
+
);
|
|
930
|
+
};
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
## Testing
|
|
934
|
+
|
|
935
|
+
### 1. Unit Tests
|
|
936
|
+
|
|
937
|
+
```javascript
|
|
938
|
+
import React from 'react';
|
|
939
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
940
|
+
import '@testing-library/jest-dom';
|
|
941
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
942
|
+
|
|
943
|
+
describe('GoalImageGallery', () => {
|
|
944
|
+
const mockImageHandlerApi = jest.fn();
|
|
945
|
+
const mockAfterUpload = jest.fn();
|
|
946
|
+
|
|
947
|
+
beforeEach(() => {
|
|
948
|
+
mockImageHandlerApi.mockClear();
|
|
949
|
+
mockAfterUpload.mockClear();
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test('renders gallery with images', () => {
|
|
953
|
+
render(
|
|
954
|
+
<GoalImageGallery
|
|
955
|
+
imageCodes={['img1', 'img2']}
|
|
956
|
+
canEdit={false}
|
|
957
|
+
/>
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
expect(screen.getByRole('grid')).toBeInTheDocument();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test('handles file upload', async () => {
|
|
964
|
+
mockImageHandlerApi.mockResolvedValue({
|
|
965
|
+
success: true,
|
|
966
|
+
data: [{ code: 'new-img', url: 'http://example.com/new-img.jpg' }],
|
|
967
|
+
message: 'Upload successful'
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
render(
|
|
971
|
+
<GoalImageGallery
|
|
972
|
+
imageCodes={[]}
|
|
973
|
+
canEdit={true}
|
|
974
|
+
ownerEntity={{ id: 'test-entity' }}
|
|
975
|
+
imageHandlerApi={mockImageHandlerApi}
|
|
976
|
+
afterUpload={mockAfterUpload}
|
|
977
|
+
multiple={true}
|
|
978
|
+
/>
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
// Simulate file upload
|
|
982
|
+
const fileInput = screen.getByLabelText(/select files/i);
|
|
983
|
+
const file = new File(['dummy content'], 'test.jpg', { type: 'image/jpeg' });
|
|
984
|
+
|
|
985
|
+
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
986
|
+
|
|
987
|
+
await waitFor(() => {
|
|
988
|
+
expect(mockImageHandlerApi).toHaveBeenCalledWith(
|
|
989
|
+
expect.objectContaining({
|
|
990
|
+
files: [file]
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
expect(mockAfterUpload).toHaveBeenCalledWith({
|
|
996
|
+
success: true,
|
|
997
|
+
data: [{ code: 'new-img', url: 'http://example.com/new-img.jpg' }],
|
|
998
|
+
message: 'Upload successful'
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test('handles upload error', async () => {
|
|
1003
|
+
mockImageHandlerApi.mockResolvedValue({
|
|
1004
|
+
success: false,
|
|
1005
|
+
message: 'Upload failed'
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
const mockShowError = jest.fn();
|
|
1009
|
+
|
|
1010
|
+
render(
|
|
1011
|
+
<GoalImageGallery
|
|
1012
|
+
imageCodes={[]}
|
|
1013
|
+
canEdit={true}
|
|
1014
|
+
ownerEntity={{ id: 'test-entity' }}
|
|
1015
|
+
imageHandlerApi={mockImageHandlerApi}
|
|
1016
|
+
showError={mockShowError}
|
|
1017
|
+
multiple={true}
|
|
1018
|
+
/>
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
// Simulate file upload
|
|
1022
|
+
const fileInput = screen.getByLabelText(/select files/i);
|
|
1023
|
+
const file = new File(['dummy content'], 'test.jpg', { type: 'image/jpeg' });
|
|
1024
|
+
|
|
1025
|
+
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
1026
|
+
|
|
1027
|
+
await waitFor(() => {
|
|
1028
|
+
expect(mockShowError).toHaveBeenCalledWith('Upload failed');
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('supports keyboard navigation', () => {
|
|
1033
|
+
render(
|
|
1034
|
+
<GoalImageGallery
|
|
1035
|
+
imageCodes={['img1', 'img2']}
|
|
1036
|
+
canEdit={false}
|
|
1037
|
+
/>
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
const gallery = screen.getByRole('grid');
|
|
1041
|
+
|
|
1042
|
+
// Test tab navigation
|
|
1043
|
+
fireEvent.keyDown(gallery, { key: 'Tab' });
|
|
1044
|
+
expect(document.activeElement).toBe(gallery.firstChild);
|
|
1045
|
+
|
|
1046
|
+
// Test arrow navigation
|
|
1047
|
+
fireEvent.keyDown(gallery, { key: 'ArrowRight' });
|
|
1048
|
+
// Add assertions for navigation behavior
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
### 2. Integration Tests
|
|
1054
|
+
|
|
1055
|
+
```javascript
|
|
1056
|
+
import React from 'react';
|
|
1057
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
1058
|
+
import { GoalImageGallery } from '@mgonza02/goal-image-gallery';
|
|
1059
|
+
import { rest } from 'msw';
|
|
1060
|
+
import { setupServer } from 'msw/node';
|
|
1061
|
+
|
|
1062
|
+
const server = setupServer(
|
|
1063
|
+
rest.post('/api/upload', (req, res, ctx) => {
|
|
1064
|
+
return res(ctx.json({
|
|
1065
|
+
success: true,
|
|
1066
|
+
images: [
|
|
1067
|
+
{ code: 'img1', url: 'http://example.com/img1.jpg' }
|
|
1068
|
+
]
|
|
1069
|
+
}));
|
|
1070
|
+
})
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
beforeAll(() => server.listen());
|
|
1074
|
+
afterEach(() => server.resetHandlers());
|
|
1075
|
+
afterAll(() => server.close());
|
|
1076
|
+
|
|
1077
|
+
test('full upload workflow', async () => {
|
|
1078
|
+
const handleImageUpload = async (imageData) => {
|
|
1079
|
+
const formData = new FormData();
|
|
1080
|
+
imageData.files.forEach(file => formData.append('files', file));
|
|
1081
|
+
|
|
1082
|
+
const response = await fetch('/api/upload', {
|
|
1083
|
+
method: 'POST',
|
|
1084
|
+
body: formData
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
const result = await response.json();
|
|
1088
|
+
return result;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
render(
|
|
1092
|
+
<GoalImageGallery
|
|
1093
|
+
imageCodes={[]}
|
|
1094
|
+
canEdit={true}
|
|
1095
|
+
ownerEntity={{ id: 'test-entity' }}
|
|
1096
|
+
imageHandlerApi={handleImageUpload}
|
|
1097
|
+
multiple={true}
|
|
1098
|
+
/>
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
// Upload a file
|
|
1102
|
+
const fileInput = screen.getByLabelText(/select files/i);
|
|
1103
|
+
const file = new File(['dummy content'], 'test.jpg', { type: 'image/jpeg' });
|
|
1104
|
+
|
|
1105
|
+
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
1106
|
+
|
|
1107
|
+
// Wait for upload to complete
|
|
1108
|
+
await waitFor(() => {
|
|
1109
|
+
expect(screen.getByAltText(/img1/i)).toBeInTheDocument();
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
## Deployment
|
|
1115
|
+
|
|
1116
|
+
### 1. Build Configuration
|
|
1117
|
+
|
|
1118
|
+
```javascript
|
|
1119
|
+
// webpack.config.js
|
|
1120
|
+
const path = require('path');
|
|
1121
|
+
|
|
1122
|
+
module.exports = {
|
|
1123
|
+
entry: './src/index.js',
|
|
1124
|
+
output: {
|
|
1125
|
+
path: path.resolve(__dirname, 'dist'),
|
|
1126
|
+
filename: 'goal-image-gallery.js',
|
|
1127
|
+
library: 'GoalImageGallery',
|
|
1128
|
+
libraryTarget: 'umd'
|
|
1129
|
+
},
|
|
1130
|
+
externals: {
|
|
1131
|
+
react: 'React',
|
|
1132
|
+
'react-dom': 'ReactDOM',
|
|
1133
|
+
'@mui/material': 'mui-material',
|
|
1134
|
+
'@mui/icons-material': 'mui-icons-material',
|
|
1135
|
+
'@emotion/react': 'emotion-react',
|
|
1136
|
+
'@emotion/styled': 'emotion-styled'
|
|
1137
|
+
},
|
|
1138
|
+
module: {
|
|
1139
|
+
rules: [
|
|
1140
|
+
{
|
|
1141
|
+
test: /\.(js|jsx)$/,
|
|
1142
|
+
exclude: /node_modules/,
|
|
1143
|
+
use: 'babel-loader'
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
test: /\.css$/,
|
|
1147
|
+
use: ['style-loader', 'css-loader']
|
|
1148
|
+
}
|
|
1149
|
+
]
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
### 2. Package.json Scripts
|
|
1155
|
+
|
|
1156
|
+
```json
|
|
1157
|
+
{
|
|
1158
|
+
"scripts": {
|
|
1159
|
+
"build": "npm run clean && npm run build:js && npm run build:types",
|
|
1160
|
+
"build:js": "babel src --out-dir lib --copy-files --ignore '**/*.test.js'",
|
|
1161
|
+
"build:types": "cp src/index.d.ts lib/index.d.ts",
|
|
1162
|
+
"build:watch": "npm run build:js -- --watch",
|
|
1163
|
+
"clean": "rm -rf lib dist",
|
|
1164
|
+
"test": "jest",
|
|
1165
|
+
"test:watch": "jest --watch",
|
|
1166
|
+
"test:coverage": "jest --coverage",
|
|
1167
|
+
"lint": "eslint src --ext .js,.jsx",
|
|
1168
|
+
"lint:fix": "eslint src --ext .js,.jsx --fix",
|
|
1169
|
+
"format": "prettier --write src/**/*.{js,jsx}",
|
|
1170
|
+
"validate": "npm run lint && npm run test && npm run build",
|
|
1171
|
+
"prepublishOnly": "npm run validate"
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
### 3. CDN Deployment
|
|
1177
|
+
|
|
1178
|
+
```html
|
|
1179
|
+
<!-- Include via CDN -->
|
|
1180
|
+
<script src="https://unpkg.com/@mgonza02/goal-image-gallery@latest/dist/goal-image-gallery.js"></script>
|
|
1181
|
+
|
|
1182
|
+
<script>
|
|
1183
|
+
// Use the global GoalImageGallery
|
|
1184
|
+
const gallery = React.createElement(GoalImageGallery, {
|
|
1185
|
+
imageCodes: ['img1', 'img2'],
|
|
1186
|
+
canEdit: false
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
ReactDOM.render(gallery, document.getElementById('gallery-container'));
|
|
1190
|
+
</script>
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
## Troubleshooting
|
|
1194
|
+
|
|
1195
|
+
### Common Issues and Solutions
|
|
1196
|
+
|
|
1197
|
+
#### 1. Images Not Loading
|
|
1198
|
+
|
|
1199
|
+
**Problem**: Images show as broken or don't load at all.
|
|
1200
|
+
|
|
1201
|
+
**Solutions**:
|
|
1202
|
+
```javascript
|
|
1203
|
+
// Check image URLs
|
|
1204
|
+
const debugGetImageUrl = ({ imageCode, thumbMail }) => {
|
|
1205
|
+
const url = `https://api.example.com/images/${imageCode}`;
|
|
1206
|
+
console.log('Generated URL:', url);
|
|
1207
|
+
return url;
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
// Add error handling
|
|
1211
|
+
const handleImageError = (error) => {
|
|
1212
|
+
console.error('Image load error:', error);
|
|
1213
|
+
// Show fallback image or placeholder
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// Use with gallery
|
|
1217
|
+
<GoalImageGallery
|
|
1218
|
+
imageCodes={images}
|
|
1219
|
+
getImageUrl={debugGetImageUrl}
|
|
1220
|
+
onImageError={handleImageError}
|
|
1221
|
+
noImageUrl="/fallback-image.jpg"
|
|
1222
|
+
/>
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
#### 2. Upload Not Working
|
|
1226
|
+
|
|
1227
|
+
**Problem**: File uploads fail or don't trigger.
|
|
1228
|
+
|
|
1229
|
+
**Solutions**:
|
|
1230
|
+
```javascript
|
|
1231
|
+
// Debug upload function
|
|
1232
|
+
const debugUploadHandler = async (imageData) => {
|
|
1233
|
+
console.log('Upload data:', imageData);
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
// Validate data
|
|
1237
|
+
if (!imageData.files || imageData.files.length === 0) {
|
|
1238
|
+
throw new Error('No files provided');
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Test API call
|
|
1242
|
+
const response = await fetch('/api/upload', {
|
|
1243
|
+
method: 'POST',
|
|
1244
|
+
body: imageData.formData
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
console.log('Upload response:', response);
|
|
1248
|
+
|
|
1249
|
+
const result = await response.json();
|
|
1250
|
+
console.log('Upload result:', result);
|
|
1251
|
+
|
|
1252
|
+
return result;
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
console.error('Upload error:', error);
|
|
1255
|
+
return {
|
|
1256
|
+
success: false,
|
|
1257
|
+
message: error.message
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
#### 3. Permission Issues
|
|
1264
|
+
|
|
1265
|
+
**Problem**: Users can't edit even with proper permissions.
|
|
1266
|
+
|
|
1267
|
+
**Solutions**:
|
|
1268
|
+
```javascript
|
|
1269
|
+
// Debug permission system
|
|
1270
|
+
const debugPermissions = (permission) => {
|
|
1271
|
+
console.log('Checking permission:', permission);
|
|
1272
|
+
console.log('User permissions:', user.permissions);
|
|
1273
|
+
|
|
1274
|
+
const hasPermission = user.permissions.includes(permission);
|
|
1275
|
+
console.log('Has permission:', hasPermission);
|
|
1276
|
+
|
|
1277
|
+
return hasPermission;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
// Use with gallery
|
|
1281
|
+
<GoalImageGallery
|
|
1282
|
+
imageCodes={images}
|
|
1283
|
+
canEdit={true}
|
|
1284
|
+
permission="EDIT_IMAGES"
|
|
1285
|
+
hasPermission={debugPermissions}
|
|
1286
|
+
/>
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
#### 4. Performance Issues
|
|
1290
|
+
|
|
1291
|
+
**Problem**: Gallery is slow with many images.
|
|
1292
|
+
|
|
1293
|
+
**Solutions**:
|
|
1294
|
+
```javascript
|
|
1295
|
+
// Optimize image loading
|
|
1296
|
+
const optimizedGallery = () => {
|
|
1297
|
+
const [visibleImages, setVisibleImages] = useState(images.slice(0, 20));
|
|
1298
|
+
|
|
1299
|
+
const loadMoreImages = useCallback(() => {
|
|
1300
|
+
setVisibleImages(prev => [
|
|
1301
|
+
...prev,
|
|
1302
|
+
...images.slice(prev.length, prev.length + 20)
|
|
1303
|
+
]);
|
|
1304
|
+
}, [images]);
|
|
1305
|
+
|
|
1306
|
+
return (
|
|
1307
|
+
<GoalImageGallery
|
|
1308
|
+
imageCodes={visibleImages}
|
|
1309
|
+
onLoadMore={loadMoreImages}
|
|
1310
|
+
enableLazyLoading={true}
|
|
1311
|
+
/>
|
|
1312
|
+
);
|
|
1313
|
+
};
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
#### 5. Accessibility Issues
|
|
1317
|
+
|
|
1318
|
+
**Problem**: Gallery not accessible to screen readers.
|
|
1319
|
+
|
|
1320
|
+
**Solutions**:
|
|
1321
|
+
```javascript
|
|
1322
|
+
// Enhance accessibility
|
|
1323
|
+
<GoalImageGallery
|
|
1324
|
+
imageCodes={images}
|
|
1325
|
+
aria-label="Product image gallery"
|
|
1326
|
+
aria-describedby="gallery-instructions"
|
|
1327
|
+
role="img"
|
|
1328
|
+
// Add descriptive alt text
|
|
1329
|
+
getImageAlt={(imageCode) => `Product image ${imageCode}`}
|
|
1330
|
+
/>
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
### Debug Mode
|
|
1334
|
+
|
|
1335
|
+
Enable debug mode for development:
|
|
1336
|
+
|
|
1337
|
+
```javascript
|
|
1338
|
+
const DebugGallery = ({ images }) => {
|
|
1339
|
+
const [debugMode, setDebugMode] = useState(process.env.NODE_ENV === 'development');
|
|
1340
|
+
|
|
1341
|
+
return (
|
|
1342
|
+
<div>
|
|
1343
|
+
<button onClick={() => setDebugMode(!debugMode)}>
|
|
1344
|
+
Toggle Debug Mode
|
|
1345
|
+
</button>
|
|
1346
|
+
|
|
1347
|
+
<GoalImageGallery
|
|
1348
|
+
imageCodes={images}
|
|
1349
|
+
debug={debugMode}
|
|
1350
|
+
onDebug={(event, data) => {
|
|
1351
|
+
console.log('Debug event:', event, data);
|
|
1352
|
+
}}
|
|
1353
|
+
/>
|
|
1354
|
+
</div>
|
|
1355
|
+
);
|
|
1356
|
+
};
|
|
1357
|
+
```
|
|
1358
|
+
|
|
1359
|
+
### Browser Compatibility
|
|
1360
|
+
|
|
1361
|
+
```javascript
|
|
1362
|
+
// Check browser support
|
|
1363
|
+
const checkBrowserSupport = () => {
|
|
1364
|
+
const support = {
|
|
1365
|
+
fileAPI: typeof FileReader !== 'undefined',
|
|
1366
|
+
dragDrop: 'draggable' in document.createElement('div'),
|
|
1367
|
+
clipboard: navigator.clipboard !== undefined,
|
|
1368
|
+
webp: document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
console.log('Browser support:', support);
|
|
1372
|
+
return support;
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
// Use with gallery
|
|
1376
|
+
const BrowserAwareGallery = ({ images }) => {
|
|
1377
|
+
const [browserSupport] = useState(checkBrowserSupport());
|
|
1378
|
+
|
|
1379
|
+
return (
|
|
1380
|
+
<GoalImageGallery
|
|
1381
|
+
imageCodes={images}
|
|
1382
|
+
enableClipboard={browserSupport.clipboard}
|
|
1383
|
+
enableDragDrop={browserSupport.dragDrop}
|
|
1384
|
+
preferWebP={browserSupport.webp}
|
|
1385
|
+
/>
|
|
1386
|
+
);
|
|
1387
|
+
};
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
---
|
|
1391
|
+
|
|
1392
|
+
This implementation guide provides comprehensive coverage of the GoalImageGallery component, from basic usage to advanced customization and troubleshooting. For additional help or specific use cases, refer to the component's documentation or contact the development team.
|