vasuzex 2.1.4 → 2.1.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/framework/Database/Model.js +35 -0
- package/framework/Database/QueryBuilder.js +21 -0
- package/framework/Foundation/BaseApp.js +2 -0
- package/framework/Services/Storage/Providers/S3StorageProvider.js +21 -0
- package/framework/Services/Storage/StorageServiceProvider.js +0 -4
- package/framework/Services/Upload/FileValidator.js +4 -3
- package/framework/Services/Upload/index.js +1 -1
- package/framework/Validation/Validator.js +37 -0
- package/frontend/client/Errors/FormErrorHandler.js +97 -20
- package/frontend/react-ui/components/PhotoManager/EnhancedPhotoManager.jsx +313 -0
- package/frontend/react-ui/components/PhotoManager/PhotoManager.jsx +34 -0
- package/frontend/react-ui/components/PhotoManager/README.md +423 -0
- package/frontend/react-ui/components/PhotoManager/index.js +1 -0
- package/package.json +11 -2
|
@@ -832,9 +832,44 @@ export class Model extends GuruORMModel {
|
|
|
832
832
|
query = query.whereNull(this.deletedAt);
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
+
// Monkey-patch the update method to add timestamps
|
|
836
|
+
const modelClass = this;
|
|
837
|
+
const originalUpdate = query.update;
|
|
838
|
+
|
|
839
|
+
query.update = async function(data) {
|
|
840
|
+
// Add updated_at if timestamps are enabled
|
|
841
|
+
if (modelClass.timestamps && modelClass.updatedAt && data[modelClass.updatedAt] === undefined) {
|
|
842
|
+
data[modelClass.updatedAt] = new Date();
|
|
843
|
+
}
|
|
844
|
+
return await originalUpdate.call(this, data);
|
|
845
|
+
};
|
|
846
|
+
|
|
835
847
|
return query;
|
|
836
848
|
}
|
|
837
849
|
|
|
850
|
+
/**
|
|
851
|
+
* Override where() to pass through model context
|
|
852
|
+
*/
|
|
853
|
+
static where(...args) {
|
|
854
|
+
const query = this.query();
|
|
855
|
+
return query.where(...args);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Static update method with timestamps
|
|
860
|
+
* Override to add updated_at automatically
|
|
861
|
+
*/
|
|
862
|
+
static async update(id, attributes) {
|
|
863
|
+
// Add updated_at if timestamps are enabled
|
|
864
|
+
if (this.timestamps && this.updatedAt && attributes[this.updatedAt] === undefined) {
|
|
865
|
+
attributes[this.updatedAt] = new Date();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const pk = this.primaryKey || 'id';
|
|
869
|
+
const query = this.query();
|
|
870
|
+
return await query.where(pk, id).update(attributes);
|
|
871
|
+
}
|
|
872
|
+
|
|
838
873
|
/**
|
|
839
874
|
* Query builder with soft deleted records
|
|
840
875
|
*/
|
|
@@ -171,6 +171,27 @@ export class QueryBuilder {
|
|
|
171
171
|
* Update records
|
|
172
172
|
*/
|
|
173
173
|
async update(data) {
|
|
174
|
+
// Add updated_at if model class has timestamps enabled
|
|
175
|
+
if (this.query._modelClass) {
|
|
176
|
+
const modelClass = this.query._modelClass;
|
|
177
|
+
if (modelClass.timestamps && modelClass.updatedAt && data[modelClass.updatedAt] === undefined) {
|
|
178
|
+
data[modelClass.updatedAt] = new Date();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return await this.query.update(data);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Update records with timestamps (for use with Model)
|
|
187
|
+
* @param {Object} data - Data to update
|
|
188
|
+
* @param {boolean} withTimestamps - Whether to add updated_at timestamp
|
|
189
|
+
* @returns {Promise}
|
|
190
|
+
*/
|
|
191
|
+
async updateWithTimestamps(data, withTimestamps = true) {
|
|
192
|
+
if (withTimestamps) {
|
|
193
|
+
data.updated_at = new Date();
|
|
194
|
+
}
|
|
174
195
|
return await this.query.update(data);
|
|
175
196
|
}
|
|
176
197
|
|
|
@@ -5,6 +5,7 @@ import { LogServiceProvider } from '../Services/Log/LogServiceProvider.js';
|
|
|
5
5
|
import { HashServiceProvider } from './Providers/HashServiceProvider.js';
|
|
6
6
|
import { ValidationServiceProvider } from './Providers/ValidationServiceProvider.js';
|
|
7
7
|
import { EncryptionServiceProvider } from './Providers/EncryptionServiceProvider.js';
|
|
8
|
+
import { StorageServiceProvider } from '../Services/Storage/StorageServiceProvider.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* BaseApp - Base class for application-level apps (Express apps)
|
|
@@ -38,6 +39,7 @@ export class BaseApp extends Application {
|
|
|
38
39
|
this.register(HashServiceProvider);
|
|
39
40
|
this.register(ValidationServiceProvider);
|
|
40
41
|
this.register(EncryptionServiceProvider);
|
|
42
|
+
this.register(StorageServiceProvider);
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -14,6 +14,7 @@ export class S3StorageProvider extends BaseStorageProvider {
|
|
|
14
14
|
this.key = config.key;
|
|
15
15
|
this.secret = config.secret;
|
|
16
16
|
this.baseUrl = config.url;
|
|
17
|
+
this.forcePathStyle = config.forcePathStyle || false; // For MinIO compatibility
|
|
17
18
|
this.s3Client = null;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -30,6 +31,7 @@ export class S3StorageProvider extends BaseStorageProvider {
|
|
|
30
31
|
this.s3Client = new S3Client({
|
|
31
32
|
region: this.region,
|
|
32
33
|
endpoint: this.endpoint,
|
|
34
|
+
forcePathStyle: this.forcePathStyle, // Required for MinIO
|
|
33
35
|
credentials: {
|
|
34
36
|
accessKeyId: this.key,
|
|
35
37
|
secretAccessKey: this.secret,
|
|
@@ -132,6 +134,25 @@ export class S3StorageProvider extends BaseStorageProvider {
|
|
|
132
134
|
return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${path}`;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Get presigned URL for temporary access
|
|
139
|
+
* @param {string} path - File path
|
|
140
|
+
* @param {number} expiresIn - Expiration time in seconds (default: 7 days)
|
|
141
|
+
* @returns {Promise<string>} Presigned URL
|
|
142
|
+
*/
|
|
143
|
+
async temporaryUrl(path, expiresIn = 604800) {
|
|
144
|
+
const client = await this.getClient();
|
|
145
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
146
|
+
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
|
147
|
+
|
|
148
|
+
const command = new GetObjectCommand({
|
|
149
|
+
Bucket: this.bucket,
|
|
150
|
+
Key: path,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return await getSignedUrl(client, command, { expiresIn });
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
/**
|
|
136
157
|
* Get file size
|
|
137
158
|
*/
|
|
@@ -26,10 +26,6 @@ export class StorageServiceProvider extends ServiceProvider {
|
|
|
26
26
|
*/
|
|
27
27
|
async boot() {
|
|
28
28
|
// Storage service is ready to use
|
|
29
|
-
if (this.config('filesystems.default')) {
|
|
30
|
-
const storage = this.make('storage');
|
|
31
|
-
console.log(`[StorageServiceProvider] Storage service initialized with driver: ${this.config('filesystems.default')}`);
|
|
32
|
-
}
|
|
33
29
|
}
|
|
34
30
|
}
|
|
35
31
|
|
|
@@ -219,12 +219,13 @@ export class FileValidator {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
/**
|
|
222
|
-
* Validation Error
|
|
222
|
+
* File Validation Error
|
|
223
|
+
* Renamed to avoid conflict with framework's ValidationError
|
|
223
224
|
*/
|
|
224
|
-
export class
|
|
225
|
+
export class FileValidationError extends Error {
|
|
225
226
|
constructor(message, errors = []) {
|
|
226
227
|
super(message);
|
|
227
|
-
this.name = '
|
|
228
|
+
this.name = 'FileValidationError';
|
|
228
229
|
this.errors = errors;
|
|
229
230
|
}
|
|
230
231
|
}
|
|
@@ -2,7 +2,7 @@ export { UploadManager } from './UploadManager.js';
|
|
|
2
2
|
export { LocalDriver } from './Drivers/LocalDriver.js';
|
|
3
3
|
export { S3Driver } from './Drivers/S3Driver.js';
|
|
4
4
|
export { DigitalOceanSpacesDriver } from './Drivers/DigitalOceanSpacesDriver.js';
|
|
5
|
-
export { FileValidator,
|
|
5
|
+
export { FileValidator, FileValidationError } from './FileValidator.js';
|
|
6
6
|
export { ImageProcessor } from './ImageProcessor.js';
|
|
7
7
|
export { SecurityScanner, SecurityError } from './SecurityScanner.js';
|
|
8
8
|
export { UploadServiceProvider } from './UploadServiceProvider.js';
|
|
@@ -26,6 +26,43 @@ export class Validator {
|
|
|
26
26
|
return { error: null, value: result.value };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Get nested value from object using dot notation
|
|
31
|
+
* @param {Object} obj - Object to search
|
|
32
|
+
* @param {String} path - Dot notation path (e.g., 'user.profile.name')
|
|
33
|
+
* @returns {*} Value at path or undefined
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const data = { user: { profile: { name: 'John' } } };
|
|
37
|
+
* Validator.getNestedValue(data, 'user.profile.name'); // 'John'
|
|
38
|
+
*/
|
|
39
|
+
static getNestedValue(obj, path) {
|
|
40
|
+
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate required fields in data object
|
|
45
|
+
* @param {Object} data - Data object to validate
|
|
46
|
+
* @param {Array<String>} fields - Required field names (supports dot notation)
|
|
47
|
+
* @returns {Object} Object with errors (empty if valid)
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const errors = Validator.validateRequiredFields(data, ['name', 'user.email']);
|
|
51
|
+
* if (Object.keys(errors).length > 0) {
|
|
52
|
+
* throw new ValidationError(errors);
|
|
53
|
+
* }
|
|
54
|
+
*/
|
|
55
|
+
static validateRequiredFields(data, fields) {
|
|
56
|
+
const errors = {};
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
const value = this.getNestedValue(data, field);
|
|
59
|
+
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
|
|
60
|
+
errors[field] = `${field} is required`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
/**
|
|
30
67
|
* Validate middleware
|
|
31
68
|
*/
|
|
@@ -2,66 +2,131 @@
|
|
|
2
2
|
* Form Error Handling Utilities
|
|
3
3
|
*
|
|
4
4
|
* Framework-agnostic error handling utilities for forms
|
|
5
|
-
* Handles backend validation errors and maps them to form fields
|
|
5
|
+
* Handles backend validation errors (Joi) and maps them to form fields
|
|
6
|
+
* Compatible with both Yup (Formik) and Joi (backend) error formats
|
|
6
7
|
* Similar to Laravel's error bag functionality
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Handle API errors in forms with Formik
|
|
11
|
-
* Automatically maps backend validation errors to
|
|
12
|
+
* Automatically maps backend validation errors (Joi) to Formik field errors
|
|
13
|
+
* Works seamlessly alongside Yup validation
|
|
12
14
|
* Shows appropriate toast notifications
|
|
13
15
|
*
|
|
14
16
|
* @param {Object} error - Error object from API call
|
|
15
|
-
* @param {Function}
|
|
17
|
+
* @param {Function} setStatus - Formik's setStatus function to store backend errors separately
|
|
16
18
|
* @param {Object} options - Configuration options
|
|
17
19
|
* @param {string} options.fallbackMessage - Message to show if no specific error message
|
|
18
20
|
* @param {Function} options.onValidationError - Callback for validation errors
|
|
19
21
|
* @param {Function} options.onError - Callback for other errors
|
|
20
22
|
* @param {boolean} options.showToast - Whether to show toast notification (default: true)
|
|
21
23
|
* @param {boolean} options.logError - Whether to log error to console (default: true)
|
|
24
|
+
* @param {Object} options.toast - Toast library instance (react-toastify, sonner, etc.)
|
|
22
25
|
*
|
|
23
26
|
* @example
|
|
24
27
|
* ```javascript
|
|
25
|
-
* import { handleFormError } from '
|
|
28
|
+
* import { handleFormError } from 'vasuzex/react';
|
|
29
|
+
* import { toast } from 'react-toastify';
|
|
26
30
|
*
|
|
27
|
-
* const handleSubmit = async (values, {
|
|
31
|
+
* const handleSubmit = async (values, { setStatus }) => {
|
|
28
32
|
* try {
|
|
29
33
|
* await brandService.create(values);
|
|
30
34
|
* toast.success("Brand created successfully");
|
|
35
|
+
* setStatus({ backendErrors: null }); // Clear backend errors on success
|
|
31
36
|
* } catch (error) {
|
|
32
|
-
* handleFormError(error,
|
|
33
|
-
* fallbackMessage: "Failed to save brand"
|
|
37
|
+
* handleFormError(error, setStatus, {
|
|
38
|
+
* fallbackMessage: "Failed to save brand",
|
|
39
|
+
* toast
|
|
34
40
|
* });
|
|
35
41
|
* }
|
|
36
42
|
* };
|
|
37
43
|
* ```
|
|
44
|
+
*
|
|
45
|
+
* @example With nested fields (Joi backend validation)
|
|
46
|
+
* ```javascript
|
|
47
|
+
* // Backend returns:
|
|
48
|
+
* {
|
|
49
|
+
* "success": false,
|
|
50
|
+
* "message": "Validation failed",
|
|
51
|
+
* "errors": {
|
|
52
|
+
* "phone": "Phone must be exactly 10 digits",
|
|
53
|
+
* "bankdetails.ifscCode": "Invalid IFSC code format",
|
|
54
|
+
* "owner.email": "Owner email must be a valid email address"
|
|
55
|
+
* }
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* // Stored in Formik status (doesn't conflict with Yup validation):
|
|
59
|
+
* // setStatus({
|
|
60
|
+
* // backendErrors: {
|
|
61
|
+
* // 'phone': 'Phone must be exactly 10 digits',
|
|
62
|
+
* // 'bankdetails': { 'ifscCode': 'Invalid IFSC code format' },
|
|
63
|
+
* // 'owner': { 'email': 'Owner email must be a valid email address' }
|
|
64
|
+
* // }
|
|
65
|
+
* // })
|
|
66
|
+
* ```
|
|
38
67
|
*/
|
|
39
|
-
export const handleFormError = (error,
|
|
68
|
+
export const handleFormError = (error, setStatus, options = {}) => {
|
|
40
69
|
const {
|
|
41
70
|
fallbackMessage = 'An error occurred',
|
|
42
71
|
onValidationError,
|
|
43
72
|
onError,
|
|
44
73
|
showToast = true,
|
|
45
74
|
logError = true,
|
|
75
|
+
toast: toastLib,
|
|
46
76
|
} = options;
|
|
47
77
|
|
|
48
78
|
// Log error for debugging
|
|
49
79
|
if (logError) {
|
|
50
80
|
console.error('Form error:', error);
|
|
81
|
+
console.log('[handleFormError] error.isValidationError:', error.isValidationError);
|
|
82
|
+
console.log('[handleFormError] error.errors:', error.errors);
|
|
83
|
+
console.log('[handleFormError] typeof error.errors:', typeof error.errors);
|
|
51
84
|
}
|
|
52
85
|
|
|
53
86
|
// Handle validation errors (422) - attach to form fields
|
|
87
|
+
// Works with both Joi (backend) and Yup (frontend) error formats
|
|
54
88
|
if (error.isValidationError && error.errors && typeof error.errors === 'object') {
|
|
55
|
-
|
|
89
|
+
console.log('[handleFormError] Processing validation errors...');
|
|
90
|
+
|
|
91
|
+
// Convert flat dot-notation errors to nested structure
|
|
92
|
+
// Backend: { "bankdetails.ifscCode": "error" }
|
|
93
|
+
// Formik needs: { bankdetails: { ifscCode: "error" } }
|
|
94
|
+
const formikErrors = {};
|
|
95
|
+
|
|
56
96
|
Object.keys(error.errors).forEach((field) => {
|
|
57
|
-
|
|
58
|
-
|
|
97
|
+
console.log(`[handleFormError] Processing field "${field}":`, error.errors[field]);
|
|
98
|
+
|
|
99
|
+
if (field.includes('.')) {
|
|
100
|
+
// Nested field: convert "bankdetails.ifscCode" to { bankdetails: { ifscCode: "error" } }
|
|
101
|
+
const parts = field.split('.');
|
|
102
|
+
let current = formikErrors;
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
105
|
+
if (!current[parts[i]]) {
|
|
106
|
+
current[parts[i]] = {};
|
|
107
|
+
}
|
|
108
|
+
current = current[parts[i]];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
current[parts[parts.length - 1]] = error.errors[field];
|
|
112
|
+
} else {
|
|
113
|
+
// Top-level field
|
|
114
|
+
formikErrors[field] = error.errors[field];
|
|
59
115
|
}
|
|
60
116
|
});
|
|
117
|
+
|
|
118
|
+
console.log('[handleFormError] Storing backend errors in status:', formikErrors);
|
|
119
|
+
|
|
120
|
+
// Store backend validation errors in Formik status
|
|
121
|
+
// This prevents conflict with Yup validation
|
|
122
|
+
// Components should check status.backendErrors first, then fall back to Formik errors
|
|
123
|
+
if (setStatus) {
|
|
124
|
+
setStatus({ backendErrors: formikErrors });
|
|
125
|
+
}
|
|
61
126
|
|
|
62
|
-
// Show error message
|
|
63
|
-
if (showToast) {
|
|
64
|
-
|
|
127
|
+
// Show error toast with validation message
|
|
128
|
+
if (showToast && toastLib) {
|
|
129
|
+
toastLib.error(error.message || 'Please fix the validation errors');
|
|
65
130
|
}
|
|
66
131
|
|
|
67
132
|
// Call custom validation error handler if provided
|
|
@@ -94,8 +159,8 @@ export const handleFormError = (error, setFieldError, options = {}) => {
|
|
|
94
159
|
// Backend MUST send error.message (standardized)
|
|
95
160
|
const errorMessage = error.message || fallbackMessage;
|
|
96
161
|
|
|
97
|
-
if (showToast) {
|
|
98
|
-
|
|
162
|
+
if (showToast && toastLib) {
|
|
163
|
+
toastLib.error(errorMessage);
|
|
99
164
|
}
|
|
100
165
|
|
|
101
166
|
// Call custom error handler if provided
|
|
@@ -112,6 +177,7 @@ export const handleFormError = (error, setFieldError, options = {}) => {
|
|
|
112
177
|
/**
|
|
113
178
|
* Handle API errors without Formik
|
|
114
179
|
* For simple forms or non-Formik scenarios
|
|
180
|
+
* Returns errors object for manual handling
|
|
115
181
|
*
|
|
116
182
|
* @param {Object} error - Error object from API call
|
|
117
183
|
* @param {Object} options - Configuration options
|
|
@@ -119,16 +185,26 @@ export const handleFormError = (error, setFieldError, options = {}) => {
|
|
|
119
185
|
* @param {Function} options.onError - Callback for errors
|
|
120
186
|
* @param {boolean} options.showToast - Whether to show toast notification (default: true)
|
|
121
187
|
* @param {boolean} options.logError - Whether to log error to console (default: true)
|
|
188
|
+
* @param {Object} options.toast - Toast library instance (react-toastify, sonner, etc.)
|
|
122
189
|
*
|
|
123
190
|
* @example
|
|
124
191
|
* ```javascript
|
|
125
|
-
* import { handleApiError } from '
|
|
192
|
+
* import { handleApiError } from 'vasuzex/react';
|
|
193
|
+
* import { toast } from 'react-toastify';
|
|
126
194
|
*
|
|
127
195
|
* try {
|
|
128
196
|
* await api.delete('/brands/123');
|
|
129
197
|
* toast.success("Brand deleted");
|
|
130
198
|
* } catch (error) {
|
|
131
|
-
* handleApiError(error, {
|
|
199
|
+
* const result = handleApiError(error, {
|
|
200
|
+
* fallbackMessage: "Failed to delete brand",
|
|
201
|
+
* toast
|
|
202
|
+
* });
|
|
203
|
+
*
|
|
204
|
+
* // Access validation errors if needed
|
|
205
|
+
* if (result.type === 'validation') {
|
|
206
|
+
* console.log(result.errors); // { field: "error message" }
|
|
207
|
+
* }
|
|
132
208
|
* }
|
|
133
209
|
* ```
|
|
134
210
|
*/
|
|
@@ -138,6 +214,7 @@ export const handleApiError = (error, options = {}) => {
|
|
|
138
214
|
onError,
|
|
139
215
|
showToast = true,
|
|
140
216
|
logError = true,
|
|
217
|
+
toast: toastLib,
|
|
141
218
|
} = options;
|
|
142
219
|
|
|
143
220
|
// Log error for debugging
|
|
@@ -150,9 +227,9 @@ export const handleApiError = (error, options = {}) => {
|
|
|
150
227
|
const errorMessage = error.message || fallbackMessage;
|
|
151
228
|
|
|
152
229
|
// Show toast notification
|
|
153
|
-
if (showToast && !error.isPermissionError) {
|
|
230
|
+
if (showToast && toastLib && !error.isPermissionError) {
|
|
154
231
|
// Don't show toast for permission errors as api-client already shows SweetAlert
|
|
155
|
-
|
|
232
|
+
toastLib.error(errorMessage);
|
|
156
233
|
}
|
|
157
234
|
|
|
158
235
|
// Call custom error handler if provided
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Photo Manager with DnD, Bulk Operations, and Custom Components
|
|
3
|
+
*
|
|
4
|
+
* Production-ready photo management component with:
|
|
5
|
+
* - Drag & drop upload
|
|
6
|
+
* - Sortable photos (drag to reorder)
|
|
7
|
+
* - Bulk selection and delete
|
|
8
|
+
* - Custom component support
|
|
9
|
+
* - Upload progress tracking
|
|
10
|
+
* - Responsive grid layout
|
|
11
|
+
*
|
|
12
|
+
* @module components/PhotoManager/EnhancedPhotoManager
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, useEffect } from "react";
|
|
16
|
+
import PropTypes from "prop-types";
|
|
17
|
+
import {
|
|
18
|
+
DndContext,
|
|
19
|
+
closestCenter,
|
|
20
|
+
KeyboardSensor,
|
|
21
|
+
PointerSensor,
|
|
22
|
+
useSensor,
|
|
23
|
+
useSensors,
|
|
24
|
+
} from "@dnd-kit/core";
|
|
25
|
+
import {
|
|
26
|
+
arrayMove,
|
|
27
|
+
SortableContext,
|
|
28
|
+
sortableKeyboardCoordinates,
|
|
29
|
+
rectSortingStrategy,
|
|
30
|
+
} from "@dnd-kit/sortable";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Enhanced Photo Manager Component
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} props
|
|
36
|
+
* @param {string} props.entityId - Entity ID (store, product, etc.)
|
|
37
|
+
* @param {Object} props.photosHook - Hook with photos management functions
|
|
38
|
+
* @param {string} [props.title="Photos"] - Section title
|
|
39
|
+
* @param {string} [props.description] - Section description
|
|
40
|
+
* @param {string} [props.emptyStateText="No photos uploaded yet"] - Empty state message
|
|
41
|
+
* @param {number} [props.maxFiles=20] - Maximum number of photos allowed
|
|
42
|
+
* @param {string} [props.gridCols="grid-cols-2 sm:grid-cols-3 md:grid-cols-4"] - Grid columns CSS
|
|
43
|
+
* @param {Object} props.components - Custom components (UploadDropZone, PhotoCard, etc.)
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* <EnhancedPhotoManager
|
|
47
|
+
* entityId={storeId}
|
|
48
|
+
* photosHook={useStorePhotos(storeId)}
|
|
49
|
+
* title="Store Photos"
|
|
50
|
+
* maxFiles={20}
|
|
51
|
+
* components={{
|
|
52
|
+
* UploadDropZone,
|
|
53
|
+
* PhotoCard,
|
|
54
|
+
* UploadProgressCard,
|
|
55
|
+
* TrashIcon,
|
|
56
|
+
* }}
|
|
57
|
+
* />
|
|
58
|
+
*/
|
|
59
|
+
export function EnhancedPhotoManager({
|
|
60
|
+
entityId,
|
|
61
|
+
photosHook,
|
|
62
|
+
title = "Photos",
|
|
63
|
+
description,
|
|
64
|
+
emptyStateText = "No photos uploaded yet",
|
|
65
|
+
maxFiles = 20,
|
|
66
|
+
gridCols = "grid-cols-2 sm:grid-cols-3 md:grid-cols-4",
|
|
67
|
+
components = {},
|
|
68
|
+
}) {
|
|
69
|
+
const {
|
|
70
|
+
photos = [],
|
|
71
|
+
uploadingFiles = [],
|
|
72
|
+
loading = false,
|
|
73
|
+
fetchPhotos,
|
|
74
|
+
uploadPhotos,
|
|
75
|
+
deletePhoto,
|
|
76
|
+
deleteMultiplePhotos,
|
|
77
|
+
reorderPhotos,
|
|
78
|
+
} = photosHook;
|
|
79
|
+
|
|
80
|
+
const [selectedPhotos, setSelectedPhotos] = useState([]);
|
|
81
|
+
const [sortedPhotos, setSortedPhotos] = useState([]);
|
|
82
|
+
|
|
83
|
+
// Extract custom components
|
|
84
|
+
const UploadDropZone = components.UploadDropZone;
|
|
85
|
+
const PhotoCard = components.PhotoCard;
|
|
86
|
+
const UploadProgressCard = components.UploadProgressCard;
|
|
87
|
+
const TrashIcon = components.TrashIcon;
|
|
88
|
+
|
|
89
|
+
// Sensors for drag & drop
|
|
90
|
+
const sensors = useSensors(
|
|
91
|
+
useSensor(PointerSensor, {
|
|
92
|
+
activationConstraint: {
|
|
93
|
+
distance: 8,
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
useSensor(KeyboardSensor, {
|
|
97
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Fetch photos on mount
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (entityId && fetchPhotos) {
|
|
104
|
+
fetchPhotos();
|
|
105
|
+
}
|
|
106
|
+
}, [entityId, fetchPhotos]);
|
|
107
|
+
|
|
108
|
+
// Update sorted photos when photos change
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
setSortedPhotos(photos);
|
|
111
|
+
}, [photos]);
|
|
112
|
+
|
|
113
|
+
// Handle file selection
|
|
114
|
+
const handleFilesSelected = async (files) => {
|
|
115
|
+
if (uploadPhotos) {
|
|
116
|
+
await uploadPhotos(files);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Handle photo selection toggle
|
|
121
|
+
const handlePhotoSelect = (photoId) => {
|
|
122
|
+
setSelectedPhotos((prev) =>
|
|
123
|
+
prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId],
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Handle single photo delete
|
|
128
|
+
const handlePhotoDelete = async (photoId) => {
|
|
129
|
+
if (deletePhoto && confirm("Delete this photo?")) {
|
|
130
|
+
await deletePhoto(photoId);
|
|
131
|
+
setSelectedPhotos((prev) => prev.filter((id) => id !== photoId));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Handle bulk delete
|
|
136
|
+
const handleBulkDelete = async () => {
|
|
137
|
+
if (
|
|
138
|
+
selectedPhotos.length > 0 &&
|
|
139
|
+
deleteMultiplePhotos &&
|
|
140
|
+
confirm(`Delete ${selectedPhotos.length} selected photo(s)?`)
|
|
141
|
+
) {
|
|
142
|
+
await deleteMultiplePhotos(selectedPhotos);
|
|
143
|
+
setSelectedPhotos([]);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Handle drag end - reorder photos
|
|
148
|
+
const handleDragEnd = async (event) => {
|
|
149
|
+
const { active, over } = event;
|
|
150
|
+
|
|
151
|
+
if (active.id !== over.id) {
|
|
152
|
+
const oldIndex = sortedPhotos.findIndex((p) => p.id === active.id);
|
|
153
|
+
const newIndex = sortedPhotos.findIndex((p) => p.id === over.id);
|
|
154
|
+
|
|
155
|
+
const newOrder = arrayMove(sortedPhotos, oldIndex, newIndex);
|
|
156
|
+
setSortedPhotos(newOrder);
|
|
157
|
+
|
|
158
|
+
// Update backend
|
|
159
|
+
if (reorderPhotos) {
|
|
160
|
+
await reorderPhotos(newOrder);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Select all photos
|
|
166
|
+
const handleSelectAll = () => {
|
|
167
|
+
if (selectedPhotos.length === sortedPhotos.length) {
|
|
168
|
+
setSelectedPhotos([]);
|
|
169
|
+
} else {
|
|
170
|
+
setSelectedPhotos(sortedPhotos.map((p) => p.id));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const canUpload = sortedPhotos.length < maxFiles;
|
|
175
|
+
const hasSelection = selectedPhotos.length > 0;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="vasuzex-enhanced-photo-manager space-y-6">
|
|
179
|
+
{/* Header */}
|
|
180
|
+
<div className="flex items-start justify-between">
|
|
181
|
+
<div>
|
|
182
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
|
|
183
|
+
{description && <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{description}</p>}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Bulk Actions */}
|
|
187
|
+
{hasSelection && (
|
|
188
|
+
<div className="flex items-center gap-2">
|
|
189
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
190
|
+
{selectedPhotos.length} selected
|
|
191
|
+
</span>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={handleBulkDelete}
|
|
195
|
+
className="px-3 py-1.5 text-sm font-medium rounded-lg
|
|
196
|
+
bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400
|
|
197
|
+
hover:bg-red-100 dark:hover:bg-red-900/30
|
|
198
|
+
transition-colors duration-200"
|
|
199
|
+
>
|
|
200
|
+
{TrashIcon && <TrashIcon className="w-4 h-4 inline mr-1" />}
|
|
201
|
+
Delete
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Upload Drop Zone */}
|
|
208
|
+
{canUpload && UploadDropZone && (
|
|
209
|
+
<UploadDropZone
|
|
210
|
+
onFilesSelected={handleFilesSelected}
|
|
211
|
+
disabled={loading}
|
|
212
|
+
maxFiles={maxFiles - sortedPhotos.length}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Info Bar */}
|
|
217
|
+
<div className="flex items-center justify-between text-sm">
|
|
218
|
+
<div className="text-gray-600 dark:text-gray-400">
|
|
219
|
+
{sortedPhotos.length} / {maxFiles} photos
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{sortedPhotos.length > 0 && (
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={handleSelectAll}
|
|
226
|
+
className="text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 font-medium"
|
|
227
|
+
>
|
|
228
|
+
{selectedPhotos.length === sortedPhotos.length ? "Deselect All" : "Select All"}
|
|
229
|
+
</button>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Uploading Files Progress */}
|
|
234
|
+
{uploadingFiles.length > 0 && UploadProgressCard && (
|
|
235
|
+
<div className={`grid ${gridCols} gap-4`}>
|
|
236
|
+
{uploadingFiles.map((file) => (
|
|
237
|
+
<UploadProgressCard key={file.id} file={file} />
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* Photos Grid with DnD */}
|
|
243
|
+
{sortedPhotos.length > 0 && PhotoCard && (
|
|
244
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
245
|
+
<SortableContext items={sortedPhotos.map((p) => p.id)} strategy={rectSortingStrategy}>
|
|
246
|
+
<div className={`grid ${gridCols} gap-4`}>
|
|
247
|
+
{sortedPhotos.map((photo, index) => (
|
|
248
|
+
<PhotoCard
|
|
249
|
+
key={photo.id}
|
|
250
|
+
photo={{ ...photo, order: index }}
|
|
251
|
+
isSelected={selectedPhotos.includes(photo.id)}
|
|
252
|
+
onSelect={handlePhotoSelect}
|
|
253
|
+
onDelete={handlePhotoDelete}
|
|
254
|
+
disabled={loading}
|
|
255
|
+
/>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
</SortableContext>
|
|
259
|
+
</DndContext>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{/* Empty State */}
|
|
263
|
+
{sortedPhotos.length === 0 && uploadingFiles.length === 0 && !loading && (
|
|
264
|
+
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
265
|
+
<p>{emptyStateText}</p>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{/* Loading State */}
|
|
270
|
+
{loading && sortedPhotos.length === 0 && (
|
|
271
|
+
<div className="text-center py-12">
|
|
272
|
+
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
|
273
|
+
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading photos...</p>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
EnhancedPhotoManager.propTypes = {
|
|
281
|
+
/** Entity ID (store, product, etc.) */
|
|
282
|
+
entityId: PropTypes.string.isRequired,
|
|
283
|
+
/** Photos hook with management functions */
|
|
284
|
+
photosHook: PropTypes.shape({
|
|
285
|
+
photos: PropTypes.array,
|
|
286
|
+
uploadingFiles: PropTypes.array,
|
|
287
|
+
loading: PropTypes.bool,
|
|
288
|
+
fetchPhotos: PropTypes.func,
|
|
289
|
+
uploadPhotos: PropTypes.func,
|
|
290
|
+
deletePhoto: PropTypes.func,
|
|
291
|
+
deleteMultiplePhotos: PropTypes.func,
|
|
292
|
+
reorderPhotos: PropTypes.func,
|
|
293
|
+
}).isRequired,
|
|
294
|
+
/** Section title */
|
|
295
|
+
title: PropTypes.string,
|
|
296
|
+
/** Section description */
|
|
297
|
+
description: PropTypes.string,
|
|
298
|
+
/** Empty state message */
|
|
299
|
+
emptyStateText: PropTypes.string,
|
|
300
|
+
/** Maximum number of photos */
|
|
301
|
+
maxFiles: PropTypes.number,
|
|
302
|
+
/** Grid columns CSS classes */
|
|
303
|
+
gridCols: PropTypes.string,
|
|
304
|
+
/** Custom components */
|
|
305
|
+
components: PropTypes.shape({
|
|
306
|
+
UploadDropZone: PropTypes.elementType,
|
|
307
|
+
PhotoCard: PropTypes.elementType,
|
|
308
|
+
UploadProgressCard: PropTypes.elementType,
|
|
309
|
+
TrashIcon: PropTypes.elementType,
|
|
310
|
+
}),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
export default EnhancedPhotoManager;
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import { useState, useRef, useCallback } from 'react';
|
|
10
10
|
import PropTypes from 'prop-types';
|
|
11
|
+
import { EnhancedPhotoManager } from './EnhancedPhotoManager.jsx';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Photo upload and management component
|
|
14
15
|
*
|
|
15
16
|
* @param {Object} props
|
|
17
|
+
* @param {string} [props.variant='basic'] - 'basic' or 'enhanced' for advanced features
|
|
16
18
|
* @param {Array<string|Object>} [props.photos=[]] - Initial photos (URLs or objects)
|
|
17
19
|
* @param {Function} props.onPhotosChange - Callback when photos change
|
|
18
20
|
* @param {Function} [props.onUpload] - Custom upload handler
|
|
@@ -24,6 +26,13 @@ import PropTypes from 'prop-types';
|
|
|
24
26
|
* @param {boolean} [props.disabled] - Disable uploads
|
|
25
27
|
* @param {Function} [props.getPhotoUrl] - Get URL from photo object
|
|
26
28
|
*
|
|
29
|
+
* Enhanced variant props:
|
|
30
|
+
* @param {string|number} props.entityId - Entity ID (for enhanced variant)
|
|
31
|
+
* @param {Object} props.photosHook - Custom photos hook (for enhanced variant)
|
|
32
|
+
* @param {string} [props.title] - Title for enhanced variant
|
|
33
|
+
* @param {string} [props.description] - Description for enhanced variant
|
|
34
|
+
* @param {Object} [props.components] - Custom components for enhanced variant
|
|
35
|
+
*
|
|
27
36
|
* @example
|
|
28
37
|
* <PhotoManager
|
|
29
38
|
* photos={photos}
|
|
@@ -39,6 +48,7 @@ import PropTypes from 'prop-types';
|
|
|
39
48
|
* />
|
|
40
49
|
*/
|
|
41
50
|
export function PhotoManager({
|
|
51
|
+
variant = 'basic',
|
|
42
52
|
photos = [],
|
|
43
53
|
onPhotosChange,
|
|
44
54
|
onUpload,
|
|
@@ -49,7 +59,12 @@ export function PhotoManager({
|
|
|
49
59
|
className = '',
|
|
50
60
|
disabled = false,
|
|
51
61
|
getPhotoUrl = (photo) => typeof photo === 'string' ? photo : photo.url,
|
|
62
|
+
...enhancedProps
|
|
52
63
|
}) {
|
|
64
|
+
// If enhanced variant requested, use EnhancedPhotoManager
|
|
65
|
+
if (variant === 'enhanced') {
|
|
66
|
+
return <EnhancedPhotoManager maxFiles={maxPhotos} {...enhancedProps} />;
|
|
67
|
+
}
|
|
53
68
|
const [uploading, setUploading] = useState(false);
|
|
54
69
|
const [dragActive, setDragActive] = useState(false);
|
|
55
70
|
const [error, setError] = useState(null);
|
|
@@ -292,6 +307,8 @@ PhotoManager.propTypes = {
|
|
|
292
307
|
}),
|
|
293
308
|
])
|
|
294
309
|
),
|
|
310
|
+
/** Component variant - 'basic' for simple manager, 'enhanced' for advanced features */
|
|
311
|
+
variant: PropTypes.oneOf(['basic', 'enhanced']),
|
|
295
312
|
/** Callback when photos change */
|
|
296
313
|
onPhotosChange: PropTypes.func,
|
|
297
314
|
/** Alternative prop name for onChange */
|
|
@@ -318,6 +335,23 @@ PhotoManager.propTypes = {
|
|
|
318
335
|
getPhotoUrl: PropTypes.func,
|
|
319
336
|
/** Helper text displayed below upload area */
|
|
320
337
|
helperText: PropTypes.string,
|
|
338
|
+
// Enhanced variant props
|
|
339
|
+
/** Entity ID (required for enhanced variant) */
|
|
340
|
+
entityId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
341
|
+
/** Photos hook with photos, loading, error, upload, etc. (required for enhanced variant) */
|
|
342
|
+
photosHook: PropTypes.object,
|
|
343
|
+
/** Title for enhanced variant */
|
|
344
|
+
title: PropTypes.string,
|
|
345
|
+
/** Description for enhanced variant */
|
|
346
|
+
description: PropTypes.string,
|
|
347
|
+
/** Custom components for enhanced variant */
|
|
348
|
+
components: PropTypes.object,
|
|
349
|
+
/** Grid columns class for enhanced variant */
|
|
350
|
+
gridCols: PropTypes.string,
|
|
351
|
+
/** Empty state text for enhanced variant */
|
|
352
|
+
emptyStateText: PropTypes.string,
|
|
353
|
+
/** Max files for enhanced variant */
|
|
354
|
+
maxFiles: PropTypes.number,
|
|
321
355
|
};
|
|
322
356
|
|
|
323
357
|
PhotoManager.defaultProps = {
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# PhotoManager Component
|
|
2
|
+
|
|
3
|
+
Reusable photo management component for all vasuzex-based applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Two Variants**: Basic and Enhanced
|
|
8
|
+
- **Drag & Drop**: Reorder photos by dragging
|
|
9
|
+
- **Bulk Operations**: Select multiple photos and delete them together
|
|
10
|
+
- **Upload Progress**: Real-time upload tracking
|
|
11
|
+
- **Custom Components**: Override default UI components
|
|
12
|
+
- **Responsive Grid**: Configurable grid layouts
|
|
13
|
+
- **Type Safety**: PropTypes validation
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @dnd-kit/core @dnd-kit/sortable react-dropzone
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Basic Usage
|
|
22
|
+
|
|
23
|
+
Simple photo manager with upload and delete:
|
|
24
|
+
|
|
25
|
+
```jsx
|
|
26
|
+
import { PhotoManager } from 'vasuzex/react';
|
|
27
|
+
|
|
28
|
+
function MyComponent() {
|
|
29
|
+
const [photos, setPhotos] = useState([]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<PhotoManager
|
|
33
|
+
photos={photos}
|
|
34
|
+
onPhotosChange={setPhotos}
|
|
35
|
+
onUpload={async (file) => {
|
|
36
|
+
// Upload file and return URL
|
|
37
|
+
const formData = new FormData();
|
|
38
|
+
formData.append('photo', file);
|
|
39
|
+
const res = await fetch('/api/upload', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: formData
|
|
42
|
+
});
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
return data.url;
|
|
45
|
+
}}
|
|
46
|
+
maxPhotos={10}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Enhanced Usage
|
|
53
|
+
|
|
54
|
+
Advanced photo manager with DnD, bulk operations, and custom components:
|
|
55
|
+
|
|
56
|
+
```jsx
|
|
57
|
+
import { PhotoManager } from 'vasuzex/react';
|
|
58
|
+
import { useStorePhotos } from './hooks/useStorePhotos';
|
|
59
|
+
import { PhotoCard, UploadDropZone, UploadProgressCard } from './components';
|
|
60
|
+
import { TrashBinIcon } from './icons';
|
|
61
|
+
|
|
62
|
+
function StorePhotosTab({ storeId }) {
|
|
63
|
+
const photosHook = useStorePhotos(storeId);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<PhotoManager
|
|
67
|
+
variant="enhanced"
|
|
68
|
+
entityId={storeId}
|
|
69
|
+
photosHook={photosHook}
|
|
70
|
+
title="Store Photos"
|
|
71
|
+
description="Upload and manage store photos. Drag to reorder display priority."
|
|
72
|
+
emptyStateText="Upload your first store photo"
|
|
73
|
+
maxFiles={20}
|
|
74
|
+
gridCols="grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
|
75
|
+
components={{
|
|
76
|
+
UploadDropZone,
|
|
77
|
+
PhotoCard,
|
|
78
|
+
UploadProgressCard,
|
|
79
|
+
TrashIcon: TrashBinIcon,
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Custom Photos Hook
|
|
87
|
+
|
|
88
|
+
The enhanced variant requires a custom hook that provides photo management functions:
|
|
89
|
+
|
|
90
|
+
```jsx
|
|
91
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
92
|
+
import apiClient from '../config/api';
|
|
93
|
+
|
|
94
|
+
export function useStorePhotos(storeId) {
|
|
95
|
+
const [photos, setPhotos] = useState([]);
|
|
96
|
+
const [loading, setLoading] = useState(false);
|
|
97
|
+
const [error, setError] = useState(null);
|
|
98
|
+
const [uploadingFiles, setUploadingFiles] = useState([]);
|
|
99
|
+
const [selectedPhotos, setSelectedPhotos] = useState([]);
|
|
100
|
+
|
|
101
|
+
// Fetch photos
|
|
102
|
+
const fetchPhotos = useCallback(async () => {
|
|
103
|
+
if (!storeId) return;
|
|
104
|
+
|
|
105
|
+
setLoading(true);
|
|
106
|
+
setError(null);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const response = await apiClient.get(`/api/stores/${storeId}/photos`);
|
|
110
|
+
setPhotos(response.data.data || []);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
setError(err.message);
|
|
113
|
+
console.error('Failed to fetch photos:', err);
|
|
114
|
+
} finally {
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
}, [storeId]);
|
|
118
|
+
|
|
119
|
+
// Upload photo
|
|
120
|
+
const uploadPhoto = async (file, type = 'gallery') => {
|
|
121
|
+
const uploadId = `${Date.now()}-${file.name}`;
|
|
122
|
+
|
|
123
|
+
setUploadingFiles((prev) => [
|
|
124
|
+
...prev,
|
|
125
|
+
{
|
|
126
|
+
id: uploadId,
|
|
127
|
+
name: file.name,
|
|
128
|
+
progress: 0,
|
|
129
|
+
status: 'uploading',
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const formData = new FormData();
|
|
135
|
+
formData.append('photo', file);
|
|
136
|
+
formData.append('type', type);
|
|
137
|
+
|
|
138
|
+
const response = await apiClient.post(
|
|
139
|
+
`/api/stores/${storeId}/photos`,
|
|
140
|
+
formData,
|
|
141
|
+
{
|
|
142
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
143
|
+
onUploadProgress: (progressEvent) => {
|
|
144
|
+
const percentCompleted = Math.round(
|
|
145
|
+
(progressEvent.loaded * 100) / progressEvent.total
|
|
146
|
+
);
|
|
147
|
+
setUploadingFiles((prev) =>
|
|
148
|
+
prev.map((f) =>
|
|
149
|
+
f.id === uploadId ? { ...f, progress: percentCompleted } : f
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
setUploadingFiles((prev) =>
|
|
157
|
+
prev.map((f) =>
|
|
158
|
+
f.id === uploadId ? { ...f, status: 'completed' } : f
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await fetchPhotos();
|
|
163
|
+
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
setUploadingFiles((prev) => prev.filter((f) => f.id !== uploadId));
|
|
166
|
+
}, 2000);
|
|
167
|
+
|
|
168
|
+
return response.data.data;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
setUploadingFiles((prev) =>
|
|
171
|
+
prev.map((f) =>
|
|
172
|
+
f.id === uploadId ? { ...f, status: 'error' } : f
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Delete photo
|
|
180
|
+
const deletePhoto = async (photoId) => {
|
|
181
|
+
try {
|
|
182
|
+
await apiClient.delete(`/api/stores/${storeId}/photos/${photoId}`);
|
|
183
|
+
await fetchPhotos();
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error('Failed to delete photo:', err);
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Bulk delete
|
|
191
|
+
const bulkDeletePhotos = async (photoIds) => {
|
|
192
|
+
try {
|
|
193
|
+
await apiClient.post(`/api/stores/${storeId}/photos/bulk-delete`, {
|
|
194
|
+
photoIds,
|
|
195
|
+
});
|
|
196
|
+
await fetchPhotos();
|
|
197
|
+
setSelectedPhotos([]);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error('Failed to bulk delete photos:', err);
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Reorder photos
|
|
205
|
+
const reorderPhotos = async (photoIds) => {
|
|
206
|
+
try {
|
|
207
|
+
await apiClient.post(`/api/stores/${storeId}/photos/reorder`, {
|
|
208
|
+
photoIds,
|
|
209
|
+
});
|
|
210
|
+
await fetchPhotos();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error('Failed to reorder photos:', err);
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Set primary photo
|
|
218
|
+
const setPrimaryPhoto = async (photoId) => {
|
|
219
|
+
try {
|
|
220
|
+
await apiClient.post(`/api/stores/${storeId}/photos/${photoId}/set-primary`);
|
|
221
|
+
await fetchPhotos();
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error('Failed to set primary photo:', err);
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Fetch photos on mount
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
fetchPhotos();
|
|
231
|
+
}, [fetchPhotos]);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
photos,
|
|
235
|
+
loading,
|
|
236
|
+
error,
|
|
237
|
+
uploadingFiles,
|
|
238
|
+
selectedPhotos,
|
|
239
|
+
setSelectedPhotos,
|
|
240
|
+
uploadPhoto,
|
|
241
|
+
deletePhoto,
|
|
242
|
+
bulkDeletePhotos,
|
|
243
|
+
reorderPhotos,
|
|
244
|
+
setPrimaryPhoto,
|
|
245
|
+
refetch: fetchPhotos,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Custom Components
|
|
251
|
+
|
|
252
|
+
### PhotoCard
|
|
253
|
+
|
|
254
|
+
```jsx
|
|
255
|
+
import { useSortable } from '@dnd-kit/sortable';
|
|
256
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
257
|
+
|
|
258
|
+
export function PhotoCard({
|
|
259
|
+
photo,
|
|
260
|
+
isSelected,
|
|
261
|
+
onSelect,
|
|
262
|
+
onDelete,
|
|
263
|
+
TrashIcon
|
|
264
|
+
}) {
|
|
265
|
+
const {
|
|
266
|
+
attributes,
|
|
267
|
+
listeners,
|
|
268
|
+
setNodeRef,
|
|
269
|
+
transform,
|
|
270
|
+
transition,
|
|
271
|
+
isDragging,
|
|
272
|
+
} = useSortable({ id: photo.id });
|
|
273
|
+
|
|
274
|
+
const style = {
|
|
275
|
+
transform: CSS.Transform.toString(transform),
|
|
276
|
+
transition,
|
|
277
|
+
opacity: isDragging ? 0.5 : 1,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
282
|
+
{/* Your photo card UI */}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### UploadDropZone
|
|
289
|
+
|
|
290
|
+
```jsx
|
|
291
|
+
import { useDropzone } from 'react-dropzone';
|
|
292
|
+
|
|
293
|
+
export function UploadDropZone({ onDrop, maxFiles, UploadIcon }) {
|
|
294
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
295
|
+
onDrop,
|
|
296
|
+
accept: { 'image/*': [] },
|
|
297
|
+
multiple: true,
|
|
298
|
+
maxFiles,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div {...getRootProps()}>
|
|
303
|
+
<input {...getInputProps()} />
|
|
304
|
+
{/* Your dropzone UI */}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### UploadProgressCard
|
|
311
|
+
|
|
312
|
+
```jsx
|
|
313
|
+
export function UploadProgressCard({ file }) {
|
|
314
|
+
return (
|
|
315
|
+
<div>
|
|
316
|
+
<div>{file.name}</div>
|
|
317
|
+
<div>Progress: {file.progress}%</div>
|
|
318
|
+
<div>Status: {file.status}</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Props
|
|
325
|
+
|
|
326
|
+
### Basic Variant
|
|
327
|
+
|
|
328
|
+
| Prop | Type | Default | Description |
|
|
329
|
+
|------|------|---------|-------------|
|
|
330
|
+
| variant | `'basic' \| 'enhanced'` | `'basic'` | Component variant |
|
|
331
|
+
| photos | `Array` | `[]` | Initial photos (URLs or objects) |
|
|
332
|
+
| onPhotosChange | `Function` | - | Callback when photos change |
|
|
333
|
+
| onUpload | `Function` | - | Custom upload handler |
|
|
334
|
+
| maxPhotos | `Number` | `10` | Maximum number of photos |
|
|
335
|
+
| maxFileSize | `Number` | `5242880` | Max file size (bytes) |
|
|
336
|
+
| acceptedTypes | `Array<string>` | Image types | Accepted MIME types |
|
|
337
|
+
| multiple | `Boolean` | `true` | Allow multiple uploads |
|
|
338
|
+
| className | `String` | `''` | Additional CSS classes |
|
|
339
|
+
| disabled | `Boolean` | `false` | Disable uploads |
|
|
340
|
+
|
|
341
|
+
### Enhanced Variant
|
|
342
|
+
|
|
343
|
+
| Prop | Type | Default | Description |
|
|
344
|
+
|------|------|---------|-------------|
|
|
345
|
+
| variant | `'enhanced'` | - | Must be 'enhanced' |
|
|
346
|
+
| entityId | `String \| Number` | - | Entity ID (required) |
|
|
347
|
+
| photosHook | `Object` | - | Custom photos hook (required) |
|
|
348
|
+
| title | `String` | - | Section title |
|
|
349
|
+
| description | `String` | - | Section description |
|
|
350
|
+
| emptyStateText | `String` | - | Empty state message |
|
|
351
|
+
| maxFiles | `Number` | `10` | Maximum files allowed |
|
|
352
|
+
| gridCols | `String` | - | Tailwind grid columns class |
|
|
353
|
+
| components | `Object` | - | Custom components |
|
|
354
|
+
|
|
355
|
+
### Components Object
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
{
|
|
359
|
+
UploadDropZone?: Component,
|
|
360
|
+
PhotoCard?: Component,
|
|
361
|
+
UploadProgressCard?: Component,
|
|
362
|
+
TrashIcon?: Component,
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Use Cases
|
|
367
|
+
|
|
368
|
+
### Admin App - Store Photos
|
|
369
|
+
```jsx
|
|
370
|
+
<PhotoManager variant="enhanced" entityId={storeId} photosHook={useStorePhotos(storeId)} />
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Business App - Business Profile Photos
|
|
374
|
+
```jsx
|
|
375
|
+
<PhotoManager variant="enhanced" entityId={businessId} photosHook={useBusinessPhotos(businessId)} />
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Customer App - Product Photos (if needed)
|
|
379
|
+
```jsx
|
|
380
|
+
<PhotoManager variant="enhanced" entityId={productId} photosHook={useProductPhotos(productId)} />
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Registration - Business Registration Photos
|
|
384
|
+
```jsx
|
|
385
|
+
<PhotoManager variant="enhanced" entityId={registrationId} photosHook={useRegistrationPhotos(registrationId)} />
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Backend Requirements
|
|
389
|
+
|
|
390
|
+
The enhanced variant expects these API endpoints:
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
GET /api/{entity}/{id}/photos - List photos
|
|
394
|
+
POST /api/{entity}/{id}/photos - Upload photo
|
|
395
|
+
DELETE /api/{entity}/{id}/photos/{photoId} - Delete photo
|
|
396
|
+
POST /api/{entity}/{id}/photos/bulk-delete - Bulk delete
|
|
397
|
+
POST /api/{entity}/{id}/photos/reorder - Reorder photos
|
|
398
|
+
POST /api/{entity}/{id}/photos/{photoId}/set-primary - Set primary
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
## Database Schema
|
|
402
|
+
|
|
403
|
+
```sql
|
|
404
|
+
CREATE TABLE entity_photos (
|
|
405
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
406
|
+
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
407
|
+
url TEXT NOT NULL,
|
|
408
|
+
type VARCHAR(50) DEFAULT 'gallery',
|
|
409
|
+
display_order INTEGER DEFAULT 0,
|
|
410
|
+
is_primary BOOLEAN DEFAULT false,
|
|
411
|
+
metadata JSONB,
|
|
412
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
413
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
414
|
+
deleted_at TIMESTAMP
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
CREATE INDEX idx_entity_photos_entity ON entity_photos(entity_id) WHERE deleted_at IS NULL;
|
|
418
|
+
CREATE INDEX idx_entity_photos_order ON entity_photos(entity_id, display_order) WHERE deleted_at IS NULL;
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
## License
|
|
422
|
+
|
|
423
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vasuzex",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.5",
|
|
4
4
|
"description": "Laravel-inspired framework for Node.js monorepos - V2 with optimized dependencies",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./framework/index.js",
|
|
@@ -89,6 +89,10 @@
|
|
|
89
89
|
"pnpm": ">=10.0.0"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
+
"@aws-sdk/client-s3": "^3.948.0",
|
|
93
|
+
"@aws-sdk/s3-request-presigner": "^3.948.0",
|
|
94
|
+
"@dnd-kit/core": "^6.3.1",
|
|
95
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
92
96
|
"@reduxjs/toolkit": "^2.5.0",
|
|
93
97
|
"axios": "^1.13.2",
|
|
94
98
|
"bcrypt": "^6.0.0",
|
|
@@ -114,15 +118,20 @@
|
|
|
114
118
|
"ora": "^8.2.0",
|
|
115
119
|
"pg": "^8.16.3",
|
|
116
120
|
"plop": "^4.0.1",
|
|
121
|
+
"prop-types": "^15.8.1",
|
|
117
122
|
"razorpay": "^2.9.6",
|
|
118
123
|
"react": "^18.2.0",
|
|
119
124
|
"react-dom": "^18.2.0",
|
|
125
|
+
"react-icons": "^5.5.0",
|
|
120
126
|
"react-router-dom": "^6.28.0",
|
|
127
|
+
"react-toastify": "^11.0.5",
|
|
121
128
|
"redux-persist": "^6.0.0",
|
|
122
129
|
"sharp": "^0.33.5",
|
|
123
130
|
"stripe": "^20.0.0",
|
|
124
131
|
"svelte": "^4.2.0",
|
|
125
|
-
"
|
|
132
|
+
"sweetalert2": "^11.26.3",
|
|
133
|
+
"vue": "^3.4.0",
|
|
134
|
+
"yup": "^1.7.1"
|
|
126
135
|
},
|
|
127
136
|
"devDependencies": {
|
|
128
137
|
"@jest/globals": "^29.7.0",
|