@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.
@@ -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.