@visns-studio/visns-components 5.15.8 → 5.15.9

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
@@ -94,7 +94,7 @@
94
94
  "react-dom": "^17.0.0 || ^18.0.0"
95
95
  },
96
96
  "name": "@visns-studio/visns-components",
97
- "version": "5.15.8",
97
+ "version": "5.15.9",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -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,14 +31,18 @@ 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);
@@ -49,6 +51,7 @@ const CameraPlacement = () => {
49
51
  const [isDragOver, setIsDragOver] = useState(false);
50
52
  const [isRotating, setIsRotating] = useState(false);
51
53
 
54
+
52
55
  // Mapbox configuration
53
56
  const MAPBOX_ACCESS_TOKEN =
54
57
  'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
@@ -69,7 +72,6 @@ const CameraPlacement = () => {
69
72
  try {
70
73
  map.remove();
71
74
  } catch (error) {
72
- console.warn('Error removing map:', error);
73
75
  }
74
76
  setMap(null);
75
77
  }
@@ -166,7 +168,6 @@ const CameraPlacement = () => {
166
168
  try {
167
169
  map.remove();
168
170
  } catch (error) {
169
- console.warn('Error removing map:', error);
170
171
  }
171
172
  setMap(null);
172
173
  }
@@ -335,8 +336,43 @@ const CameraPlacement = () => {
335
336
  [map, cameras]
336
337
  );
337
338
 
338
- // Save placement as JSON file
339
- const saveAsFile = useCallback(() => {
339
+ // Fetch saved projects (lightweight - no heavy JSON fields)
340
+ const fetchSavedProjects = useCallback(async () => {
341
+ if (!userProfile?.id) return;
342
+
343
+ setLoadingProjects(true);
344
+ try {
345
+ const response = await fetch('/ajax/cameraPlacement/table', {
346
+ method: 'POST',
347
+ headers: {
348
+ 'Content-Type': 'application/json',
349
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
350
+ },
351
+ body: JSON.stringify({
352
+ page: 1,
353
+ per_page: 100,
354
+ order_by: 'created_at',
355
+ order: 'desc'
356
+ }),
357
+ });
358
+
359
+ const responseText = await response.text();
360
+ const result = JSON.parse(responseText);
361
+
362
+ if (result.data && Array.isArray(result.data)) {
363
+ setSavedProjects(result.data);
364
+ } else if (result.data && Array.isArray(result.data.data)) {
365
+ setSavedProjects(result.data.data);
366
+ }
367
+ } catch (error) {
368
+ } finally {
369
+ setLoadingProjects(false);
370
+ }
371
+ }, [userProfile]);
372
+
373
+ // Save placement to database
374
+ const saveToDatabase = useCallback(async () => {
375
+
340
376
  if (!cameras.length && !uploadedImage) {
341
377
  Swal.fire({
342
378
  icon: 'warning',
@@ -347,115 +383,367 @@ const CameraPlacement = () => {
347
383
  return;
348
384
  }
349
385
 
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
- };
386
+ // Enhanced camera data validation
387
+ if (cameras.length === 0) {
388
+ Swal.fire({
389
+ icon: 'warning',
390
+ title: 'No Cameras to Save',
391
+ text: 'Please place at least one camera before saving. Click on the image to add cameras.',
392
+ confirmButtonColor: '#f59e0b',
393
+ });
394
+ return;
395
+ }
371
396
 
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',
397
+ // Validate each camera has required data
398
+ const invalidCameras = cameras.filter(camera => {
399
+ const issues = [];
400
+
401
+ if (!camera.id) issues.push('missing ID');
402
+ if (typeof camera.x !== 'number' || isNaN(camera.x)) issues.push('invalid X coordinate');
403
+ if (typeof camera.y !== 'number' || isNaN(camera.y)) issues.push('invalid Y coordinate');
404
+ if (!camera.name || camera.name.trim() === '') issues.push('missing name');
405
+ if (typeof camera.direction !== 'number' || isNaN(camera.direction)) issues.push('invalid direction');
406
+
407
+ return issues.length > 0;
395
408
  });
396
- }, [cameras, uploadedImage, mode]);
397
409
 
398
- // Load placement from JSON file
399
- const loadFromFile = useCallback(() => {
400
- const input = document.createElement('input');
401
- input.type = 'file';
402
- input.accept = '.json';
410
+ if (invalidCameras.length > 0) {
411
+ Swal.fire({
412
+ icon: 'error',
413
+ title: 'Invalid Camera Data',
414
+ text: `${invalidCameras.length} camera(s) have invalid data. Please check all cameras have proper positioning, names, and direction values.`,
415
+ confirmButtonColor: '#ef4444',
416
+ });
417
+ return;
418
+ }
403
419
 
404
- input.onchange = (event) => {
405
- const file = event.target.files[0];
406
- if (!file) return;
420
+ // Validate cameras_data array will not be empty
421
+ const validCamerasData = cameras.map((camera) => ({
422
+ id: camera.id,
423
+ name: camera.name,
424
+ customName: camera.customName,
425
+ x: camera.x,
426
+ y: camera.y,
427
+ direction: camera.direction,
428
+ verkadaModel: camera.verkadaModel,
429
+ timestamp: camera.timestamp,
430
+ })).filter(camera => camera.id && camera.name);
431
+
432
+ if (validCamerasData.length === 0) {
433
+ Swal.fire({
434
+ icon: 'error',
435
+ title: 'No Valid Camera Data',
436
+ text: 'All cameras seem to have missing or invalid data. Please recreate your cameras.',
437
+ confirmButtonColor: '#ef4444',
438
+ });
439
+ return;
440
+ }
407
441
 
408
- const reader = new FileReader();
409
- reader.onload = (e) => {
410
- try {
411
- const saveData = JSON.parse(e.target.result);
442
+ if (!projectName.trim()) {
443
+ Swal.fire({
444
+ icon: 'warning',
445
+ title: 'Project Name Required',
446
+ text: 'Please enter a project name before saving.',
447
+ confirmButtonColor: '#8b5cf6',
448
+ });
449
+ return;
450
+ }
412
451
 
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
- }
452
+ // Validate user profile
453
+ if (!userProfile || !userProfile.id) {
454
+ Swal.fire({
455
+ icon: 'error',
456
+ title: 'Authentication Error',
457
+ text: 'User profile is required to save camera placements.',
458
+ confirmButtonColor: '#ef4444',
459
+ });
460
+ return;
461
+ }
423
462
 
424
- // Load image if present
425
- if (saveData.image && saveData.image.src) {
426
- setUploadedImage(saveData.image.src);
427
- }
463
+ // Validate mode
464
+ if (!mode || !['image', 'map'].includes(mode)) {
465
+ Swal.fire({
466
+ icon: 'error',
467
+ title: 'Invalid Mode',
468
+ text: 'Invalid placement mode. Please refresh the page and try again.',
469
+ confirmButtonColor: '#ef4444',
470
+ });
471
+ return;
472
+ }
428
473
 
429
- // Set mode
430
- if (saveData.mode) {
431
- setMode(saveData.mode);
432
- }
474
+ try {
475
+ // Check if we're updating an existing project or creating a new one
476
+ const isUpdating = selectedProject && selectedProject.id;
477
+ const method = isUpdating ? 'PUT' : 'POST';
478
+ const url = isUpdating ? `/ajax/cameraPlacement/${selectedProject.id}` : '/ajax/cameraPlacement';
479
+
480
+ const saveData = {
481
+ name: projectName.trim(),
482
+ project_name: projectName.trim(),
483
+ version: '1.0',
484
+ mode: mode,
485
+ user_id: userProfile.id,
486
+ image_data: uploadedImage
487
+ ? {
488
+ src: uploadedImage,
489
+ name: 'uploaded-image',
490
+ }
491
+ : null,
492
+ image_name: uploadedImage ? 'uploaded-image' : null,
493
+ cameras_data: validCamerasData,
494
+ };
495
+
496
+ const response = await fetch(url, {
497
+ method: method,
498
+ headers: {
499
+ 'Content-Type': 'application/json',
500
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
501
+ },
502
+ body: JSON.stringify(saveData),
503
+ });
433
504
 
434
- // Load cameras
435
- setCameras(saveData.cameras || []);
505
+ const responseText = await response.text();
436
506
 
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
- });
507
+ let result;
508
+ try {
509
+ result = JSON.parse(responseText);
510
+ } catch (parseError) {
511
+ throw new Error(`Server returned invalid JSON. Status: ${response.status}, Response: ${responseText.substring(0, 200)}`);
512
+ }
513
+
514
+ // DynamicController returns { data: {...}, error: "" } format
515
+ if (result.data && !result.error) {
516
+ // Update the selectedProject with the latest data if we were updating
517
+ if (isUpdating) {
518
+ setSelectedProject(result.data);
453
519
  }
454
- };
455
- reader.readAsText(file);
456
- };
457
520
 
458
- input.click();
521
+ Swal.fire({
522
+ icon: 'success',
523
+ title: isUpdating ? 'Updated!' : 'Saved!',
524
+ text: `Camera placement "${projectName}" ${isUpdating ? 'updated' : 'saved'} successfully`,
525
+ timer: 3000,
526
+ showConfirmButton: false,
527
+ toast: true,
528
+ position: 'top-end',
529
+ });
530
+
531
+ // Refresh the projects list to show updated data
532
+ fetchSavedProjects();
533
+ } else {
534
+ const errorMessage = result.error || result.message || 'Save failed';
535
+ const errorDetails = result.errors ? JSON.stringify(result.errors) : '';
536
+ throw new Error(`${errorMessage}${errorDetails ? ` - Details: ${errorDetails}` : ''}`);
537
+ }
538
+ } catch (error) {
539
+ Swal.fire({
540
+ icon: 'error',
541
+ title: 'Save Failed',
542
+ text: error.message || 'Failed to save camera placement. Please try again.',
543
+ confirmButtonColor: '#ef4444',
544
+ });
545
+ }
546
+ }, [cameras, uploadedImage, mode, projectName, userProfile, selectedProject, fetchSavedProjects]);
547
+
548
+ // Fetch full project data with heavy JSON fields
549
+ const fetchFullProject = useCallback(async (projectId) => {
550
+ try {
551
+ const response = await fetch(`/ajax/cameraPlacement/${projectId}`, {
552
+ headers: {
553
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
554
+ },
555
+ });
556
+
557
+ const responseText = await response.text();
558
+ const result = JSON.parse(responseText);
559
+
560
+ if (result.id) {
561
+ // Direct object response (not wrapped in data)
562
+ return result;
563
+ } else if (result.data) {
564
+ // Wrapped in data object
565
+ return result.data;
566
+ } else {
567
+ throw new Error('Failed to fetch full project data');
568
+ }
569
+ } catch (error) {
570
+ throw error;
571
+ }
572
+ }, []);
573
+
574
+ // Load a specific project
575
+ const loadProject = useCallback(async (lightweightProject) => {
576
+ try {
577
+ // Show loading indicator
578
+ const loadingToast = Swal.fire({
579
+ title: 'Loading Project...',
580
+ text: 'Fetching project data',
581
+ allowOutsideClick: false,
582
+ showConfirmButton: false,
583
+ didOpen: () => {
584
+ Swal.showLoading();
585
+ }
586
+ });
587
+
588
+ // Fetch full project data including heavy JSON fields
589
+ const fullProject = await fetchFullProject(lightweightProject.id);
590
+
591
+ // Close loading indicator
592
+ Swal.close();
593
+
594
+ setSelectedProject(fullProject);
595
+ setProjectName(fullProject.project_name);
596
+ setMode(fullProject.mode);
597
+
598
+ if (fullProject.image_data && fullProject.image_data.src) {
599
+ setUploadedImage(fullProject.image_data.src);
600
+ } else {
601
+ setUploadedImage(null);
602
+ }
603
+
604
+ setCameras(fullProject.cameras_data || []);
605
+ setSidebarTab('cameras'); // Switch to cameras tab
606
+
607
+ Swal.fire({
608
+ icon: 'success',
609
+ title: 'Project Loaded!',
610
+ text: `Loaded "${fullProject.project_name}" with ${fullProject.cameras_data?.length || 0} cameras`,
611
+ timer: 2000,
612
+ showConfirmButton: false,
613
+ toast: true,
614
+ position: 'top-end',
615
+ });
616
+ } catch (error) {
617
+ Swal.fire({
618
+ icon: 'error',
619
+ title: 'Load Failed',
620
+ text: 'Failed to load project. Please try again.',
621
+ confirmButtonColor: '#ef4444',
622
+ });
623
+ }
624
+ }, [fetchFullProject]);
625
+
626
+ // Fetch projects when user profile changes or sidebar switches to projects
627
+ useEffect(() => {
628
+ if (sidebarTab === 'projects' && userProfile?.id) {
629
+ fetchSavedProjects();
630
+ }
631
+ }, [sidebarTab, userProfile, fetchSavedProjects]);
632
+
633
+ // Initial fetch of projects when component mounts to get correct count
634
+ useEffect(() => {
635
+ if (userProfile?.id) {
636
+ fetchSavedProjects();
637
+ }
638
+ }, [userProfile, fetchSavedProjects]);
639
+
640
+ // Load placement from database (legacy - keeping for compatibility)
641
+ const loadFromDatabase = useCallback(async () => {
642
+ try {
643
+ const response = await fetch('/ajax/cameraPlacement/table', {
644
+ method: 'POST',
645
+ headers: {
646
+ 'Content-Type': 'application/json',
647
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
648
+ },
649
+ body: JSON.stringify({
650
+ page: 1,
651
+ per_page: 100,
652
+ order_by: 'created_at',
653
+ order: 'desc'
654
+ }),
655
+ });
656
+
657
+ const result = await response.json();
658
+
659
+ if (result.data && result.data.length > 0) {
660
+ const placements = result.data;
661
+
662
+ // Show placement selection modal
663
+ const { value: selectedPlacement } = await Swal.fire({
664
+ title: 'Load Camera Placement',
665
+ html: `
666
+ <div style="text-align: left; max-height: 400px; overflow-y: auto;">
667
+ ${placements.map(placement => `
668
+ <div style="padding: 12px; margin: 8px 0; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;"
669
+ onmouseover="this.style.backgroundColor='#f9fafb'"
670
+ onmouseout="this.style.backgroundColor='white'"
671
+ onclick="this.classList.toggle('selected'); document.querySelectorAll('.placement-item').forEach(el => el !== this && el.classList.remove('selected'));"
672
+ class="placement-item"
673
+ data-placement-id="${placement.id}">
674
+ <div style="font-weight: 600; color: #1f2937;">${placement.project_name}</div>
675
+ <div style="font-size: 14px; color: #6b7280; margin-top: 4px;">
676
+ ${placement.cameras_count} cameras • ${placement.mode} mode
677
+ </div>
678
+ <div style="font-size: 12px; color: #9ca3af; margin-top: 4px;">
679
+ Created: ${new Date(placement.created_at).toLocaleDateString()}
680
+ </div>
681
+ </div>
682
+ `).join('')}
683
+ </div>
684
+ <style>
685
+ .placement-item.selected {
686
+ background-color: #dbeafe !important;
687
+ border-color: #3b82f6 !important;
688
+ }
689
+ </style>
690
+ `,
691
+ showCancelButton: true,
692
+ confirmButtonText: 'Load Selected',
693
+ cancelButtonText: 'Cancel',
694
+ confirmButtonColor: '#3b82f6',
695
+ preConfirm: () => {
696
+ const selectedElement = document.querySelector('.placement-item.selected');
697
+ if (!selectedElement) {
698
+ Swal.showValidationMessage('Please select a placement to load');
699
+ return false;
700
+ }
701
+ return selectedElement.getAttribute('data-placement-id');
702
+ }
703
+ });
704
+
705
+ if (selectedPlacement) {
706
+ const placement = placements.find(p => p.id == selectedPlacement);
707
+ if (placement) {
708
+ // Load the placement data
709
+ setProjectName(placement.project_name);
710
+ setMode(placement.mode);
711
+
712
+ if (placement.image_data && placement.image_data.src) {
713
+ setUploadedImage(placement.image_data.src);
714
+ } else {
715
+ setUploadedImage(null);
716
+ }
717
+
718
+ setCameras(placement.cameras_data || []);
719
+
720
+ Swal.fire({
721
+ icon: 'success',
722
+ title: 'Loaded!',
723
+ text: `Successfully loaded "${placement.project_name}" with ${placement.cameras_count} cameras`,
724
+ timer: 3000,
725
+ showConfirmButton: false,
726
+ toast: true,
727
+ position: 'top-end',
728
+ });
729
+ }
730
+ }
731
+ } else {
732
+ Swal.fire({
733
+ icon: 'info',
734
+ title: 'No Saved Placements',
735
+ text: 'You have no saved camera placements yet.',
736
+ confirmButtonColor: '#3b82f6',
737
+ });
738
+ }
739
+ } catch (error) {
740
+ Swal.fire({
741
+ icon: 'error',
742
+ title: 'Load Failed',
743
+ text: 'Failed to load camera placements. Please try again.',
744
+ confirmButtonColor: '#ef4444',
745
+ });
746
+ }
459
747
  }, []);
460
748
 
461
749
  const exportToPDF = useCallback(async () => {
@@ -501,7 +789,6 @@ const CameraPlacement = () => {
501
789
  });
502
790
  mapImageData = canvas.toDataURL('image/png', 0.9);
503
791
  } catch (error) {
504
- console.warn('Could not capture site map image:', error);
505
792
  // Continue without image if capture fails
506
793
  }
507
794
  }
@@ -520,7 +807,6 @@ const CameraPlacement = () => {
520
807
  })
521
808
  ).toBlob();
522
809
  } catch (pdfError) {
523
- console.warn('Enhanced PDF failed, trying simple version:', pdfError);
524
810
 
525
811
  // Fallback to simple PDF
526
812
  pdfBlob = await pdf(
@@ -586,7 +872,6 @@ const CameraPlacement = () => {
586
872
  confirmButtonText: 'Great!',
587
873
  });
588
874
  } catch (error) {
589
- console.error('Error generating PDF:', error);
590
875
 
591
876
  // More specific error handling
592
877
  let errorMessage = 'Unable to generate PDF report. Please try again.';
@@ -631,10 +916,6 @@ const CameraPlacement = () => {
631
916
  );
632
917
  }
633
918
  } catch (error) {
634
- console.warn(
635
- 'Could not access map style, skipping FOV layer cleanup:',
636
- error
637
- );
638
919
  }
639
920
  existingSources.forEach((sourceId) => {
640
921
  try {
@@ -646,10 +927,6 @@ const CameraPlacement = () => {
646
927
  map.removeSource(sourceId);
647
928
  }
648
929
  } catch (error) {
649
- console.warn(
650
- `Could not remove layer/source ${sourceId}:`,
651
- error
652
- );
653
930
  }
654
931
  });
655
932
 
@@ -899,8 +1176,18 @@ const CameraPlacement = () => {
899
1176
  <div className={styles.cameraPlacement}>
900
1177
  <div className={styles.header}>
901
1178
  <div className={styles.headerContent}>
902
- <h1 className={styles.title}>Site Layout Planner</h1>
1179
+ <div className={styles.titleSection}>
1180
+ <h1 className={styles.title}>Site Map Planner</h1>
1181
+ </div>
903
1182
  <div className={styles.controls}>
1183
+ <input
1184
+ type="text"
1185
+ placeholder="Project name..."
1186
+ value={projectName}
1187
+ onChange={(e) => setProjectName(e.target.value)}
1188
+ className={styles.projectInput}
1189
+ maxLength={100}
1190
+ />
904
1191
  <IconButton
905
1192
  icon={Upload}
906
1193
  onClick={() => fileInputRef.current?.click()}
@@ -909,26 +1196,11 @@ const CameraPlacement = () => {
909
1196
  />
910
1197
  <IconButton
911
1198
  icon={Save}
912
- onClick={saveAsFile}
1199
+ onClick={saveToDatabase}
913
1200
  disabled={!cameras.length && !uploadedImage}
914
- tooltip="Save camera placement as JSON file"
1201
+ tooltip="Save camera placement to database"
915
1202
  className={styles.saveFileBtn}
916
1203
  />
917
- <IconButton
918
- icon={FolderOpen}
919
- onClick={loadFromFile}
920
- tooltip="Load camera placement from JSON file"
921
- className={styles.loadFileBtn}
922
- />
923
- <IconButton
924
- icon={sidebarCollapsed ? Eye : EyeOff}
925
- onClick={() =>
926
- setSidebarCollapsed(!sidebarCollapsed)
927
- }
928
- tooltip={`${
929
- sidebarCollapsed ? 'Show' : 'Hide'
930
- } Cameras`}
931
- />
932
1204
  <IconButton
933
1205
  icon={Download}
934
1206
  onClick={exportToPDF}
@@ -944,9 +1216,7 @@ const CameraPlacement = () => {
944
1216
  <div
945
1217
  className={`${styles.imageContainer} ${
946
1218
  uploadedImage ? styles.addMode : ''
947
- } ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
948
- isDragOver ? styles.dragOver : ''
949
- }`}
1219
+ } ${isDragOver ? styles.dragOver : ''}`}
950
1220
  ref={imageContainer}
951
1221
  onClick={handleImageClick}
952
1222
  onDragOver={handleDragOver}
@@ -1386,10 +1656,6 @@ const CameraPlacement = () => {
1386
1656
  rotationHandle.style
1387
1657
  ) {
1388
1658
  rotationHandle.style.transform = `rotate(${newRotation}deg)`;
1389
- } else {
1390
- console.error(
1391
- 'Rotation handle is null or has no style property'
1392
- );
1393
1659
  }
1394
1660
 
1395
1661
  // Update camera direction
@@ -1468,56 +1734,51 @@ const CameraPlacement = () => {
1468
1734
  </div>
1469
1735
  )}
1470
1736
  </div>
1471
- </div>
1472
1737
 
1473
- {/* Hidden file input */}
1474
- <input
1475
- type="file"
1476
- ref={fileInputRef}
1477
- onChange={handleImageUpload}
1478
- accept="image/*"
1479
- style={{ display: 'none' }}
1480
- />
1481
-
1482
- <div
1483
- className={`${styles.sidebar} ${
1484
- sidebarCollapsed ? styles.collapsed : ''
1485
- }`}
1486
- >
1738
+ <div className={styles.sidebar}>
1487
1739
  <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
- }}
1740
+ <div className={styles.sidebarTabs}>
1741
+ <button
1742
+ className={`${styles.tabButton} ${sidebarTab === 'cameras' ? styles.active : ''}`}
1743
+ onClick={() => setSidebarTab('cameras')}
1504
1744
  >
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>
1745
+ Cameras
1746
+ <span className={styles.tabCount}>({cameras.length})</span>
1747
+ </button>
1748
+ <button
1749
+ className={`${styles.tabButton} ${sidebarTab === 'projects' ? styles.active : ''}`}
1750
+ onClick={() => setSidebarTab('projects')}
1751
+ >
1752
+ Projects
1753
+ <span className={styles.tabCount}>({savedProjects.length})</span>
1754
+ </button>
1755
+ </div>
1756
+ {selectedProject && sidebarTab === 'cameras' && (
1757
+ <div className={styles.currentProject}>
1758
+ <span className={styles.projectLabel}>Current:</span>
1759
+ <span className={styles.projectName}>{selectedProject.project_name}</span>
1519
1760
  </div>
1520
- ) : (
1761
+ )}
1762
+ </div>
1763
+
1764
+ <div className={styles.sidebarContent}>
1765
+ {sidebarTab === 'cameras' ? (
1766
+ <div className={styles.camerasList}>
1767
+ {cameras.length === 0 ? (
1768
+ <div className={styles.emptyState}>
1769
+ <div>
1770
+ {selectedProject
1771
+ ? `No cameras in "${selectedProject.project_name}"`
1772
+ : "Upload an image first, then click anywhere on it to place cameras"
1773
+ }
1774
+ </div>
1775
+ <div className={styles.emptyStateSubtext}>
1776
+ Once placed, you can drag cameras to reposition
1777
+ them and use the rotation handle to adjust
1778
+ direction
1779
+ </div>
1780
+ </div>
1781
+ ) : (
1521
1782
  cameras.map((camera) => {
1522
1783
  const verkadaCamera = camera.verkadaModel
1523
1784
  ? getCameraById(camera.verkadaModel)
@@ -1969,9 +2230,73 @@ const CameraPlacement = () => {
1969
2230
  </div>
1970
2231
  );
1971
2232
  })
2233
+ )}
2234
+ </div>
2235
+ ) : (
2236
+ <div className={styles.projectsList}>
2237
+ {loadingProjects ? (
2238
+ <div className={styles.loadingState}>
2239
+ <div>Loading projects...</div>
2240
+ </div>
2241
+ ) : savedProjects.length === 0 ? (
2242
+ <div className={styles.emptyState}>
2243
+ <div>No saved projects found</div>
2244
+ <div className={styles.emptyStateSubtext}>
2245
+ Create and save your first camera placement project
2246
+ </div>
2247
+ </div>
2248
+ ) : (
2249
+ savedProjects.map((project) => (
2250
+ <div
2251
+ key={project.id}
2252
+ className={`${styles.projectItem} ${selectedProject?.id === project.id ? styles.selected : ''}`}
2253
+ onClick={() => loadProject(project)}
2254
+ >
2255
+ <div className={styles.projectHeader}>
2256
+ <h4 className={styles.projectTitle}>
2257
+ {project.project_name}
2258
+ </h4>
2259
+ <div className={styles.projectMeta}>
2260
+ <span className={styles.projectMode}>
2261
+ {project.mode}
2262
+ </span>
2263
+ <span className={styles.projectDate}>
2264
+ {new Date(project.created_at).toLocaleDateString()}
2265
+ </span>
2266
+ </div>
2267
+ </div>
2268
+ <div className={styles.projectInfo}>
2269
+ <span className={styles.cameraCount}>
2270
+ {project.cameras_count || 0} cameras
2271
+ </span>
2272
+ {project.image_data && (
2273
+ <span className={styles.hasImage}>
2274
+ 📷 Image
2275
+ </span>
2276
+ )}
2277
+ </div>
2278
+ {project.description && (
2279
+ <div className={styles.projectDescription}>
2280
+ {project.description}
2281
+ </div>
2282
+ )}
2283
+ </div>
2284
+ ))
2285
+ )}
2286
+ </div>
1972
2287
  )}
1973
2288
  </div>
1974
2289
  </div>
2290
+ </div>
2291
+
2292
+ {/* Hidden file input */}
2293
+ <input
2294
+ type="file"
2295
+ ref={fileInputRef}
2296
+ onChange={handleImageUpload}
2297
+ accept="image/*"
2298
+ style={{ display: 'none' }}
2299
+ />
1975
2300
 
1976
2301
  {showModal && selectedCamera && (
1977
2302
  <CameraSelectionModal
@@ -18,11 +18,17 @@
18
18
  border-bottom: 1px solid rgba(var(--primary-rgb), 0.7);
19
19
  z-index: 100;
20
20
  position: relative;
21
+ min-width: 1000px;
21
22
 
22
23
  .headerContent {
23
24
  display: flex;
24
25
  justify-content: space-between;
25
26
  align-items: center;
27
+ min-width: 0;
28
+ }
29
+
30
+ .titleSection {
31
+ flex-shrink: 0;
26
32
  }
27
33
 
28
34
  .title {
@@ -32,12 +38,44 @@
32
38
  margin: 0;
33
39
  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
34
40
  letter-spacing: -0.5px;
41
+ white-space: nowrap;
35
42
  }
36
43
 
37
44
  .controls {
38
45
  display: flex;
39
46
  gap: 12px;
40
47
  align-items: center;
48
+ flex-shrink: 0;
49
+ min-width: 480px;
50
+ }
51
+
52
+ .projectInput {
53
+ background: rgba(255, 255, 255, 0.15);
54
+ border: 1px solid rgba(255, 255, 255, 0.3);
55
+ border-radius: var(--br, 8px);
56
+ padding: 8px 12px;
57
+ color: white;
58
+ font-size: 14px;
59
+ width: 300px;
60
+ max-width: 300px;
61
+ transition: all 0.2s ease;
62
+ margin-right: 12px;
63
+ flex-shrink: 0;
64
+
65
+ &::placeholder {
66
+ color: rgba(255, 255, 255, 0.7);
67
+ }
68
+
69
+ &:focus {
70
+ outline: none;
71
+ background: rgba(255, 255, 255, 0.25);
72
+ border-color: rgba(255, 255, 255, 0.5);
73
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
74
+ }
75
+
76
+ &:hover {
77
+ background: rgba(255, 255, 255, 0.2);
78
+ }
41
79
  }
42
80
 
43
81
  .iconButton {
@@ -46,6 +84,7 @@
46
84
  justify-content: center;
47
85
  width: 44px;
48
86
  height: 44px;
87
+ min-width: 44px;
49
88
  padding: 0;
50
89
  background: var(--secondary-color, #6b7280);
51
90
  color: white;
@@ -56,6 +95,7 @@
56
95
  border: 1px solid rgba(var(--secondary-rgb, 107, 114, 128), 1);
57
96
  position: relative;
58
97
  margin: 0 2px;
98
+ flex-shrink: 0;
59
99
 
60
100
  &::after {
61
101
  content: attr(title);
@@ -180,6 +220,7 @@
180
220
  overflow: hidden;
181
221
  gap: 0;
182
222
  position: relative;
223
+ height: calc(100vh - 100px);
183
224
  }
184
225
 
185
226
  .mapContainer {
@@ -236,9 +277,6 @@
236
277
  .imageContainer {
237
278
  flex: 1;
238
279
  position: relative;
239
- min-height: calc(100vh - 100px);
240
- height: calc(100vh - 100px);
241
- width: calc(100% - 400px);
242
280
  background: var(--bg);
243
281
  overflow: hidden;
244
282
  cursor: default;
@@ -247,9 +285,6 @@
247
285
  cursor: crosshair;
248
286
  }
249
287
 
250
- &.sidebarCollapsed {
251
- width: 100%;
252
- }
253
288
 
254
289
  &.dragOver {
255
290
  background: rgba(var(--primary-rgb), 0.05);
@@ -359,23 +394,16 @@
359
394
  }
360
395
 
361
396
  .sidebar {
362
- position: absolute;
363
- top: 70px;
364
- right: 0;
365
397
  width: 400px;
366
- height: calc(100% - 70px);
398
+ min-width: 400px;
367
399
  background: var(--item-color);
368
400
  border-left: 1px solid var(--border-color, #e5e7eb);
369
401
  display: flex;
370
402
  flex-direction: column;
371
403
  z-index: 20;
372
404
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
373
- transform: translateX(0);
374
- transition: transform 0.3s ease;
375
-
376
- &.collapsed {
377
- transform: translateX(100%);
378
- }
405
+ flex-shrink: 0;
406
+ height: 100%;
379
407
 
380
408
  .sidebarHeader {
381
409
  padding: 20px 24px;
@@ -384,6 +412,75 @@
384
412
  position: relative;
385
413
  z-index: 1;
386
414
 
415
+ .sidebarTabs {
416
+ display: flex;
417
+ gap: 4px;
418
+ margin-bottom: 12px;
419
+
420
+ .tabButton {
421
+ flex: 1;
422
+ padding: 8px 12px;
423
+ background: transparent;
424
+ border: 1px solid var(--border-color, #e5e7eb);
425
+ border-radius: 6px;
426
+ font-size: 14px;
427
+ font-weight: 500;
428
+ color: var(--paragraph-color, #64748b);
429
+ cursor: pointer;
430
+ transition: all 0.2s ease;
431
+ display: flex;
432
+ align-items: center;
433
+ justify-content: center;
434
+ gap: 4px;
435
+
436
+ .tabCount {
437
+ font-size: 12px;
438
+ opacity: 0.7;
439
+ }
440
+
441
+ &:hover {
442
+ background: var(--surface-color, #f1f5f9);
443
+ color: var(--heading-color, #1e293b);
444
+ }
445
+
446
+ &.active {
447
+ background: var(--primary-color, #3b82f6);
448
+ color: white;
449
+ border-color: var(--primary-color, #3b82f6);
450
+
451
+ .tabCount {
452
+ opacity: 0.8;
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ .currentProject {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 8px;
462
+ padding: 8px 12px;
463
+ background: var(--success-color-light, #ecfdf5);
464
+ border: 1px solid var(--success-color, #10b981);
465
+ border-radius: 6px;
466
+ font-size: 12px;
467
+
468
+ .projectLabel {
469
+ color: var(--success-color, #10b981);
470
+ font-weight: 500;
471
+ }
472
+
473
+ .projectName {
474
+ color: var(--heading-color, #1e293b);
475
+ font-weight: 600;
476
+ flex: 1;
477
+ max-width: 200px;
478
+ overflow: hidden;
479
+ text-overflow: ellipsis;
480
+ white-space: nowrap;
481
+ }
482
+ }
483
+
387
484
  .sidebarTitle {
388
485
  font-size: 18px;
389
486
  font-weight: 700;
@@ -400,6 +497,140 @@
400
497
  }
401
498
  }
402
499
 
500
+ .sidebarContent {
501
+ flex: 1;
502
+ overflow: hidden;
503
+ display: flex;
504
+ flex-direction: column;
505
+ }
506
+
507
+ .emptyState {
508
+ text-align: center;
509
+ color: #6c757d;
510
+ padding: 40px 20px;
511
+ font-style: italic;
512
+
513
+ .emptyStateSubtext {
514
+ fontSize: 12px;
515
+ margin-top: 8px;
516
+ }
517
+ }
518
+
519
+ .loadingState {
520
+ text-align: center;
521
+ color: #6c757d;
522
+ padding: 40px 20px;
523
+ font-style: italic;
524
+ }
525
+
526
+ .projectsList {
527
+ flex: 1;
528
+ overflow-y: auto;
529
+ padding: 20px 24px;
530
+
531
+ &::-webkit-scrollbar {
532
+ width: 6px;
533
+ }
534
+
535
+ &::-webkit-scrollbar-track {
536
+ background: rgba(0, 0, 0, 0.05);
537
+ border-radius: 3px;
538
+ }
539
+
540
+ &::-webkit-scrollbar-thumb {
541
+ background: #d1d5db;
542
+ border-radius: 3px;
543
+
544
+ &:hover {
545
+ background: #9ca3af;
546
+ }
547
+ }
548
+
549
+ .projectItem {
550
+ padding: 16px;
551
+ background: var(--item-color);
552
+ border-radius: var(--br, 8px);
553
+ margin-bottom: 12px;
554
+ border: 1px solid var(--border-color, #e5e7eb);
555
+ transition: all 0.2s ease;
556
+ cursor: pointer;
557
+ position: relative;
558
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
559
+
560
+ &:hover {
561
+ background: var(--surface-color, #f8fafc);
562
+ border-color: var(--primary-color, #3b82f6);
563
+ transform: translateY(-1px);
564
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
565
+ }
566
+
567
+ &.selected {
568
+ border-color: var(--primary-color, #3b82f6);
569
+ background: var(--primary-color-light, #eff6ff);
570
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
571
+ }
572
+
573
+ .projectHeader {
574
+ margin-bottom: 8px;
575
+
576
+ .projectTitle {
577
+ font-size: 16px;
578
+ font-weight: 600;
579
+ color: var(--heading-color, #1e293b);
580
+ margin: 0 0 4px 0;
581
+ line-height: 1.2;
582
+ }
583
+
584
+ .projectMeta {
585
+ display: flex;
586
+ gap: 8px;
587
+ align-items: center;
588
+
589
+ .projectMode {
590
+ background: var(--primary-color-light, #eff6ff);
591
+ color: var(--primary-color, #3b82f6);
592
+ padding: 2px 6px;
593
+ border-radius: 4px;
594
+ font-size: 11px;
595
+ font-weight: 500;
596
+ text-transform: uppercase;
597
+ }
598
+
599
+ .projectDate {
600
+ font-size: 12px;
601
+ color: var(--paragraph-color, #64748b);
602
+ }
603
+ }
604
+ }
605
+
606
+ .projectInfo {
607
+ display: flex;
608
+ gap: 12px;
609
+ align-items: center;
610
+ margin-bottom: 8px;
611
+
612
+ .cameraCount {
613
+ font-size: 13px;
614
+ color: var(--paragraph-color, #64748b);
615
+ font-weight: 500;
616
+ }
617
+
618
+ .hasImage {
619
+ font-size: 12px;
620
+ color: var(--success-color, #10b981);
621
+ font-weight: 500;
622
+ }
623
+ }
624
+
625
+ .projectDescription {
626
+ font-size: 12px;
627
+ color: var(--paragraph-color, #64748b);
628
+ line-height: 1.4;
629
+ font-style: italic;
630
+ }
631
+ }
632
+ }
633
+
403
634
  .camerasList {
404
635
  flex: 1;
405
636
  overflow-y: auto;