@visns-studio/visns-components 5.15.8 → 5.15.10

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.
@@ -12,8 +12,6 @@ import {
12
12
  Upload,
13
13
  Save,
14
14
  FolderOpen,
15
- Eye,
16
- EyeOff,
17
15
  Download,
18
16
  FileImage,
19
17
  Lightbulb,
@@ -33,22 +31,28 @@ import {
33
31
  } from './data/verkadaCameras';
34
32
  import CameraIcon from './utils/CameraIcon';
35
33
 
36
- const CameraPlacement = () => {
34
+ const CameraPlacement = ({ userProfile }) => {
37
35
  const [cameras, setCameras] = useState([]);
38
36
  const [selectedCamera, setSelectedCamera] = useState(null);
39
37
  const [showModal, setShowModal] = useState(false);
40
38
  const [map, setMap] = useState(null);
41
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
42
39
  const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
43
40
  const [uploadedImage, setUploadedImage] = useState(null);
41
+ const [projectName, setProjectName] = useState('');
42
+ const [sidebarTab, setSidebarTab] = useState('cameras'); // 'cameras' or 'projects'
43
+ const [savedProjects, setSavedProjects] = useState([]);
44
+ const [selectedProject, setSelectedProject] = useState(null);
45
+ const [loadingProjects, setLoadingProjects] = useState(false);
44
46
  const mapContainer = useRef(null);
45
47
  const mapRef = useRef(null);
46
48
  const geocoderRef = useRef(null);
47
49
  const imageContainer = useRef(null);
48
50
  const fileInputRef = useRef(null);
51
+ const jsonFileInputRef = useRef(null);
49
52
  const [isDragOver, setIsDragOver] = useState(false);
50
53
  const [isRotating, setIsRotating] = useState(false);
51
54
 
55
+
52
56
  // Mapbox configuration
53
57
  const MAPBOX_ACCESS_TOKEN =
54
58
  'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
@@ -69,7 +73,6 @@ const CameraPlacement = () => {
69
73
  try {
70
74
  map.remove();
71
75
  } catch (error) {
72
- console.warn('Error removing map:', error);
73
76
  }
74
77
  setMap(null);
75
78
  }
@@ -166,7 +169,6 @@ const CameraPlacement = () => {
166
169
  try {
167
170
  map.remove();
168
171
  } catch (error) {
169
- console.warn('Error removing map:', error);
170
172
  }
171
173
  setMap(null);
172
174
  }
@@ -185,6 +187,282 @@ const CameraPlacement = () => {
185
187
  [map]
186
188
  );
187
189
 
190
+ // Handle legacy JSON file upload
191
+ const handleJsonFileUpload = useCallback(async (event) => {
192
+ const file = event.target.files[0];
193
+ if (!file) return;
194
+
195
+ if (!file.name.toLowerCase().endsWith('.json')) {
196
+ Swal.fire({
197
+ icon: 'error',
198
+ title: 'Invalid File Type',
199
+ text: 'Please select a JSON file (.json extension)',
200
+ confirmButtonColor: '#ef4444',
201
+ });
202
+ return;
203
+ }
204
+
205
+ try {
206
+ const text = await file.text();
207
+ const jsonData = JSON.parse(text);
208
+
209
+ // Show loading indicator
210
+ Swal.fire({
211
+ title: 'Processing Legacy Data...',
212
+ text: 'Converting and validating JSON data',
213
+ allowOutsideClick: false,
214
+ showConfirmButton: false,
215
+ didOpen: () => {
216
+ Swal.showLoading();
217
+ }
218
+ });
219
+
220
+ // Process and validate the JSON data
221
+ const result = await processLegacyJsonData(jsonData);
222
+
223
+ Swal.close();
224
+
225
+ if (result.success) {
226
+ // Load the processed data
227
+ setProjectName(result.data.projectName);
228
+ setMode(result.data.mode);
229
+ setCameras(result.data.cameras);
230
+
231
+ if (result.data.uploadedImage) {
232
+ setUploadedImage(result.data.uploadedImage);
233
+ }
234
+
235
+ // Clear the selected project since this is new legacy data
236
+ setSelectedProject(null);
237
+ setSidebarTab('cameras');
238
+
239
+ Swal.fire({
240
+ icon: 'success',
241
+ title: 'Legacy Data Loaded!',
242
+ html: `
243
+ <div style="text-align: left;">
244
+ <p><strong>Successfully imported:</strong></p>
245
+ <ul style="margin: 10px 0; padding-left: 20px;">
246
+ <li>Project: "${result.data.projectName}"</li>
247
+ <li>Cameras: ${result.data.cameras.length}</li>
248
+ <li>Mode: ${result.data.mode}</li>
249
+ ${result.data.uploadedImage ? '<li>Image: Yes</li>' : '<li>Image: No</li>'}
250
+ </ul>
251
+ <p style="color: #6b7280; font-size: 14px; margin-top: 15px;">
252
+ <strong>Next steps:</strong> Review the imported data and save it to the database using the Save button.
253
+ </p>
254
+ </div>
255
+ `,
256
+ confirmButtonColor: '#3b82f6',
257
+ confirmButtonText: 'Continue',
258
+ });
259
+ } else {
260
+ throw new Error(result.error);
261
+ }
262
+ } catch (error) {
263
+ Swal.fire({
264
+ icon: 'error',
265
+ title: 'Import Failed',
266
+ html: `
267
+ <div style="text-align: left;">
268
+ <p>Failed to import legacy JSON file:</p>
269
+ <div style="background: #f3f4f6; padding: 10px; border-radius: 4px; margin: 10px 0; font-family: monospace; font-size: 12px; max-height: 150px; overflow-y: auto;">
270
+ ${error.message}
271
+ </div>
272
+ <p style="color: #6b7280; font-size: 14px;">
273
+ Please check that the JSON file contains valid camera placement data.
274
+ </p>
275
+ </div>
276
+ `,
277
+ confirmButtonColor: '#ef4444',
278
+ });
279
+ }
280
+
281
+ // Clear the file input
282
+ event.target.value = '';
283
+ }, []);
284
+
285
+ // Process legacy JSON data and convert to current format
286
+ const processLegacyJsonData = useCallback(async (jsonData) => {
287
+ try {
288
+ // Validate basic structure
289
+ if (!jsonData || typeof jsonData !== 'object') {
290
+ throw new Error('Invalid JSON structure - expected an object');
291
+ }
292
+
293
+ let cameras = [];
294
+ let projectName = '';
295
+ let mode = 'image';
296
+ let uploadedImage = null;
297
+
298
+ // Handle different legacy JSON formats
299
+
300
+ // Format 1: Direct camera placement export with cameras array
301
+ if (jsonData.cameras && Array.isArray(jsonData.cameras)) {
302
+ cameras = jsonData.cameras.map((camera, index) => {
303
+ // Ensure required fields exist with fallbacks
304
+ const id = camera.id || Date.now() + index;
305
+ const name = camera.name || camera.customName || `Camera ${index + 1}`;
306
+ const x = typeof camera.x === 'number' ? camera.x : (camera.position?.x || 0);
307
+ const y = typeof camera.y === 'number' ? camera.y : (camera.position?.y || 0);
308
+ const direction = typeof camera.direction === 'number' ? camera.direction : (camera.rotation || 0);
309
+
310
+ return {
311
+ id: id,
312
+ name: name,
313
+ customName: camera.customName || '',
314
+ x: x,
315
+ y: y,
316
+ direction: direction,
317
+ verkadaModel: camera.verkadaModel || camera.model || null,
318
+ timestamp: camera.timestamp || new Date().toISOString(),
319
+ };
320
+ });
321
+
322
+ projectName = jsonData.projectName || jsonData.name || jsonData.title || 'Imported Legacy Project';
323
+ mode = jsonData.mode || 'image';
324
+
325
+ // Handle different image data formats - extract src string directly
326
+ if (jsonData.image && jsonData.image.src) {
327
+ // Format: { "image": { "src": "data:image/png;base64,..." } }
328
+ uploadedImage = jsonData.image.src;
329
+ } else if (jsonData.uploadedImage && jsonData.uploadedImage.src) {
330
+ // Format: { "uploadedImage": { "src": "...", "name": "..." } }
331
+ uploadedImage = jsonData.uploadedImage.src;
332
+ } else if (jsonData.uploadedImage && typeof jsonData.uploadedImage === 'string') {
333
+ // Format: { "uploadedImage": "data:image/..." }
334
+ uploadedImage = jsonData.uploadedImage;
335
+ } else if (jsonData.backgroundImage) {
336
+ // Format: { "backgroundImage": "data:image/..." }
337
+ uploadedImage = jsonData.backgroundImage;
338
+ } else {
339
+ uploadedImage = null;
340
+ }
341
+ }
342
+
343
+ // Format 2: Legacy format with different structure
344
+ else if (jsonData.placement || jsonData.cameraPlacement) {
345
+ const placement = jsonData.placement || jsonData.cameraPlacement;
346
+
347
+ if (placement.cameras && Array.isArray(placement.cameras)) {
348
+ cameras = placement.cameras.map((camera, index) => ({
349
+ id: camera.id || Date.now() + index,
350
+ name: camera.name || `Camera ${index + 1}`,
351
+ customName: camera.customName || '',
352
+ x: camera.x || 0,
353
+ y: camera.y || 0,
354
+ direction: camera.direction || 0,
355
+ verkadaModel: camera.verkadaModel || null,
356
+ timestamp: new Date().toISOString(),
357
+ }));
358
+ }
359
+
360
+ projectName = placement.projectName || placement.name || 'Imported Legacy Project';
361
+ mode = placement.mode || 'image';
362
+
363
+ // Handle different image data formats - extract src string directly
364
+ if (placement.image && placement.image.src) {
365
+ uploadedImage = placement.image.src;
366
+ } else if (placement.image && typeof placement.image === 'string') {
367
+ uploadedImage = placement.image;
368
+ } else if (placement.backgroundImage) {
369
+ uploadedImage = placement.backgroundImage;
370
+ } else {
371
+ uploadedImage = null;
372
+ }
373
+ }
374
+
375
+ // Format 3: Simple array of cameras
376
+ else if (Array.isArray(jsonData)) {
377
+ cameras = jsonData.map((camera, index) => ({
378
+ id: camera.id || Date.now() + index,
379
+ name: camera.name || `Camera ${index + 1}`,
380
+ customName: camera.customName || '',
381
+ x: camera.x || 0,
382
+ y: camera.y || 0,
383
+ direction: camera.direction || 0,
384
+ verkadaModel: camera.verkadaModel || null,
385
+ timestamp: new Date().toISOString(),
386
+ }));
387
+
388
+ projectName = 'Imported Legacy Project';
389
+ mode = 'image';
390
+ }
391
+
392
+ // Format 4: Check for other common legacy formats
393
+ else {
394
+ // Look for any array that might contain camera data
395
+ const possibleCameraArrays = Object.values(jsonData).filter(value =>
396
+ Array.isArray(value) && value.length > 0 &&
397
+ value[0] && typeof value[0] === 'object'
398
+ );
399
+
400
+ if (possibleCameraArrays.length > 0) {
401
+ const cameraArray = possibleCameraArrays[0];
402
+ cameras = cameraArray.map((camera, index) => ({
403
+ id: camera.id || Date.now() + index,
404
+ name: camera.name || `Camera ${index + 1}`,
405
+ customName: camera.customName || '',
406
+ x: camera.x || 0,
407
+ y: camera.y || 0,
408
+ direction: camera.direction || 0,
409
+ verkadaModel: camera.verkadaModel || null,
410
+ timestamp: new Date().toISOString(),
411
+ }));
412
+
413
+ projectName = jsonData.name || jsonData.title || 'Imported Legacy Project';
414
+ mode = jsonData.mode || 'image';
415
+
416
+ // Handle different image data formats - extract src string directly
417
+ if (jsonData.image && jsonData.image.src) {
418
+ uploadedImage = jsonData.image.src;
419
+ } else if (jsonData.image && typeof jsonData.image === 'string') {
420
+ uploadedImage = jsonData.image;
421
+ } else if (jsonData.backgroundImage) {
422
+ uploadedImage = jsonData.backgroundImage;
423
+ } else {
424
+ uploadedImage = null;
425
+ }
426
+ } else {
427
+ throw new Error('No valid camera data found in JSON file. Expected arrays of camera objects.');
428
+ }
429
+ }
430
+
431
+ // Validate that we have at least some cameras
432
+ if (!cameras || cameras.length === 0) {
433
+ throw new Error('No cameras found in the JSON file');
434
+ }
435
+
436
+ // Validate camera data
437
+ const invalidCameras = cameras.filter(camera =>
438
+ typeof camera.x !== 'number' ||
439
+ typeof camera.y !== 'number' ||
440
+ isNaN(camera.x) ||
441
+ isNaN(camera.y)
442
+ );
443
+
444
+ if (invalidCameras.length > 0) {
445
+ throw new Error(`${invalidCameras.length} cameras have invalid position data (x, y coordinates must be numbers)`);
446
+ }
447
+
448
+ return {
449
+ success: true,
450
+ data: {
451
+ projectName: projectName,
452
+ mode: mode,
453
+ cameras: cameras,
454
+ uploadedImage: uploadedImage
455
+ }
456
+ };
457
+
458
+ } catch (error) {
459
+ return {
460
+ success: false,
461
+ error: error.message
462
+ };
463
+ }
464
+ }, []);
465
+
188
466
  // Initialize Mapbox GL map
189
467
  useEffect(() => {
190
468
  if (!mapContainer.current || mode !== 'map') return;
@@ -335,8 +613,43 @@ const CameraPlacement = () => {
335
613
  [map, cameras]
336
614
  );
337
615
 
338
- // Save placement as JSON file
339
- const saveAsFile = useCallback(() => {
616
+ // Fetch saved projects (lightweight - no heavy JSON fields)
617
+ const fetchSavedProjects = useCallback(async () => {
618
+ if (!userProfile?.id) return;
619
+
620
+ setLoadingProjects(true);
621
+ try {
622
+ const response = await fetch('/ajax/cameraPlacement/table', {
623
+ method: 'POST',
624
+ headers: {
625
+ 'Content-Type': 'application/json',
626
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
627
+ },
628
+ body: JSON.stringify({
629
+ page: 1,
630
+ per_page: 100,
631
+ order_by: 'created_at',
632
+ order: 'desc'
633
+ }),
634
+ });
635
+
636
+ const responseText = await response.text();
637
+ const result = JSON.parse(responseText);
638
+
639
+ if (result.data && Array.isArray(result.data)) {
640
+ setSavedProjects(result.data);
641
+ } else if (result.data && Array.isArray(result.data.data)) {
642
+ setSavedProjects(result.data.data);
643
+ }
644
+ } catch (error) {
645
+ } finally {
646
+ setLoadingProjects(false);
647
+ }
648
+ }, [userProfile]);
649
+
650
+ // Save placement to database
651
+ const saveToDatabase = useCallback(async () => {
652
+
340
653
  if (!cameras.length && !uploadedImage) {
341
654
  Swal.fire({
342
655
  icon: 'warning',
@@ -347,115 +660,367 @@ const CameraPlacement = () => {
347
660
  return;
348
661
  }
349
662
 
350
- const saveData = {
351
- version: '1.0',
352
- timestamp: new Date().toISOString(),
353
- image: uploadedImage
354
- ? {
355
- src: uploadedImage,
356
- name: 'uploaded-image',
357
- }
358
- : null,
359
- mode: mode,
360
- cameras: cameras.map((camera) => ({
361
- id: camera.id,
362
- name: camera.name,
363
- customName: camera.customName,
364
- x: camera.x,
365
- y: camera.y,
366
- direction: camera.direction,
367
- verkadaModel: camera.verkadaModel,
368
- timestamp: camera.timestamp,
369
- })),
370
- };
663
+ // Enhanced camera data validation
664
+ if (cameras.length === 0) {
665
+ Swal.fire({
666
+ icon: 'warning',
667
+ title: 'No Cameras to Save',
668
+ text: 'Please place at least one camera before saving. Click on the image to add cameras.',
669
+ confirmButtonColor: '#f59e0b',
670
+ });
671
+ return;
672
+ }
371
673
 
372
- const dataStr = JSON.stringify(saveData, null, 2);
373
- const dataUri =
374
- 'data:application/json;charset=utf-8,' +
375
- encodeURIComponent(dataStr);
376
-
377
- const exportFileDefaultName = `camera-placement-${
378
- new Date().toISOString().split('T')[0]
379
- }.json`;
380
-
381
- const linkElement = document.createElement('a');
382
- linkElement.setAttribute('href', dataUri);
383
- linkElement.setAttribute('download', exportFileDefaultName);
384
- linkElement.click();
385
-
386
- // Show success message
387
- Swal.fire({
388
- icon: 'success',
389
- title: 'File Saved!',
390
- text: `Camera placement saved as ${exportFileDefaultName}`,
391
- timer: 3000,
392
- showConfirmButton: false,
393
- toast: true,
394
- position: 'top-end',
674
+ // Validate each camera has required data
675
+ const invalidCameras = cameras.filter(camera => {
676
+ const issues = [];
677
+
678
+ if (!camera.id) issues.push('missing ID');
679
+ if (typeof camera.x !== 'number' || isNaN(camera.x)) issues.push('invalid X coordinate');
680
+ if (typeof camera.y !== 'number' || isNaN(camera.y)) issues.push('invalid Y coordinate');
681
+ if (!camera.name || camera.name.trim() === '') issues.push('missing name');
682
+ if (typeof camera.direction !== 'number' || isNaN(camera.direction)) issues.push('invalid direction');
683
+
684
+ return issues.length > 0;
395
685
  });
396
- }, [cameras, uploadedImage, mode]);
397
686
 
398
- // Load placement from JSON file
399
- const loadFromFile = useCallback(() => {
400
- const input = document.createElement('input');
401
- input.type = 'file';
402
- input.accept = '.json';
687
+ if (invalidCameras.length > 0) {
688
+ Swal.fire({
689
+ icon: 'error',
690
+ title: 'Invalid Camera Data',
691
+ text: `${invalidCameras.length} camera(s) have invalid data. Please check all cameras have proper positioning, names, and direction values.`,
692
+ confirmButtonColor: '#ef4444',
693
+ });
694
+ return;
695
+ }
403
696
 
404
- input.onchange = (event) => {
405
- const file = event.target.files[0];
406
- if (!file) return;
697
+ // Validate cameras_data array will not be empty
698
+ const validCamerasData = cameras.map((camera) => ({
699
+ id: camera.id,
700
+ name: camera.name,
701
+ customName: camera.customName,
702
+ x: camera.x,
703
+ y: camera.y,
704
+ direction: camera.direction,
705
+ verkadaModel: camera.verkadaModel,
706
+ timestamp: camera.timestamp,
707
+ })).filter(camera => camera.id && camera.name);
708
+
709
+ if (validCamerasData.length === 0) {
710
+ Swal.fire({
711
+ icon: 'error',
712
+ title: 'No Valid Camera Data',
713
+ text: 'All cameras seem to have missing or invalid data. Please recreate your cameras.',
714
+ confirmButtonColor: '#ef4444',
715
+ });
716
+ return;
717
+ }
407
718
 
408
- const reader = new FileReader();
409
- reader.onload = (e) => {
410
- try {
411
- const saveData = JSON.parse(e.target.result);
719
+ if (!projectName.trim()) {
720
+ Swal.fire({
721
+ icon: 'warning',
722
+ title: 'Project Name Required',
723
+ text: 'Please enter a project name before saving.',
724
+ confirmButtonColor: '#8b5cf6',
725
+ });
726
+ return;
727
+ }
412
728
 
413
- // Validate data structure
414
- if (!saveData.version || !Array.isArray(saveData.cameras)) {
415
- Swal.fire({
416
- icon: 'error',
417
- title: 'Invalid File',
418
- text: 'Please select a valid camera placement file.',
419
- confirmButtonColor: '#f59e0b',
420
- });
421
- return;
422
- }
729
+ // Validate user profile
730
+ if (!userProfile || !userProfile.id) {
731
+ Swal.fire({
732
+ icon: 'error',
733
+ title: 'Authentication Error',
734
+ text: 'User profile is required to save camera placements.',
735
+ confirmButtonColor: '#ef4444',
736
+ });
737
+ return;
738
+ }
423
739
 
424
- // Load image if present
425
- if (saveData.image && saveData.image.src) {
426
- setUploadedImage(saveData.image.src);
427
- }
740
+ // Validate mode
741
+ if (!mode || !['image', 'map'].includes(mode)) {
742
+ Swal.fire({
743
+ icon: 'error',
744
+ title: 'Invalid Mode',
745
+ text: 'Invalid placement mode. Please refresh the page and try again.',
746
+ confirmButtonColor: '#ef4444',
747
+ });
748
+ return;
749
+ }
428
750
 
429
- // Set mode
430
- if (saveData.mode) {
431
- setMode(saveData.mode);
432
- }
751
+ try {
752
+ // Check if we're updating an existing project or creating a new one
753
+ const isUpdating = selectedProject && selectedProject.id;
754
+ const method = isUpdating ? 'PUT' : 'POST';
755
+ const url = isUpdating ? `/ajax/cameraPlacement/${selectedProject.id}` : '/ajax/cameraPlacement';
756
+
757
+ const saveData = {
758
+ name: projectName.trim(),
759
+ project_name: projectName.trim(),
760
+ version: '1.0',
761
+ mode: mode,
762
+ user_id: userProfile.id,
763
+ image_data: uploadedImage
764
+ ? {
765
+ src: uploadedImage,
766
+ name: 'uploaded-image',
767
+ }
768
+ : null,
769
+ image_name: uploadedImage ? 'uploaded-image' : null,
770
+ cameras_data: validCamerasData,
771
+ };
433
772
 
434
- // Load cameras
435
- setCameras(saveData.cameras || []);
773
+ const response = await fetch(url, {
774
+ method: method,
775
+ headers: {
776
+ 'Content-Type': 'application/json',
777
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
778
+ },
779
+ body: JSON.stringify(saveData),
780
+ });
436
781
 
437
- Swal.fire({
438
- icon: 'success',
439
- title: 'File Loaded!',
440
- text: `Successfully loaded ${
441
- saveData.cameras?.length || 0
442
- } cameras from ${file.name}`,
443
- confirmButtonColor: '#f59e0b',
444
- });
445
- } catch (error) {
446
- console.error('Error loading file:', error);
447
- Swal.fire({
448
- icon: 'error',
449
- title: 'File Error',
450
- text: "Error reading file. Please ensure it's a valid JSON file.",
451
- confirmButtonColor: '#f59e0b',
452
- });
782
+ const responseText = await response.text();
783
+
784
+ let result;
785
+ try {
786
+ result = JSON.parse(responseText);
787
+ } catch (parseError) {
788
+ throw new Error(`Server returned invalid JSON. Status: ${response.status}, Response: ${responseText.substring(0, 200)}`);
789
+ }
790
+
791
+ // DynamicController returns { data: {...}, error: "" } format
792
+ if (result.data && !result.error) {
793
+ // Update the selectedProject with the latest data if we were updating
794
+ if (isUpdating) {
795
+ setSelectedProject(result.data);
453
796
  }
454
- };
455
- reader.readAsText(file);
456
- };
457
797
 
458
- input.click();
798
+ Swal.fire({
799
+ icon: 'success',
800
+ title: isUpdating ? 'Updated!' : 'Saved!',
801
+ text: `Camera placement "${projectName}" ${isUpdating ? 'updated' : 'saved'} successfully`,
802
+ timer: 3000,
803
+ showConfirmButton: false,
804
+ toast: true,
805
+ position: 'top-end',
806
+ });
807
+
808
+ // Refresh the projects list to show updated data
809
+ fetchSavedProjects();
810
+ } else {
811
+ const errorMessage = result.error || result.message || 'Save failed';
812
+ const errorDetails = result.errors ? JSON.stringify(result.errors) : '';
813
+ throw new Error(`${errorMessage}${errorDetails ? ` - Details: ${errorDetails}` : ''}`);
814
+ }
815
+ } catch (error) {
816
+ Swal.fire({
817
+ icon: 'error',
818
+ title: 'Save Failed',
819
+ text: error.message || 'Failed to save camera placement. Please try again.',
820
+ confirmButtonColor: '#ef4444',
821
+ });
822
+ }
823
+ }, [cameras, uploadedImage, mode, projectName, userProfile, selectedProject, fetchSavedProjects]);
824
+
825
+ // Fetch full project data with heavy JSON fields
826
+ const fetchFullProject = useCallback(async (projectId) => {
827
+ try {
828
+ const response = await fetch(`/ajax/cameraPlacement/${projectId}`, {
829
+ headers: {
830
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
831
+ },
832
+ });
833
+
834
+ const responseText = await response.text();
835
+ const result = JSON.parse(responseText);
836
+
837
+ if (result.id) {
838
+ // Direct object response (not wrapped in data)
839
+ return result;
840
+ } else if (result.data) {
841
+ // Wrapped in data object
842
+ return result.data;
843
+ } else {
844
+ throw new Error('Failed to fetch full project data');
845
+ }
846
+ } catch (error) {
847
+ throw error;
848
+ }
849
+ }, []);
850
+
851
+ // Load a specific project
852
+ const loadProject = useCallback(async (lightweightProject) => {
853
+ try {
854
+ // Show loading indicator
855
+ Swal.fire({
856
+ title: 'Loading Project...',
857
+ text: 'Fetching project data',
858
+ allowOutsideClick: false,
859
+ showConfirmButton: false,
860
+ didOpen: () => {
861
+ Swal.showLoading();
862
+ }
863
+ });
864
+
865
+ // Fetch full project data including heavy JSON fields
866
+ const fullProject = await fetchFullProject(lightweightProject.id);
867
+
868
+ // Close loading indicator
869
+ Swal.close();
870
+
871
+ setSelectedProject(fullProject);
872
+ setProjectName(fullProject.project_name);
873
+ setMode(fullProject.mode);
874
+
875
+ if (fullProject.image_data && fullProject.image_data.src) {
876
+ setUploadedImage(fullProject.image_data.src);
877
+ } else {
878
+ setUploadedImage(null);
879
+ }
880
+
881
+ setCameras(fullProject.cameras_data || []);
882
+ setSidebarTab('cameras'); // Switch to cameras tab
883
+
884
+ Swal.fire({
885
+ icon: 'success',
886
+ title: 'Project Loaded!',
887
+ text: `Loaded "${fullProject.project_name}" with ${fullProject.cameras_data?.length || 0} cameras`,
888
+ timer: 2000,
889
+ showConfirmButton: false,
890
+ toast: true,
891
+ position: 'top-end',
892
+ });
893
+ } catch (error) {
894
+ Swal.fire({
895
+ icon: 'error',
896
+ title: 'Load Failed',
897
+ text: 'Failed to load project. Please try again.',
898
+ confirmButtonColor: '#ef4444',
899
+ });
900
+ }
901
+ }, [fetchFullProject]);
902
+
903
+ // Fetch projects when user profile changes or sidebar switches to projects
904
+ useEffect(() => {
905
+ if (sidebarTab === 'projects' && userProfile?.id) {
906
+ fetchSavedProjects();
907
+ }
908
+ }, [sidebarTab, userProfile, fetchSavedProjects]);
909
+
910
+ // Initial fetch of projects when component mounts to get correct count
911
+ useEffect(() => {
912
+ if (userProfile?.id) {
913
+ fetchSavedProjects();
914
+ }
915
+ }, [userProfile, fetchSavedProjects]);
916
+
917
+ // Load placement from database (legacy - keeping for compatibility)
918
+ const loadFromDatabase = useCallback(async () => {
919
+ try {
920
+ const response = await fetch('/ajax/cameraPlacement/table', {
921
+ method: 'POST',
922
+ headers: {
923
+ 'Content-Type': 'application/json',
924
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
925
+ },
926
+ body: JSON.stringify({
927
+ page: 1,
928
+ per_page: 100,
929
+ order_by: 'created_at',
930
+ order: 'desc'
931
+ }),
932
+ });
933
+
934
+ const result = await response.json();
935
+
936
+ if (result.data && result.data.length > 0) {
937
+ const placements = result.data;
938
+
939
+ // Show placement selection modal
940
+ const { value: selectedPlacement } = await Swal.fire({
941
+ title: 'Load Camera Placement',
942
+ html: `
943
+ <div style="text-align: left; max-height: 400px; overflow-y: auto;">
944
+ ${placements.map(placement => `
945
+ <div style="padding: 12px; margin: 8px 0; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;"
946
+ onmouseover="this.style.backgroundColor='#f9fafb'"
947
+ onmouseout="this.style.backgroundColor='white'"
948
+ onclick="this.classList.toggle('selected'); document.querySelectorAll('.placement-item').forEach(el => el !== this && el.classList.remove('selected'));"
949
+ class="placement-item"
950
+ data-placement-id="${placement.id}">
951
+ <div style="font-weight: 600; color: #1f2937;">${placement.project_name}</div>
952
+ <div style="font-size: 14px; color: #6b7280; margin-top: 4px;">
953
+ ${placement.cameras_count} cameras • ${placement.mode} mode
954
+ </div>
955
+ <div style="font-size: 12px; color: #9ca3af; margin-top: 4px;">
956
+ Created: ${new Date(placement.created_at).toLocaleDateString()}
957
+ </div>
958
+ </div>
959
+ `).join('')}
960
+ </div>
961
+ <style>
962
+ .placement-item.selected {
963
+ background-color: #dbeafe !important;
964
+ border-color: #3b82f6 !important;
965
+ }
966
+ </style>
967
+ `,
968
+ showCancelButton: true,
969
+ confirmButtonText: 'Load Selected',
970
+ cancelButtonText: 'Cancel',
971
+ confirmButtonColor: '#3b82f6',
972
+ preConfirm: () => {
973
+ const selectedElement = document.querySelector('.placement-item.selected');
974
+ if (!selectedElement) {
975
+ Swal.showValidationMessage('Please select a placement to load');
976
+ return false;
977
+ }
978
+ return selectedElement.getAttribute('data-placement-id');
979
+ }
980
+ });
981
+
982
+ if (selectedPlacement) {
983
+ const placement = placements.find(p => p.id == selectedPlacement);
984
+ if (placement) {
985
+ // Load the placement data
986
+ setProjectName(placement.project_name);
987
+ setMode(placement.mode);
988
+
989
+ if (placement.image_data && placement.image_data.src) {
990
+ setUploadedImage(placement.image_data.src);
991
+ } else {
992
+ setUploadedImage(null);
993
+ }
994
+
995
+ setCameras(placement.cameras_data || []);
996
+
997
+ Swal.fire({
998
+ icon: 'success',
999
+ title: 'Loaded!',
1000
+ text: `Successfully loaded "${placement.project_name}" with ${placement.cameras_count} cameras`,
1001
+ timer: 3000,
1002
+ showConfirmButton: false,
1003
+ toast: true,
1004
+ position: 'top-end',
1005
+ });
1006
+ }
1007
+ }
1008
+ } else {
1009
+ Swal.fire({
1010
+ icon: 'info',
1011
+ title: 'No Saved Placements',
1012
+ text: 'You have no saved camera placements yet.',
1013
+ confirmButtonColor: '#3b82f6',
1014
+ });
1015
+ }
1016
+ } catch (error) {
1017
+ Swal.fire({
1018
+ icon: 'error',
1019
+ title: 'Load Failed',
1020
+ text: 'Failed to load camera placements. Please try again.',
1021
+ confirmButtonColor: '#ef4444',
1022
+ });
1023
+ }
459
1024
  }, []);
460
1025
 
461
1026
  const exportToPDF = useCallback(async () => {
@@ -501,7 +1066,6 @@ const CameraPlacement = () => {
501
1066
  });
502
1067
  mapImageData = canvas.toDataURL('image/png', 0.9);
503
1068
  } catch (error) {
504
- console.warn('Could not capture site map image:', error);
505
1069
  // Continue without image if capture fails
506
1070
  }
507
1071
  }
@@ -520,7 +1084,6 @@ const CameraPlacement = () => {
520
1084
  })
521
1085
  ).toBlob();
522
1086
  } catch (pdfError) {
523
- console.warn('Enhanced PDF failed, trying simple version:', pdfError);
524
1087
 
525
1088
  // Fallback to simple PDF
526
1089
  pdfBlob = await pdf(
@@ -586,7 +1149,6 @@ const CameraPlacement = () => {
586
1149
  confirmButtonText: 'Great!',
587
1150
  });
588
1151
  } catch (error) {
589
- console.error('Error generating PDF:', error);
590
1152
 
591
1153
  // More specific error handling
592
1154
  let errorMessage = 'Unable to generate PDF report. Please try again.';
@@ -631,10 +1193,6 @@ const CameraPlacement = () => {
631
1193
  );
632
1194
  }
633
1195
  } catch (error) {
634
- console.warn(
635
- 'Could not access map style, skipping FOV layer cleanup:',
636
- error
637
- );
638
1196
  }
639
1197
  existingSources.forEach((sourceId) => {
640
1198
  try {
@@ -646,10 +1204,6 @@ const CameraPlacement = () => {
646
1204
  map.removeSource(sourceId);
647
1205
  }
648
1206
  } catch (error) {
649
- console.warn(
650
- `Could not remove layer/source ${sourceId}:`,
651
- error
652
- );
653
1207
  }
654
1208
  });
655
1209
 
@@ -899,35 +1453,36 @@ const CameraPlacement = () => {
899
1453
  <div className={styles.cameraPlacement}>
900
1454
  <div className={styles.header}>
901
1455
  <div className={styles.headerContent}>
902
- <h1 className={styles.title}>Site Layout Planner</h1>
1456
+ <div className={styles.titleSection}>
1457
+ <h1 className={styles.title}>Site Map Planner</h1>
1458
+ </div>
903
1459
  <div className={styles.controls}>
1460
+ <input
1461
+ type="text"
1462
+ placeholder="Project name..."
1463
+ value={projectName}
1464
+ onChange={(e) => setProjectName(e.target.value)}
1465
+ className={styles.projectInput}
1466
+ maxLength={100}
1467
+ />
904
1468
  <IconButton
905
1469
  icon={Upload}
906
1470
  onClick={() => fileInputRef.current?.click()}
907
1471
  tooltip="Upload Image"
908
1472
  className={styles.uploadBtn}
909
1473
  />
910
- <IconButton
911
- icon={Save}
912
- onClick={saveAsFile}
913
- disabled={!cameras.length && !uploadedImage}
914
- tooltip="Save camera placement as JSON file"
915
- className={styles.saveFileBtn}
916
- />
917
1474
  <IconButton
918
1475
  icon={FolderOpen}
919
- onClick={loadFromFile}
920
- tooltip="Load camera placement from JSON file"
921
- className={styles.loadFileBtn}
1476
+ onClick={() => jsonFileInputRef.current?.click()}
1477
+ tooltip="Import Legacy JSON File"
1478
+ className={styles.importBtn}
922
1479
  />
923
1480
  <IconButton
924
- icon={sidebarCollapsed ? Eye : EyeOff}
925
- onClick={() =>
926
- setSidebarCollapsed(!sidebarCollapsed)
927
- }
928
- tooltip={`${
929
- sidebarCollapsed ? 'Show' : 'Hide'
930
- } Cameras`}
1481
+ icon={Save}
1482
+ onClick={saveToDatabase}
1483
+ disabled={!cameras.length && !uploadedImage}
1484
+ tooltip="Save camera placement to database"
1485
+ className={styles.saveFileBtn}
931
1486
  />
932
1487
  <IconButton
933
1488
  icon={Download}
@@ -944,9 +1499,7 @@ const CameraPlacement = () => {
944
1499
  <div
945
1500
  className={`${styles.imageContainer} ${
946
1501
  uploadedImage ? styles.addMode : ''
947
- } ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
948
- isDragOver ? styles.dragOver : ''
949
- }`}
1502
+ } ${isDragOver ? styles.dragOver : ''}`}
950
1503
  ref={imageContainer}
951
1504
  onClick={handleImageClick}
952
1505
  onDragOver={handleDragOver}
@@ -1386,10 +1939,6 @@ const CameraPlacement = () => {
1386
1939
  rotationHandle.style
1387
1940
  ) {
1388
1941
  rotationHandle.style.transform = `rotate(${newRotation}deg)`;
1389
- } else {
1390
- console.error(
1391
- 'Rotation handle is null or has no style property'
1392
- );
1393
1942
  }
1394
1943
 
1395
1944
  // Update camera direction
@@ -1468,56 +2017,51 @@ const CameraPlacement = () => {
1468
2017
  </div>
1469
2018
  )}
1470
2019
  </div>
1471
- </div>
1472
-
1473
- {/* Hidden file input */}
1474
- <input
1475
- type="file"
1476
- ref={fileInputRef}
1477
- onChange={handleImageUpload}
1478
- accept="image/*"
1479
- style={{ display: 'none' }}
1480
- />
1481
2020
 
1482
- <div
1483
- className={`${styles.sidebar} ${
1484
- sidebarCollapsed ? styles.collapsed : ''
1485
- }`}
1486
- >
2021
+ <div className={styles.sidebar}>
1487
2022
  <div className={styles.sidebarHeader}>
1488
- <h3 className={styles.sidebarTitle}>Camera List</h3>
1489
- <p className={styles.cameraCount}>
1490
- {cameras.length} camera
1491
- {cameras.length !== 1 ? 's' : ''} placed
1492
- </p>
1493
- </div>
1494
-
1495
- <div className={styles.camerasList}>
1496
- {cameras.length === 0 ? (
1497
- <div
1498
- style={{
1499
- textAlign: 'center',
1500
- color: '#6c757d',
1501
- padding: '40px 20px',
1502
- fontStyle: 'italic',
1503
- }}
2023
+ <div className={styles.sidebarTabs}>
2024
+ <button
2025
+ className={`${styles.tabButton} ${sidebarTab === 'cameras' ? styles.active : ''}`}
2026
+ onClick={() => setSidebarTab('cameras')}
1504
2027
  >
1505
- <div>
1506
- Upload an image first, then click anywhere on it
1507
- to place cameras
1508
- </div>
1509
- <div
1510
- style={{
1511
- fontSize: '12px',
1512
- marginTop: '8px',
1513
- }}
1514
- >
1515
- Once placed, you can drag cameras to reposition
1516
- them and use the rotation handle to adjust
1517
- direction
1518
- </div>
2028
+ Cameras
2029
+ <span className={styles.tabCount}>({cameras.length})</span>
2030
+ </button>
2031
+ <button
2032
+ className={`${styles.tabButton} ${sidebarTab === 'projects' ? styles.active : ''}`}
2033
+ onClick={() => setSidebarTab('projects')}
2034
+ >
2035
+ Projects
2036
+ <span className={styles.tabCount}>({savedProjects.length})</span>
2037
+ </button>
2038
+ </div>
2039
+ {selectedProject && sidebarTab === 'cameras' && (
2040
+ <div className={styles.currentProject}>
2041
+ <span className={styles.projectLabel}>Current:</span>
2042
+ <span className={styles.projectName}>{selectedProject.project_name}</span>
1519
2043
  </div>
1520
- ) : (
2044
+ )}
2045
+ </div>
2046
+
2047
+ <div className={styles.sidebarContent}>
2048
+ {sidebarTab === 'cameras' ? (
2049
+ <div className={styles.camerasList}>
2050
+ {cameras.length === 0 ? (
2051
+ <div className={styles.emptyState}>
2052
+ <div>
2053
+ {selectedProject
2054
+ ? `No cameras in "${selectedProject.project_name}"`
2055
+ : "Upload an image first, then click anywhere on it to place cameras"
2056
+ }
2057
+ </div>
2058
+ <div className={styles.emptyStateSubtext}>
2059
+ Once placed, you can drag cameras to reposition
2060
+ them and use the rotation handle to adjust
2061
+ direction
2062
+ </div>
2063
+ </div>
2064
+ ) : (
1521
2065
  cameras.map((camera) => {
1522
2066
  const verkadaCamera = camera.verkadaModel
1523
2067
  ? getCameraById(camera.verkadaModel)
@@ -1969,9 +2513,80 @@ const CameraPlacement = () => {
1969
2513
  </div>
1970
2514
  );
1971
2515
  })
2516
+ )}
2517
+ </div>
2518
+ ) : (
2519
+ <div className={styles.projectsList}>
2520
+ {loadingProjects ? (
2521
+ <div className={styles.loadingState}>
2522
+ <div>Loading projects...</div>
2523
+ </div>
2524
+ ) : savedProjects.length === 0 ? (
2525
+ <div className={styles.emptyState}>
2526
+ <div>No saved projects found</div>
2527
+ <div className={styles.emptyStateSubtext}>
2528
+ Create and save your first camera placement project
2529
+ </div>
2530
+ </div>
2531
+ ) : (
2532
+ savedProjects.map((project) => (
2533
+ <div
2534
+ key={project.id}
2535
+ className={`${styles.projectItem} ${selectedProject?.id === project.id ? styles.selected : ''}`}
2536
+ onClick={() => loadProject(project)}
2537
+ >
2538
+ <div className={styles.projectHeader}>
2539
+ <h4 className={styles.projectTitle}>
2540
+ {project.project_name}
2541
+ </h4>
2542
+ <div className={styles.projectMeta}>
2543
+ <span className={styles.projectMode}>
2544
+ {project.mode}
2545
+ </span>
2546
+ <span className={styles.projectDate}>
2547
+ {new Date(project.created_at).toLocaleDateString()}
2548
+ </span>
2549
+ </div>
2550
+ </div>
2551
+ <div className={styles.projectInfo}>
2552
+ <span className={styles.cameraCount}>
2553
+ {project.cameras_count || 0} cameras
2554
+ </span>
2555
+ {project.image_data && (
2556
+ <span className={styles.hasImage}>
2557
+ 📷 Image
2558
+ </span>
2559
+ )}
2560
+ </div>
2561
+ {project.description && (
2562
+ <div className={styles.projectDescription}>
2563
+ {project.description}
2564
+ </div>
2565
+ )}
2566
+ </div>
2567
+ ))
2568
+ )}
2569
+ </div>
1972
2570
  )}
1973
2571
  </div>
1974
2572
  </div>
2573
+ </div>
2574
+
2575
+ {/* Hidden file inputs */}
2576
+ <input
2577
+ type="file"
2578
+ ref={fileInputRef}
2579
+ onChange={handleImageUpload}
2580
+ accept="image/*"
2581
+ style={{ display: 'none' }}
2582
+ />
2583
+ <input
2584
+ type="file"
2585
+ ref={jsonFileInputRef}
2586
+ onChange={handleJsonFileUpload}
2587
+ accept=".json,application/json"
2588
+ style={{ display: 'none' }}
2589
+ />
1975
2590
 
1976
2591
  {showModal && selectedCamera && (
1977
2592
  <CameraSelectionModal