@visns-studio/visns-components 5.14.22 → 5.15.1

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/README.md CHANGED
@@ -9,10 +9,14 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
- ## Recent Updates (v5.14.20)
12
+ ## Recent Updates (v5.15.1)
13
13
 
14
14
  ### Latest Enhancements
15
15
 
16
+ - **Enhanced JSON Table Field**: Improved json-table field type with better styling, user experience, and data editing capabilities
17
+ - **Dynamic Action Labels**: GenericDetail component now supports dynamic action labels and custom action buttons for better user interaction
18
+ - **Dependency Updates**: Updated lucide-react to version 0.536.0 for improved icon consistency and performance
19
+ - **GenericAuth Enhancements**: Additional config prop support for more flexible authentication configurations
16
20
  - **GenericGrid Advanced Features**: Enhanced filtering with URL-based options, multiselect support, contact display integration, interactive date pickers with timezone support, and toggleable stage functionality
17
21
  - **Modular Architecture**: GenericIndex refactored into standalone GenericGrid and GenericReportForm components for better reusability
18
22
  - **Proposal System Integration**: GenericQuote enhanced with comprehensive proposal generation capabilities
package/package.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "@nivo/core": "^0.99.0",
12
12
  "@nivo/line": "^0.99.0",
13
13
  "@nivo/pie": "^0.99.0",
14
+ "@tanstack/react-query": "^5.84.1",
14
15
  "@tinymce/miniature": "^6.0.0",
15
16
  "@tinymce/tinymce-react": "^6.3.0",
16
17
  "@uiw/react-color": "^2.7.3",
@@ -89,7 +90,7 @@
89
90
  "react-dom": "^17.0.0 || ^18.0.0"
90
91
  },
91
92
  "name": "@visns-studio/visns-components",
92
- "version": "5.14.22",
93
+ "version": "5.15.1",
93
94
  "description": "Various packages to assist in the development of our Custom Applications.",
94
95
  "main": "src/index.js",
95
96
  "files": [
@@ -1,9 +1,128 @@
1
1
  import React from 'react';
2
+ import { QueryClient } from '@tanstack/react-query';
2
3
  import { trackPromise } from 'react-promise-tracker';
3
4
  import { toast } from 'react-toastify';
4
5
  import parse from 'html-react-parser';
5
6
 
6
- const CustomFetch = (
7
+ // Create a singleton QueryClient instance for the entire visns-components ecosystem
8
+ const createQueryClient = () => {
9
+ return new QueryClient({
10
+ defaultOptions: {
11
+ queries: {
12
+ staleTime: 2 * 60 * 1000, // 2 minutes - conservative setting
13
+ cacheTime: 10 * 60 * 1000, // 10 minutes - data stays in cache
14
+ retry: 1, // Conservative retry count
15
+ refetchOnWindowFocus: false, // Don't refetch on window focus
16
+ refetchOnReconnect: true, // Refetch when reconnecting to internet
17
+ retryOnMount: false, // Don't retry when component mounts
18
+ },
19
+ },
20
+ });
21
+ };
22
+
23
+ // Singleton QueryClient instance
24
+ let queryClientInstance = null;
25
+ const getQueryClient = () => {
26
+ if (!queryClientInstance) {
27
+ queryClientInstance = createQueryClient();
28
+ }
29
+ return queryClientInstance;
30
+ };
31
+
32
+ // Helper to generate consistent cache keys
33
+ const generateCacheKey = (url, method, formData) => {
34
+ const normalizedMethod = method.toUpperCase();
35
+ const baseUrl = url.replace(/^\/+/, ''); // Remove leading slashes for consistency
36
+
37
+ // For GET requests, include query params in URL part of cache key
38
+ if (normalizedMethod === 'GET' && formData && Object.keys(formData).length > 0) {
39
+ const params = new URLSearchParams();
40
+ Object.entries(formData).forEach(([key, value]) => {
41
+ if (value !== undefined && value !== null) {
42
+ params.append(key, value);
43
+ }
44
+ });
45
+ const queryString = params.toString();
46
+ return [baseUrl + (queryString ? `?${queryString}` : ''), normalizedMethod];
47
+ }
48
+
49
+ // For other methods, include formData in cache key
50
+ const dataKey = formData ? JSON.stringify(formData) : '';
51
+ return [baseUrl, normalizedMethod, dataKey];
52
+ };
53
+
54
+ // Helper to determine if request should be cached
55
+ const shouldCache = (method, url) => {
56
+ const normalizedMethod = method.toUpperCase();
57
+
58
+ // Always cache GET requests
59
+ if (normalizedMethod === 'GET') return true;
60
+
61
+ // Cache specific safe POST endpoints that return reference data
62
+ const cacheableEndpoints = [
63
+ '/dropdown', // Dropdown data (industries, facilities, etc.)
64
+ '/table', // Table data queries
65
+ '/availabilities', // Report availability data
66
+ '/reports/', // Report data
67
+ '/dashboard/', // Dashboard data
68
+ '/getFloatingDockUsage',
69
+ '/getWharfUsage',
70
+ '/getFloatingWharfUsage',
71
+ ];
72
+
73
+ return cacheableEndpoints.some(endpoint => url.includes(endpoint));
74
+ };
75
+
76
+ // Dynamic stale time based on endpoint type
77
+ const getStaleTime = (url) => {
78
+ if (url.includes('/dropdown')) return 10 * 60 * 1000; // 10 minutes for dropdown data
79
+ if (url.includes('/dashboard/')) return 5 * 60 * 1000; // 5 minutes for dashboard data
80
+ if (url.includes('/availabilities') || url.includes('/reports/')) return 2 * 60 * 1000; // 2 minutes for reports
81
+ if (url.includes('/table')) return 1 * 60 * 1000; // 1 minute for table data
82
+ return 30 * 1000; // 30 seconds default
83
+ };
84
+
85
+ // Auto-invalidation patterns for mutations
86
+ const invalidateRelatedCaches = (url, method) => {
87
+ const queryClient = getQueryClient();
88
+ const normalizedMethod = method.toUpperCase();
89
+
90
+ // Only invalidate for mutation methods
91
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(normalizedMethod)) {
92
+ return;
93
+ }
94
+
95
+ // Invalidate related cache entries based on URL patterns
96
+ if (url.includes('/leads/')) {
97
+ queryClient.invalidateQueries({ queryKey: ['leads/table'] });
98
+ queryClient.invalidateQueries({ queryKey: ['dashboard/'] });
99
+ }
100
+
101
+ if (url.includes('/clients/')) {
102
+ queryClient.invalidateQueries({ queryKey: ['clients/dropdown'] });
103
+ queryClient.invalidateQueries({ queryKey: ['clients/table'] });
104
+ }
105
+
106
+ if (url.includes('/clientAgreements/')) {
107
+ queryClient.invalidateQueries({ queryKey: ['clientAgreements/table'] });
108
+ queryClient.invalidateQueries({ queryKey: ['reports/'] });
109
+ }
110
+
111
+ if (url.includes('/facilities/')) {
112
+ queryClient.invalidateQueries({ queryKey: ['facilities/dropdown'] });
113
+ queryClient.invalidateQueries({ queryKey: ['availabilities'] });
114
+ queryClient.invalidateQueries({ queryKey: ['reports/'] });
115
+ }
116
+
117
+ // Invalidate all report caches for any data mutations
118
+ if (url.includes('/ajax/') && !url.includes('/dropdown')) {
119
+ queryClient.invalidateQueries({ queryKey: ['reports/'] });
120
+ queryClient.invalidateQueries({ queryKey: ['availabilities'] });
121
+ }
122
+ };
123
+
124
+ // Original CustomFetch logic extracted for reuse
125
+ const originalCustomFetch = (
7
126
  url,
8
127
  method,
9
128
  formData,
@@ -83,59 +202,143 @@ const CustomFetch = (
83
202
  fetchOptions.body = formData ? JSON.stringify(formData) : undefined;
84
203
  }
85
204
 
86
- return trackPromise(
87
- new Promise((resolve, reject) => {
88
- fetch(baseUrl + url, fetchOptions)
89
- .then(async (response) => {
90
- // Parse the response data
91
- const contentType = response.headers.get('content-type');
92
- let data;
93
-
94
- if (
95
- contentType &&
96
- contentType.includes('application/json')
97
- ) {
98
- data = await response.json();
99
- } else {
100
- data = await response.text();
205
+ return new Promise((resolve, reject) => {
206
+ fetch(baseUrl + url, fetchOptions)
207
+ .then(async (response) => {
208
+ // Parse the response data
209
+ const contentType = response.headers.get('content-type');
210
+ let data;
211
+
212
+ if (
213
+ contentType &&
214
+ contentType.includes('application/json')
215
+ ) {
216
+ data = await response.json();
217
+ } else {
218
+ data = await response.text();
219
+ }
220
+
221
+ // Create a response object similar to axios for compatibility
222
+ const responseObj = {
223
+ data: data,
224
+ status: response.status,
225
+ statusText: response.statusText,
226
+ headers: response.headers,
227
+ ok: response.ok,
228
+ };
229
+
230
+ if (!response.ok) {
231
+ // Handle error responses (4xx, 5xx)
232
+ const error = new Error(response.statusText);
233
+ error.response = responseObj;
234
+ throw error;
235
+ }
236
+
237
+ // Handle success
238
+ if (successCallback) {
239
+ successCallback(data);
240
+ } else {
241
+ const error = data.error;
242
+ if (error && error !== '') {
243
+ toast.error(<div>{parse(error)}</div>);
101
244
  }
245
+ }
246
+ resolve(responseObj);
247
+ })
248
+ .catch((error) => {
249
+ let errorMessage = '';
102
250
 
103
- // Create a response object similar to axios for compatibility
104
- const responseObj = {
105
- data: data,
106
- status: response.status,
107
- statusText: response.statusText,
108
- headers: response.headers,
109
- ok: response.ok,
110
- };
111
-
112
- if (!response.ok) {
113
- // Handle error responses (4xx, 5xx)
114
- const error = new Error(response.statusText);
115
- error.response = responseObj;
116
- throw error;
251
+ if (error.response && error.response.data) {
252
+ const { data } = error.response;
253
+
254
+ if (data.errors) {
255
+ const { errors } = data;
256
+
257
+ Object.values(errors).forEach((errorArray) => {
258
+ errorArray.forEach((errorMsg) => {
259
+ errorMessage += `${errorMsg}<br />`;
260
+ });
261
+ });
262
+ } else if (data.message) {
263
+ if (data.message === 'CSRF token mismatch.') {
264
+ window.location.replace('/login');
265
+ } else {
266
+ errorMessage = data.message;
267
+ }
117
268
  }
269
+ } else {
270
+ errorMessage = error.message;
271
+ }
118
272
 
119
- // Handle success
120
- if (successCallback) {
121
- successCallback(data);
273
+ if (errorCallback) {
274
+ errorCallback(errorMessage);
275
+ } else {
276
+ if (errorMessage !== 'Unauthenticated.') {
277
+ toast.error(<div>{parse(errorMessage)}</div>);
122
278
  } else {
123
- const error = data.error;
124
- if (error && error !== '') {
125
- toast.error(<div>{parse(error)}</div>);
279
+ console.info(
280
+ 'You are not authenticated into the system.'
281
+ );
282
+ }
283
+ }
284
+ reject(error);
285
+ });
286
+ });
287
+ };
288
+
289
+ // Enhanced CustomFetch with TanStack Query integration
290
+ const CustomFetch = (
291
+ url,
292
+ method,
293
+ formData,
294
+ successCallback = null,
295
+ errorCallback = null
296
+ ) => {
297
+ const queryClient = getQueryClient();
298
+ const cacheKey = generateCacheKey(url, method, formData);
299
+ const useCache = shouldCache(method, url);
300
+
301
+ if (useCache) {
302
+ // Use TanStack Query for cacheable requests
303
+ return trackPromise(
304
+ queryClient.fetchQuery({
305
+ queryKey: cacheKey,
306
+ queryFn: () => originalCustomFetch(url, method, formData, null, null),
307
+ staleTime: getStaleTime(url),
308
+ cacheTime: 10 * 60 * 1000, // 10 minutes cache time
309
+ }).then(response => {
310
+ // Call success callback exactly like original implementation
311
+ if (successCallback) {
312
+ successCallback(response.data);
313
+ }
314
+ return response;
315
+ }).catch(error => {
316
+ // Call error callback exactly like original implementation
317
+ if (errorCallback) {
318
+ let errorMessage = '';
319
+ if (error.response && error.response.data) {
320
+ const { data } = error.response;
321
+ if (data.errors) {
322
+ const { errors } = data;
323
+ Object.values(errors).forEach((errorArray) => {
324
+ errorArray.forEach((errorMsg) => {
325
+ errorMessage += `${errorMsg}<br />`;
326
+ });
327
+ });
328
+ } else if (data.message) {
329
+ errorMessage = data.message;
126
330
  }
331
+ } else {
332
+ errorMessage = error.message;
127
333
  }
128
- resolve(responseObj);
129
- })
130
- .catch((error) => {
334
+ errorCallback(errorMessage);
335
+ } else {
336
+ // Handle default error display like original
131
337
  let errorMessage = '';
132
-
133
338
  if (error.response && error.response.data) {
134
339
  const { data } = error.response;
135
-
136
340
  if (data.errors) {
137
341
  const { errors } = data;
138
-
139
342
  Object.values(errors).forEach((errorArray) => {
140
343
  errorArray.forEach((errorMsg) => {
141
344
  errorMessage += `${errorMsg}<br />`;
@@ -144,6 +347,7 @@ const CustomFetch = (
144
347
  } else if (data.message) {
145
348
  if (data.message === 'CSRF token mismatch.') {
146
349
  window.location.replace('/login');
350
+ return;
147
351
  } else {
148
352
  errorMessage = data.message;
149
353
  }
@@ -152,21 +356,53 @@ const CustomFetch = (
152
356
  errorMessage = error.message;
153
357
  }
154
358
 
155
- if (errorCallback) {
156
- errorCallback(errorMessage);
359
+ if (errorMessage !== 'Unauthenticated.') {
360
+ toast.error(<div>{parse(errorMessage)}</div>);
157
361
  } else {
158
- if (errorMessage !== 'Unauthenticated.') {
159
- toast.error(<div>{parse(errorMessage)}</div>);
160
- } else {
161
- console.info(
162
- 'You are not authenticated into the system.'
163
- );
164
- }
362
+ console.info('You are not authenticated into the system.');
165
363
  }
166
- reject(error);
167
- });
168
- })
169
- );
364
+ }
365
+ throw error;
366
+ })
367
+ );
368
+ } else {
369
+ // Use original implementation for non-cacheable requests (mutations)
370
+ const result = trackPromise(
371
+ originalCustomFetch(url, method, formData, successCallback, errorCallback)
372
+ );
373
+
374
+ // Auto-invalidate related caches after mutations
375
+ result.then(() => {
376
+ invalidateRelatedCaches(url, method);
377
+ }).catch(() => {
378
+ // Don't invalidate caches on error
379
+ });
380
+
381
+ return result;
382
+ }
383
+ };
384
+
385
+ // Export the QueryClient instance for potential advanced usage
386
+ CustomFetch.getQueryClient = getQueryClient;
387
+
388
+ // Method to manually invalidate specific cache patterns
389
+ CustomFetch.invalidateCache = (pattern) => {
390
+ const queryClient = getQueryClient();
391
+ if (Array.isArray(pattern)) {
392
+ queryClient.invalidateQueries({ queryKey: pattern });
393
+ } else {
394
+ queryClient.invalidateQueries({
395
+ predicate: (query) => query.queryKey.some(key =>
396
+ typeof key === 'string' && key.includes(pattern)
397
+ )
398
+ });
399
+ }
400
+ };
401
+
402
+ // Method to clear all cache
403
+ CustomFetch.clearCache = () => {
404
+ const queryClient = getQueryClient();
405
+ queryClient.clear();
170
406
  };
171
407
 
172
- export default CustomFetch;
408
+ export default CustomFetch;
@@ -0,0 +1,172 @@
1
+ import React from 'react';
2
+ import { trackPromise } from 'react-promise-tracker';
3
+ import { toast } from 'react-toastify';
4
+ import parse from 'html-react-parser';
5
+
6
+ const CustomFetch = (
7
+ url,
8
+ method,
9
+ formData,
10
+ successCallback = null,
11
+ errorCallback = null
12
+ ) => {
13
+ const baseUrl = '';
14
+ const headers = {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Requested-With': 'XMLHttpRequest',
17
+ };
18
+
19
+ // Add CSRF token if available
20
+ const csrfToken = document
21
+ .querySelector('meta[name="csrf-token"]')
22
+ ?.getAttribute('content');
23
+ if (csrfToken) {
24
+ headers['X-CSRF-TOKEN'] = csrfToken;
25
+ headers['X-XSRF-TOKEN'] = csrfToken;
26
+ }
27
+ const MAX_PAYLOAD_SIZE_MB =
28
+ parseFloat(import.meta.env.VITE_MAX_PAYLOAD_SIZE_MB) || 4.5;
29
+ const MAX_PAYLOAD_SIZE = MAX_PAYLOAD_SIZE_MB * 1024 * 1024; // Convert MB to bytes
30
+
31
+ // Estimate headers size
32
+ const headersSize = JSON.stringify(headers).length;
33
+
34
+ // Estimate cookies size (if any)
35
+ const cookies = document.cookie;
36
+ const cookiesSize = cookies ? cookies.length : 0;
37
+
38
+ // Check payload size
39
+ const payloadSize = new Blob([JSON.stringify(formData)]).size;
40
+ const totalRequestSize = payloadSize + headersSize + cookiesSize;
41
+
42
+ if (totalRequestSize > MAX_PAYLOAD_SIZE) {
43
+ const errorMessage =
44
+ 'The request is too large. Please try again with a smaller payload.';
45
+ if (errorCallback) {
46
+ errorCallback(errorMessage);
47
+ } else {
48
+ toast.error(errorMessage);
49
+ }
50
+ return Promise.reject(new Error(errorMessage));
51
+ }
52
+
53
+ if (method.toUpperCase() === 'PUT' || method.toUpperCase() === 'PATCH') {
54
+ formData = {
55
+ ...formData,
56
+ _method: method,
57
+ };
58
+ }
59
+
60
+ // Configure fetch options
61
+ const fetchOptions = {
62
+ method: method,
63
+ headers: headers,
64
+ };
65
+
66
+ // For GET and HEAD requests, convert formData to query parameters
67
+ if (method.toUpperCase() === 'GET' || method.toUpperCase() === 'HEAD') {
68
+ if (formData && Object.keys(formData).length > 0) {
69
+ // Convert formData to query string
70
+ const queryParams = new URLSearchParams();
71
+ Object.entries(formData).forEach(([key, value]) => {
72
+ if (value !== undefined && value !== null) {
73
+ queryParams.append(key, value);
74
+ }
75
+ });
76
+ const queryString = queryParams.toString();
77
+ if (queryString) {
78
+ url += (url.includes('?') ? '&' : '?') + queryString;
79
+ }
80
+ }
81
+ } else {
82
+ // For other methods, add body
83
+ fetchOptions.body = formData ? JSON.stringify(formData) : undefined;
84
+ }
85
+
86
+ return trackPromise(
87
+ new Promise((resolve, reject) => {
88
+ fetch(baseUrl + url, fetchOptions)
89
+ .then(async (response) => {
90
+ // Parse the response data
91
+ const contentType = response.headers.get('content-type');
92
+ let data;
93
+
94
+ if (
95
+ contentType &&
96
+ contentType.includes('application/json')
97
+ ) {
98
+ data = await response.json();
99
+ } else {
100
+ data = await response.text();
101
+ }
102
+
103
+ // Create a response object similar to axios for compatibility
104
+ const responseObj = {
105
+ data: data,
106
+ status: response.status,
107
+ statusText: response.statusText,
108
+ headers: response.headers,
109
+ ok: response.ok,
110
+ };
111
+
112
+ if (!response.ok) {
113
+ // Handle error responses (4xx, 5xx)
114
+ const error = new Error(response.statusText);
115
+ error.response = responseObj;
116
+ throw error;
117
+ }
118
+
119
+ // Handle success
120
+ if (successCallback) {
121
+ successCallback(data);
122
+ } else {
123
+ const error = data.error;
124
+ if (error && error !== '') {
125
+ toast.error(<div>{parse(error)}</div>);
126
+ }
127
+ }
128
+ resolve(responseObj);
129
+ })
130
+ .catch((error) => {
131
+ let errorMessage = '';
132
+
133
+ if (error.response && error.response.data) {
134
+ const { data } = error.response;
135
+
136
+ if (data.errors) {
137
+ const { errors } = data;
138
+
139
+ Object.values(errors).forEach((errorArray) => {
140
+ errorArray.forEach((errorMsg) => {
141
+ errorMessage += `${errorMsg}<br />`;
142
+ });
143
+ });
144
+ } else if (data.message) {
145
+ if (data.message === 'CSRF token mismatch.') {
146
+ window.location.replace('/login');
147
+ } else {
148
+ errorMessage = data.message;
149
+ }
150
+ }
151
+ } else {
152
+ errorMessage = error.message;
153
+ }
154
+
155
+ if (errorCallback) {
156
+ errorCallback(errorMessage);
157
+ } else {
158
+ if (errorMessage !== 'Unauthenticated.') {
159
+ toast.error(<div>{parse(errorMessage)}</div>);
160
+ } else {
161
+ console.info(
162
+ 'You are not authenticated into the system.'
163
+ );
164
+ }
165
+ }
166
+ reject(error);
167
+ });
168
+ })
169
+ );
170
+ };
171
+
172
+ export default CustomFetch;
@@ -1302,12 +1302,14 @@ function Field({
1302
1302
 
1303
1303
  return htmlContainer;
1304
1304
  case 'html5_date':
1305
- // Convert inputValue to 'YYYY-MM-DD' if necessary
1306
- const formattedDate =
1307
- inputValue &&
1308
- moment(inputValue, moment.ISO_8601, true).isValid()
1309
- ? moment(inputValue).format('YYYY-MM-DD')
1310
- : inputValue; // Use as-is if it's already in the correct format or invalid
1305
+ // Handle "today" value for new entries
1306
+ let formattedDate = inputValue;
1307
+ if (inputValue === 'today') {
1308
+ formattedDate = moment().format('YYYY-MM-DD');
1309
+ } else if (inputValue && moment(inputValue, moment.ISO_8601, true).isValid()) {
1310
+ // Convert inputValue to 'YYYY-MM-DD' if necessary
1311
+ formattedDate = moment(inputValue).format('YYYY-MM-DD');
1312
+ }
1311
1313
 
1312
1314
  return (
1313
1315
  <input
@@ -1321,6 +1323,14 @@ function Field({
1321
1323
  />
1322
1324
  );
1323
1325
  case 'html5_datetime':
1326
+ // Handle "today" or "now" value for new entries
1327
+ let formattedDateTime = inputValue;
1328
+ if (inputValue === 'today') {
1329
+ formattedDateTime = moment().format('YYYY-MM-DD') + 'T00:00';
1330
+ } else if (inputValue === 'now') {
1331
+ formattedDateTime = moment().format('YYYY-MM-DDTHH:mm');
1332
+ }
1333
+
1324
1334
  return (
1325
1335
  <input
1326
1336
  type="datetime-local"
@@ -1329,8 +1339,8 @@ function Field({
1329
1339
  placeholder=" "
1330
1340
  onChange={onChange}
1331
1341
  value={
1332
- inputValue && inputValue !== 'null'
1333
- ? inputValue
1342
+ formattedDateTime && formattedDateTime !== 'null'
1343
+ ? formattedDateTime
1334
1344
  : ''
1335
1345
  }
1336
1346
  autoComplete="off"
@@ -2,7 +2,7 @@ import { useState } from 'react';
2
2
  import Popup from 'reactjs-popup';
3
3
  import { toast } from 'react-toastify';
4
4
  import imageCompression from 'browser-image-compression';
5
- import Vapor from 'laravel-vapor';
5
+ // import Vapor from 'laravel-vapor'; // Commented out to avoid build issues
6
6
  import Lightbox from 'yet-another-react-lightbox';
7
7
  import 'yet-another-react-lightbox/styles.css';
8
8
  import { X, Image as ImageIcon, CloudUpload } from 'lucide-react';