@visns-studio/visns-components 5.12.4 → 5.12.6

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/package.json CHANGED
@@ -87,7 +87,7 @@
87
87
  "react-dom": "^17.0.0 || ^18.0.0"
88
88
  },
89
89
  "name": "@visns-studio/visns-components",
90
- "version": "5.12.4",
90
+ "version": "5.12.6",
91
91
  "description": "Various packages to assist in the development of our Custom Applications.",
92
92
  "main": "src/index.js",
93
93
  "files": [
@@ -54,10 +54,16 @@ import {
54
54
  Eye as EyeOpen,
55
55
  EyeOff as EyeSlashed,
56
56
  File,
57
+ FileText,
58
+ FileSpreadsheet,
59
+ FileImage,
60
+ FileCode,
61
+ FileArchive,
57
62
  Edit as Pencil,
58
63
  Trash2 as TrashCan,
64
+ X as CircleX,
59
65
  } from 'lucide-react';
60
- import { confirmDialog } from '../../utils/ConfirmDialog';
66
+ import { confirmDialog } from '../utils/ConfirmDialog';
61
67
 
62
68
  import 'react-big-calendar/lib/css/react-big-calendar.css';
63
69
  import 'react-big-calendar/lib/addons/dragAndDrop/styles.css';
@@ -79,6 +85,9 @@ import TableFilter from '../TableFilter';
79
85
  import ProposalTemplateSectionManager from '../proposal/ProposalTemplateSectionManager';
80
86
  import ProposalTemplatePreview from '../proposal/ProposalTemplatePreview';
81
87
 
88
+ // Gallery Modal
89
+ import GalleryModal from '../modals/GalleryModal';
90
+
82
91
  import styles from '../styles/GenericDetail.module.scss';
83
92
 
84
93
  const localizer = momentLocalizer(moment);
@@ -127,6 +136,88 @@ const optimizeImageIfNeeded = async (file) => {
127
136
  return file;
128
137
  };
129
138
 
139
+ // Helper function to get accept types based on filetype configuration
140
+ const getAcceptTypes = (filetype) => {
141
+ switch (filetype) {
142
+ case 'document':
143
+ return {
144
+ 'application/pdf': ['.pdf'],
145
+ 'application/msword': ['.doc'],
146
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
147
+ 'application/vnd.ms-excel': ['.xls'],
148
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
149
+ 'application/vnd.ms-powerpoint': ['.ppt'],
150
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
151
+ 'text/plain': ['.txt'],
152
+ 'text/csv': ['.csv'],
153
+ 'application/zip': ['.zip'],
154
+ 'application/x-rar-compressed': ['.rar'],
155
+ 'application/x-7z-compressed': ['.7z']
156
+ };
157
+ case 'image':
158
+ return {
159
+ 'image/jpeg': ['.jpg', '.jpeg'],
160
+ 'image/png': ['.png'],
161
+ 'image/gif': ['.gif'],
162
+ 'image/webp': ['.webp'],
163
+ 'image/svg+xml': ['.svg'],
164
+ 'image/bmp': ['.bmp'],
165
+ 'image/tiff': ['.tiff', '.tif']
166
+ };
167
+ default:
168
+ return undefined; // Accept all file types
169
+ }
170
+ };
171
+
172
+ // Helper function to get the appropriate icon based on file extension
173
+ const getFileIcon = (filename) => {
174
+ const extension = filename.split('.').pop().toLowerCase();
175
+
176
+ switch (extension) {
177
+ case 'pdf':
178
+ return <FileText strokeWidth={2} size={20} color="#dc2626" />;
179
+ case 'doc':
180
+ case 'docx':
181
+ return <FileText strokeWidth={2} size={20} color="#2563eb" />;
182
+ case 'xls':
183
+ case 'xlsx':
184
+ return <FileSpreadsheet strokeWidth={2} size={20} color="#16a34a" />;
185
+ case 'ppt':
186
+ case 'pptx':
187
+ return <FileText strokeWidth={2} size={20} color="#ea580c" />;
188
+ case 'txt':
189
+ case 'csv':
190
+ return <FileCode strokeWidth={2} size={20} color="#6b7280" />;
191
+ case 'zip':
192
+ case 'rar':
193
+ case '7z':
194
+ return <FileArchive strokeWidth={2} size={20} color="#7c3aed" />;
195
+ case 'jpg':
196
+ case 'jpeg':
197
+ case 'png':
198
+ case 'gif':
199
+ case 'webp':
200
+ case 'svg':
201
+ case 'bmp':
202
+ case 'tiff':
203
+ case 'tif':
204
+ return <FileImage strokeWidth={2} size={20} color="#059669" />;
205
+ default:
206
+ return <File strokeWidth={2} size={20} />;
207
+ }
208
+ };
209
+
210
+ // Helper function to format file size
211
+ const formatFileSize = (bytes) => {
212
+ if (!bytes || bytes === 0) return '';
213
+
214
+ const k = 1024;
215
+ const sizes = ['B', 'KB', 'MB', 'GB'];
216
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
217
+
218
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
219
+ };
220
+
130
221
  function GenericDetail({
131
222
  extraUrlParam,
132
223
  layout = 'full',
@@ -158,6 +249,19 @@ function GenericDetail({
158
249
  const [total, setTotal] = useState(0);
159
250
  const [windowHeight, setWindowHeight] = useState(window.innerHeight);
160
251
 
252
+ /** Gallery Modal States */
253
+ const [modalGalleryShow, setModalGalleryShow] = useState(false);
254
+ const [galleryData, setGalleryData] = useState([]);
255
+ const [currentItem, setCurrentItem] = useState(null);
256
+ const [lightboxOpen, setLightboxOpen] = useState(false);
257
+ const [lightboxIndex, setLightboxIndex] = useState(0);
258
+
259
+ // Gallery modal close function
260
+ const modalGalleryClose = () => {
261
+ setModalGalleryShow(false);
262
+ setLightboxOpen(false);
263
+ };
264
+
161
265
  /** Stage Popup States */
162
266
  const [showStagePopup, setShowStagePopup] = useState(false);
163
267
  const [stagePopupData, setStagePopupData] = useState(null);
@@ -1445,7 +1549,7 @@ function GenericDetail({
1445
1549
  message:
1446
1550
  'Are you sure you want to delete ' +
1447
1551
  name,
1448
- data: { name: name }, // Add context data
1552
+ data: { name: name },
1449
1553
  buttons: [
1450
1554
  {
1451
1555
  label: 'Yes',
@@ -1473,7 +1577,9 @@ function GenericDetail({
1473
1577
  },
1474
1578
  {
1475
1579
  label: 'No',
1476
- onClick: () => close(),
1580
+ onClick: () => {
1581
+ // Do nothing on cancel
1582
+ },
1477
1583
  },
1478
1584
  ],
1479
1585
  });
@@ -1501,6 +1607,7 @@ function GenericDetail({
1501
1607
  />
1502
1608
  </div>
1503
1609
  <Dropzone
1610
+ accept={getAcceptTypes(activeTabConfig.filetype)}
1504
1611
  onDrop={(acceptedFiles) => {
1505
1612
  const uploadFile = async (file) => {
1506
1613
  // Optimize image if it's an image file
@@ -1695,20 +1802,9 @@ function GenericDetail({
1695
1802
  styles.sortlist
1696
1803
  }
1697
1804
  >
1698
- {data[
1699
- activeTabConfig
1700
- .key
1701
- ] &&
1702
- Array.isArray(
1703
- data[
1704
- activeTabConfig
1705
- .key
1706
- ]
1707
- ) &&
1708
- data[
1709
- activeTabConfig
1710
- .key
1711
- ].map(
1805
+ {files &&
1806
+ Array.isArray(files) &&
1807
+ files.map(
1712
1808
  (a, b) => {
1713
1809
  const imageSource =
1714
1810
  a.base64_encoded_image ||
@@ -1732,6 +1828,33 @@ function GenericDetail({
1732
1828
  alt={
1733
1829
  a.file_name
1734
1830
  }
1831
+ onClick={(e) => {
1832
+ e.stopPropagation();
1833
+ e.preventDefault();
1834
+
1835
+ // Prepare gallery data in the format expected by Lightbox
1836
+ const galleryArray = files
1837
+ .filter(file => {
1838
+ const imageSource = file.base64_encoded_image || file.s3_url || file.file_url;
1839
+ return imageSource;
1840
+ })
1841
+ .map((file) => ({
1842
+ src: file.base64_encoded_image || file.s3_url || file.file_url,
1843
+ caption: file.file_name,
1844
+ width: 320,
1845
+ height: 240,
1846
+ }));
1847
+
1848
+ // Set gallery data for lightbox
1849
+ setGalleryData(galleryArray);
1850
+
1851
+ // Set initial lightbox index
1852
+ setLightboxIndex(b);
1853
+
1854
+ // Open lightbox directly
1855
+ setLightboxOpen(true);
1856
+ }}
1857
+ style={{ cursor: 'pointer' }}
1735
1858
  />
1736
1859
  <TrashCan
1737
1860
  onClick={(
@@ -1788,18 +1911,16 @@ function GenericDetail({
1788
1911
  );
1789
1912
  }}
1790
1913
  >
1791
- <File
1792
- strokeWidth={
1793
- 2
1794
- }
1795
- size={
1796
- 20
1797
- }
1798
- />
1914
+ {getFileIcon(a.file_name)}
1799
1915
  <div className="filename">
1800
- {
1801
- a.file_name
1802
- }
1916
+ <div className="file-name">
1917
+ {a.file_name}
1918
+ </div>
1919
+ {a.file_size && (
1920
+ <div className="file-size">
1921
+ {formatFileSize(a.file_size)}
1922
+ </div>
1923
+ )}
1803
1924
  </div>
1804
1925
  </div>
1805
1926
  <button
@@ -2390,6 +2511,7 @@ function GenericDetail({
2390
2511
  }
2391
2512
  >
2392
2513
  <Dropzone
2514
+ accept={getAcceptTypes(activeTabConfig.filetype)}
2393
2515
  onDrop={(
2394
2516
  acceptedFiles
2395
2517
  ) => {
@@ -2718,6 +2840,17 @@ function GenericDetail({
2718
2840
  }
2719
2841
  }, [subnav, tabs]);
2720
2842
 
2843
+ // Update files when active tab changes for dropzone tabs
2844
+ useEffect(() => {
2845
+ if (activeTabConfig && activeTabConfig.type === 'dropzone' && data) {
2846
+ if (activeTabConfig.file_relationship && data[activeTabConfig.file_relationship]) {
2847
+ setFiles(data[activeTabConfig.file_relationship]);
2848
+ } else if (data.files) {
2849
+ setFiles(data.files);
2850
+ }
2851
+ }
2852
+ }, [activeTabConfig, data]);
2853
+
2721
2854
  useEffect(() => {
2722
2855
  const fetchDropdownOptions = async () => {
2723
2856
  if (activeTabConfig?.columns) {
@@ -3167,6 +3300,20 @@ function GenericDetail({
3167
3300
  entityData={data}
3168
3301
  routeParams={routeParams}
3169
3302
  />
3303
+
3304
+ {/* Gallery Modal for image viewing */}
3305
+ <GalleryModal
3306
+ modalGalleryShow={modalGalleryShow}
3307
+ modalGalleryClose={modalGalleryClose}
3308
+ galleryData={galleryData}
3309
+ setGalleryData={setGalleryData}
3310
+ currentItem={currentItem}
3311
+ form={{}}
3312
+ lightboxOpen={lightboxOpen}
3313
+ setLightboxOpen={setLightboxOpen}
3314
+ lightboxIndex={lightboxIndex}
3315
+ setLightboxIndex={setLightboxIndex}
3316
+ />
3170
3317
  </>
3171
3318
  );
3172
3319
  }
@@ -25,14 +25,7 @@ const StagePopupModal = ({
25
25
 
26
26
  // Re-render form fields when fieldOptions change
27
27
  useEffect(() => {
28
- console.log('[StagePopupModal] useEffect triggered:', {
29
- show,
30
- loading,
31
- fieldOptionsKeys: Object.keys(fieldOptions),
32
- fieldOptions
33
- });
34
28
  if (show && !loading && Object.keys(fieldOptions).length > 0) {
35
- console.log('[StagePopupModal] Re-rendering form fields after fieldOptions update');
36
29
  renderFormFields();
37
30
  }
38
31
  }, [fieldOptions, loading, formData, validationErrors, show]);
@@ -64,14 +57,11 @@ const StagePopupModal = ({
64
57
  });
65
58
 
66
59
  const response = await CustomFetch(url, 'POST');
67
- console.log(`[StagePopupModal] Response for ${field.id}:`, response);
68
- console.log(`[StagePopupModal] Response.data:`, response.data);
69
60
 
70
61
  if (response.data && !response.data.error) {
71
62
  // Handle both nested (response.data.data) and direct (response.data) array formats
72
63
  const optionsData = Array.isArray(response.data.data) ? response.data.data :
73
64
  Array.isArray(response.data) ? response.data : [];
74
- console.log(`[StagePopupModal] Processed options for ${field.id}:`, optionsData);
75
65
  options[field.id] = optionsData;
76
66
  }
77
67
  } catch (error) {
@@ -81,9 +71,6 @@ const StagePopupModal = ({
81
71
  }
82
72
  }
83
73
 
84
- console.log(`[StagePopupModal] Final options object:`, options);
85
- console.log(`[StagePopupModal] Setting fieldOptions state:`, options);
86
-
87
74
  setFormData(initialData);
88
75
  setFieldOptions(options);
89
76
  setLoading(false);
@@ -106,15 +93,24 @@ const StagePopupModal = ({
106
93
 
107
94
  const validateForm = () => {
108
95
  const errors = {};
96
+ const fieldsContainer = document.getElementById('stage-popup-fields');
109
97
 
110
98
  popupConfig.fields?.forEach(field => {
111
- if (field.required && (!formData[field.id] || formData[field.id] === '')) {
99
+ let value = formData[field.id];
100
+
101
+ // Get current value from DOM if available
102
+ if (fieldsContainer) {
103
+ const input = fieldsContainer.querySelector(`[data-field-id="${field.id}"]`);
104
+ if (input) {
105
+ value = input.value;
106
+ }
107
+ }
108
+
109
+ if (field.required && (!value || value === '')) {
112
110
  errors[field.id] = `${field.label} is required`;
113
111
  }
114
112
 
115
113
  if (field.validation) {
116
- const value = formData[field.id];
117
-
118
114
  if (field.validation.minLength && value && value.length < field.validation.minLength) {
119
115
  errors[field.id] = `${field.label} must be at least ${field.validation.minLength} characters`;
120
116
  }
@@ -135,13 +131,24 @@ const StagePopupModal = ({
135
131
  }
136
132
 
137
133
  const dataToSend = {};
134
+ const fieldsContainer = document.getElementById('stage-popup-fields');
138
135
 
139
136
  // Only include fields specified in saveFields, or all fields if not specified
140
137
  const fieldsToSave = popupConfig.saveFields || popupConfig.fields?.map(f => f.id) || [];
141
138
 
142
139
  fieldsToSave.forEach(fieldId => {
143
- if (formData[fieldId] !== undefined) {
144
- dataToSend[fieldId] = formData[fieldId];
140
+ let value = formData[fieldId];
141
+
142
+ // Get current value from DOM if available
143
+ if (fieldsContainer) {
144
+ const input = fieldsContainer.querySelector(`[data-field-id="${fieldId}"]`);
145
+ if (input) {
146
+ value = input.value;
147
+ }
148
+ }
149
+
150
+ if (value !== undefined && value !== '') {
151
+ dataToSend[fieldId] = value;
145
152
  }
146
153
  });
147
154
 
@@ -168,14 +175,11 @@ const StagePopupModal = ({
168
175
  disabled={loading}
169
176
  >
170
177
  <option value="">-- Select {field.label} --</option>
171
- {(() => {
172
- console.log(`[StagePopupModal] Rendering JSX options for ${field.id}:`, fieldOptions[field.id]);
173
- return (fieldOptions[field.id] || []).map(option => (
174
- <option key={option.id || option.value} value={option.id || option.value}>
175
- {option.label || option.name || option.text}
176
- </option>
177
- ));
178
- })()}
178
+ {(fieldOptions[field.id] || []).map(option => (
179
+ <option key={option.id || option.value} value={option.id || option.value}>
180
+ {option.label || option.name || option.text}
181
+ </option>
182
+ ))}
179
183
  </select>
180
184
  {hasError && <span className={styles.errorText}>{validationErrors[field.id]}</span>}
181
185
  </div>
@@ -303,13 +307,12 @@ const StagePopupModal = ({
303
307
  onCancel();
304
308
  }
305
309
  });
306
- } else if (popupConfig.type === 'form') {
307
- // Custom form dialog
310
+ } else if (popupConfig.type === 'form' && !loading) {
311
+ // Custom form dialog - only show when not loading
308
312
  const formHTML = `
309
313
  <div class="${styles.stagePopupForm}">
310
314
  ${popupConfig.message ? `<p class="${styles.message}">${popupConfig.message}</p>` : ''}
311
315
  <div id="stage-popup-fields" class="${styles.fields}">
312
- ${loading ? '<div class="' + styles.loading + '">Loading...</div>' : ''}
313
316
  </div>
314
317
  </div>
315
318
  `;
@@ -330,6 +333,9 @@ const StagePopupModal = ({
330
333
  renderFormFields();
331
334
  },
332
335
  preConfirm: () => {
336
+ // Update form data from DOM before validation
337
+ updateFormDataFromDOM();
338
+
333
339
  if (!validateForm()) {
334
340
  Swal.showValidationMessage('Please correct the errors below');
335
341
  return false;
@@ -347,28 +353,36 @@ const StagePopupModal = ({
347
353
  }
348
354
  }, [show, popupConfig, loading, formData, fieldOptions]);
349
355
 
356
+ const updateFormDataFromDOM = () => {
357
+ const fieldsContainer = document.getElementById('stage-popup-fields');
358
+ if (!fieldsContainer) return;
359
+
360
+ const newFormData = { ...formData };
361
+
362
+ popupConfig.fields?.forEach(field => {
363
+ const input = fieldsContainer.querySelector(`[data-field-id="${field.id}"]`);
364
+ if (input) {
365
+ newFormData[field.id] = input.value;
366
+ }
367
+ });
368
+
369
+ setFormData(newFormData);
370
+ };
371
+
350
372
  const renderFormFields = () => {
351
- console.log('[StagePopupModal] renderFormFields called');
352
373
  const fieldsContainer = document.getElementById('stage-popup-fields');
353
- console.log('[StagePopupModal] fieldsContainer:', fieldsContainer);
354
- console.log('[StagePopupModal] loading:', loading);
355
374
 
356
375
  if (!fieldsContainer || loading) {
357
- console.log('[StagePopupModal] Early return - no container or loading');
358
376
  return;
359
377
  }
360
378
 
361
- console.log('[StagePopupModal] Clearing container and rendering fields');
362
379
  fieldsContainer.innerHTML = '';
363
380
 
364
- console.log('[StagePopupModal] popupConfig.fields:', popupConfig.fields);
365
381
  popupConfig.fields?.forEach(field => {
366
- console.log(`[StagePopupModal] Processing field: ${field.id}, type: ${field.type}`);
367
382
  const fieldElement = document.createElement('div');
368
383
  fieldElement.className = styles.fieldWrapper;
369
384
 
370
385
  const fieldHTML = renderFieldHTML(field);
371
- console.log(`[StagePopupModal] Generated HTML for ${field.id}:`, fieldHTML);
372
386
  fieldElement.innerHTML = fieldHTML;
373
387
 
374
388
  fieldsContainer.appendChild(fieldElement);
@@ -379,6 +393,9 @@ const StagePopupModal = ({
379
393
  input.addEventListener('change', (e) => {
380
394
  handleFieldChange(field.id, e.target.value);
381
395
  });
396
+ input.addEventListener('input', (e) => {
397
+ handleFieldChange(field.id, e.target.value);
398
+ });
382
399
  }
383
400
  });
384
401
  };
@@ -390,7 +407,6 @@ const StagePopupModal = ({
390
407
 
391
408
  switch (field.type) {
392
409
  case 'select':
393
- console.log(`[StagePopupModal] Rendering HTML options for ${field.id}:`, fieldOptions[field.id]);
394
410
  const options = (fieldOptions[field.id] || []).map(option =>
395
411
  `<option value="${option.id || option.value}" ${value === (option.id || option.value) ? 'selected' : ''}>
396
412
  ${option.label || option.name || option.text}
@@ -399,7 +415,7 @@ const StagePopupModal = ({
399
415
 
400
416
  return `
401
417
  <label class="${styles.label}">${field.label}${requiredStar}</label>
402
- <select class="${styles.select}" data-field-id="${field.id}">
418
+ <select class="${styles.select}" data-field-id="${field.id}" ${loading ? 'disabled' : ''}>
403
419
  <option value="">-- Select ${field.label} --</option>
404
420
  ${options}
405
421
  </select>
@@ -4,6 +4,7 @@ import { toast } from 'react-toastify';
4
4
  import imageCompression from 'browser-image-compression';
5
5
  import Vapor from 'laravel-vapor';
6
6
  import Lightbox from 'yet-another-react-lightbox';
7
+ import 'yet-another-react-lightbox/styles.css';
7
8
  import { X, Image as ImageIcon, CloudUpload } from 'lucide-react';
8
9
  import styles from '../styles/DataGrid.module.scss';
9
10
  import CustomFetch from '../Fetch';
@@ -396,7 +397,34 @@ const GalleryModal = ({
396
397
  open={lightboxOpen}
397
398
  close={() => setLightboxOpen(false)}
398
399
  index={lightboxIndex}
399
- slides={galleryData.map((img) => ({ src: img.src }))}
400
+ slides={galleryData.map((img, index) => ({
401
+ src: img.src,
402
+ alt: img.caption || `Image ${index + 1}`,
403
+ title: img.caption
404
+ }))}
405
+ styles={{
406
+ container: {
407
+ backgroundColor: "rgba(0, 0, 0, 0.9)",
408
+ zIndex: 999999
409
+ }
410
+ }}
411
+ controller={{
412
+ closeOnBackdropClick: true,
413
+ closeOnPullUp: true,
414
+ closeOnPullDown: true
415
+ }}
416
+ carousel={{
417
+ finite: true,
418
+ preload: 1,
419
+ padding: "16px",
420
+ spacing: "30%",
421
+ imageFit: "contain"
422
+ }}
423
+ animation={{
424
+ fade: 300,
425
+ swipe: 400
426
+ }}
427
+ portal={{ root: document.body }}
400
428
  />
401
429
  </>
402
430
  );
@@ -333,9 +333,25 @@
333
333
  }
334
334
 
335
335
  .filename {
336
- white-space: nowrap;
336
+ display: flex;
337
+ flex-direction: column;
337
338
  overflow: hidden;
338
- text-overflow: ellipsis;
339
+ flex: 1;
340
+
341
+ .file-name {
342
+ white-space: nowrap;
343
+ overflow: hidden;
344
+ text-overflow: ellipsis;
345
+ font-weight: 500;
346
+ line-height: 1.2;
347
+ }
348
+
349
+ .file-size {
350
+ font-size: 0.75rem;
351
+ color: rgba(var(--primary-rgb), 0.7);
352
+ margin-top: 2px;
353
+ font-weight: 400;
354
+ }
339
355
  }
340
356
  }
341
357
 
@@ -364,26 +380,25 @@
364
380
  }
365
381
 
366
382
  .sortlist {
367
- display: grid;
368
- grid-template-columns: repeat(4, 1fr); /* 4 columns by default */
369
- gap: 16px;
383
+ display: grid !important;
384
+ grid-template-columns: repeat(4, 1fr) !important; /* 4 columns by default */
385
+ gap: 16px !important;
370
386
  padding: 16px 0;
387
+ margin: 0;
371
388
  list-style: none;
389
+ box-sizing: border-box;
372
390
 
373
391
  /* Responsive grid layout */
374
392
  @media (max-width: 1200px) {
375
- grid-template-columns: repeat(3, 1fr); /* 3 columns on medium screens */
393
+ grid-template-columns: repeat(3, 1fr) !important; /* 3 columns on medium screens */
376
394
  }
377
395
 
378
396
  @media (max-width: 768px) {
379
- grid-template-columns: repeat(2, 1fr); /* 2 columns on small screens */
397
+ grid-template-columns: repeat(2, 1fr) !important; /* 2 columns on small screens */
380
398
  }
381
399
 
382
400
  @media (max-width: 480px) {
383
- grid-template-columns: repeat(
384
- 1,
385
- 1fr
386
- ); /* 1 column on very small screens */
401
+ grid-template-columns: repeat(1, 1fr) !important; /* 1 column on very small screens */
387
402
  }
388
403
 
389
404
  li {
@@ -413,6 +428,12 @@
413
428
  width: 100%;
414
429
  height: 100%;
415
430
  object-fit: cover;
431
+ cursor: pointer;
432
+ transition: opacity 0.2s ease;
433
+
434
+ &:hover {
435
+ opacity: 0.9;
436
+ }
416
437
  }
417
438
 
418
439
  .delete {
@@ -819,3 +840,4 @@
819
840
  overflow-x: auto;
820
841
  white-space: pre-wrap;
821
842
  }
843
+