@visns-studio/visns-components 5.15.5 → 5.15.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.
@@ -3,6 +3,7 @@ import mapboxgl from 'mapbox-gl';
3
3
  import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
4
4
  import html2canvas from 'html2canvas';
5
5
  import Swal from 'sweetalert2';
6
+ import { Edit3, Trash2 } from 'lucide-react';
6
7
  import 'mapbox-gl/dist/mapbox-gl.css';
7
8
  import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
8
9
  import styles from './styles/CameraPlacement.module.scss';
@@ -81,6 +82,7 @@ const CameraPlacement = () => {
81
82
  setAddCameraMode(false); // Exit add camera mode after placing
82
83
  }, [mode, cameras.length, addCameraMode]);
83
84
 
85
+
84
86
  // Initialize Mapbox GL map
85
87
  useEffect(() => {
86
88
  if (!mapContainer.current || mode !== 'map') return;
@@ -183,11 +185,12 @@ const CameraPlacement = () => {
183
185
  }, [map, handleMapClick]);
184
186
 
185
187
  const updateCamera = useCallback((cameraId, updates) => {
186
- setCameras((prev) =>
187
- prev.map((cam) =>
188
+ setCameras((prev) => {
189
+ const updated = prev.map((cam) =>
188
190
  cam.id === cameraId ? { ...cam, ...updates } : cam
189
- )
190
- );
191
+ );
192
+ return updated;
193
+ });
191
194
  }, []);
192
195
 
193
196
  const removeCamera = useCallback(
@@ -357,11 +360,13 @@ const CameraPlacement = () => {
357
360
  logging: false
358
361
  });
359
362
 
360
- // Calculate dimensions based on camera count (more cameras = more height needed)
361
- const cameraListWidth = 450; // Wider to show more info
362
- const headerHeight = 80;
363
- const cameraHeight = 85; // Height per camera entry
364
- const totalCameraListHeight = headerHeight + (cameras.length * cameraHeight) + 40; // padding
363
+ // Calculate dimensions for professional report layout
364
+ const cameraListWidth = 550; // Fixed width for professional report
365
+ const headerHeight = 140; // Space for report header
366
+ const summaryHeight = 120; // Space for project summary
367
+ const cameraHeight = 180; // More space per camera card
368
+ const footerHeight = 300; // Space for legend and notes
369
+ const totalCameraListHeight = headerHeight + summaryHeight + (cameras.length * cameraHeight) + footerHeight;
365
370
 
366
371
  // Use the larger of image height or camera list height
367
372
  const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
@@ -410,41 +415,141 @@ const CameraPlacement = () => {
410
415
  cameraListDiv.style.overflow = 'visible';
411
416
  cameraListDiv.style.boxSizing = 'border-box';
412
417
 
413
- // Generate optimized camera list HTML
418
+ // Generate professional report-style HTML
414
419
  const cameraListHTML = `
415
- <div style="margin-bottom: 20px; border-bottom: 2px solid #e5e7eb; padding-bottom: 12px;">
416
- <h2 style="margin: 0 0 6px 0; font-size: 18px; font-weight: 700; color: #111827;">Camera Reference</h2>
417
- <p style="margin: 0; font-size: 12px; color: #6b7280;">Generated ${new Date().toLocaleDateString()}</p>
418
- <p style="margin: 2px 0 0 0; font-size: 12px; color: #6b7280;">${cameras.length} camera${cameras.length !== 1 ? 's' : ''} placed</p>
420
+ <!-- Report Header -->
421
+ <div style="background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; padding: 32px; margin: -20px -20px 0 -20px; border-radius: 16px 16px 0 0; box-shadow: 0 6px 20px rgba(0,0,0,0.15);">
422
+ <h1 style="margin: 0 0 16px 0; font-size: 36px; font-weight: 800; text-align: center; color: #ffffff; text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.6px; line-height: 1.1;">Security Camera Installation Report</h1>
423
+ <div style="text-align: center; margin-bottom: 24px;">
424
+ <div style="display: inline-block; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); padding: 12px 24px; border-radius: 25px; font-size: 14px; font-weight: 600; letter-spacing: 1px; color: #e5e7eb;">
425
+ PROJECT LAYOUT & SPECIFICATIONS
426
+ </div>
427
+ </div>
428
+ <div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; border-top: 1px solid rgba(255,255,255,0.15); padding-top: 20px; letter-spacing: 0.3px;">
429
+ <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</div>
430
+ <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
431
+ </div>
419
432
  </div>
420
433
 
421
- ${cameras.map((camera) => {
434
+
435
+ ${cameras.map((camera, index) => {
422
436
  const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
423
- const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
437
+ // Generate sequential camera number
438
+ const cameraNumber = index + 1;
424
439
 
425
440
  return `
426
- <div style="margin-bottom: 12px; padding: 10px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; page-break-inside: avoid;">
427
- <div style="display: flex; align-items: center; margin-bottom: 6px;">
428
- <div style="width: 22px; height: 22px; background: ${verkadaCamera?.environment === 'indoor' ? '#10b981' : '#f59e0b'}; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; margin-right: 8px; flex-shrink: 0;">${cameraNumber}</div>
429
- <div style="flex: 1; min-width: 0;">
430
- <div style="font-size: 13px; font-weight: 600; color: #111827; margin-bottom: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${camera.customName || camera.name}</div>
431
- <div style="font-size: 10px; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${verkadaCamera ? verkadaCamera.name : 'No Model Selected'}</div>
441
+ <div style="margin-bottom: 20px; background: white; border: 1px solid #d1d5db; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); page-break-inside: avoid;">
442
+ <!-- Camera Header -->
443
+ <div style="background: ${verkadaCamera?.environment === 'indoor' ? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' : 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'}; color: white; padding: 16px 20px; position: relative;">
444
+ <div style="display: flex; align-items: center; gap: 16px;">
445
+ <div style="width: 40px; height: 40px; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.25); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 16px; flex-shrink: 0; text-shadow: 0 1px 3px rgba(0,0,0,0.3);">${cameraNumber}</div>
446
+ <div style="flex: 1;">
447
+ <h4 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.3px; text-shadow: 0 1px 3px rgba(0,0,0,0.2);">Camera ${cameraNumber}</h4>
448
+ <div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${verkadaCamera ? verkadaCamera.name : 'Model Not Selected'}</div>
449
+ </div>
450
+ ${verkadaCamera?.ptz ? '<div style="background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); padding: 6px 12px; border-radius: 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px;">PTZ</div>' : ''}
432
451
  </div>
433
452
  </div>
434
453
 
435
- ${verkadaCamera ? `
436
- <div style="font-size: 10px; line-height: 1.5;">
437
- <div style="margin-bottom: 4px;"><strong>Resolution:</strong> ${verkadaCamera.resolution} | <strong>FOV:</strong> ${verkadaCamera.fov}°</div>
438
- <div style="margin-bottom: 4px;"><strong>Environment:</strong> ${verkadaCamera.environment} | <strong>Range:</strong> ${verkadaCamera.irRange}m IR</div>
439
- ${camera.direction ? `<div style="margin-bottom: 4px;"><strong>Direction:</strong> ${camera.direction}°</div>` : ''}
440
- <div style="color: #6b7280; margin-top: 6px; font-size: 9px; line-height: 1.4;">${verkadaCamera.features?.slice(0, 3).join(', ') || 'No features'}</div>
441
- </div>
442
- ` : `
443
- <div style="font-size: 10px; color: #dc2626; font-style: italic; margin-top: 4px;">Camera model not selected</div>
444
- `}
454
+ <!-- Camera Details -->
455
+ <div style="padding: 20px;">
456
+ ${verkadaCamera ? `
457
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
458
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
459
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
460
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.resolution}</div>
461
+ </div>
462
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
463
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Field of View</div>
464
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.fov}°</div>
465
+ </div>
466
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b'};">
467
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Environment</div>
468
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${verkadaCamera.environment}</div>
469
+ </div>
470
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
471
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
472
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.irRange}m</div>
473
+ </div>
474
+ </div>
475
+
476
+ <!-- Position & Direction -->
477
+ <div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
478
+ <div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; text-align: center; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px;">Installation Details</div>
479
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
480
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
481
+ <div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</div>
482
+ <div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${camera.direction ? `${camera.direction}° from North` : 'Not set'}</div>
483
+ </div>
484
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
485
+ <div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</div>
486
+ <div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${mode === 'map' ? `${camera.lat?.toFixed(6)}, ${camera.lng?.toFixed(6)}` : `X: ${Math.round(camera.x)}, Y: ${Math.round(camera.y)}`}</div>
487
+ </div>
488
+ </div>
489
+ </div>
490
+
491
+ <!-- Features -->
492
+ ${verkadaCamera.features && verkadaCamera.features.length > 0 ? `
493
+ <div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
494
+ <div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 10px; text-align: center;">Key Features</div>
495
+ <div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
496
+ ${verkadaCamera.features.slice(0, 6).map(feature =>
497
+ `<span style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); color: #1e40af; border: 1px solid #93c5fd; padding: 6px 12px; border-radius: 18px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;">${feature}</span>`
498
+ ).join('')}
499
+ ${verkadaCamera.features.length > 6 ? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${verkadaCamera.features.length - 6} more</span>` : ''}
500
+ </div>
501
+ </div>
502
+ ` : ''}
503
+ ` : `
504
+ <div style="text-align: center; padding: 30px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #fca5a5; border-radius: 12px; margin: 10px 0;">
505
+ <div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
506
+ <div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
507
+ </div>
508
+ `}
509
+ </div>
445
510
  </div>
446
511
  `;
447
512
  }).join('')}
513
+
514
+ <!-- Report Footer -->
515
+ <div style="margin-top: 32px; padding: 24px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); border-radius: 16px; border: 1px solid #cbd5e1; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
516
+ <!-- Legend Section -->
517
+ <div style="margin-bottom: 24px;">
518
+ <h3 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 800; color: #0f172a; text-align: center; letter-spacing: -0.4px;">Camera Types & Legend</h3>
519
+ <div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
520
+ <div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
521
+ <div style="width: 24px; height: 24px; background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(13,148,136,0.3);"></div>
522
+ <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
523
+ </div>
524
+ <div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
525
+ <div style="width: 24px; height: 24px; background: linear-gradient(135deg, #ea580c 0%, #dc2626 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(234,88,12,0.3);"></div>
526
+ <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
527
+ </div>
528
+ ${cameras.some(c => getCameraById(c.verkadaModel)?.ptz) ? `
529
+ <div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
530
+ <div style="width: 24px; height: 24px; background: #6b7280; border-radius: 50%; border: 3px solid #ef4444; box-shadow: 0 2px 6px rgba(239,68,68,0.3);"></div>
531
+ <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
532
+ </div>` : ''}
533
+ </div>
534
+ </div>
535
+
536
+ <!-- Technical Notes -->
537
+ <div style="background: white; padding: 20px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
538
+ <h4 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: #0f172a; text-align: center; letter-spacing: -0.2px;">📋 Installation Notes</h4>
539
+ <div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
540
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #3b82f6;"><strong style="color: #1e40af;">Direction Reference:</strong> All camera directions are measured clockwise from North (0° = North, 90° = East, 180° = South, 270° = West)</div>
541
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #8b5cf6;"><strong style="color: #7c3aed;">Field of View:</strong> FOV angles represent the horizontal viewing angle. Actual coverage may vary based on mounting height and obstacles</div>
542
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${mode === 'map' ? 'Geographic coordinates (Latitude, Longitude) in decimal degrees' : 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'}</div>
543
+ <div style="padding: 12px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border-radius: 8px; border-left: 3px solid #ef4444;"><strong style="color: #dc2626;">Important:</strong> This layout should be verified on-site during installation. Actual camera placement may need adjustment based on physical constraints, lighting conditions, and site requirements</div>
544
+ </div>
545
+ </div>
546
+
547
+ <!-- Report ID & Disclaimer -->
548
+ <div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
549
+ <div style="font-size: 12px; color: #475569; margin-bottom: 8px; letter-spacing: 0.5px; font-weight: 600;">Report ID: <span style="font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px;">${Date.now().toString(36).toUpperCase()}</span></div>
550
+ <div style="font-size: 11px; color: #64748b; line-height: 1.6; letter-spacing: 0.3px; max-width: 400px; margin: 0 auto;">This report was generated automatically and should be reviewed by qualified security installation professionals</div>
551
+ </div>
552
+ </div>
448
553
  `;
449
554
 
450
555
  cameraListDiv.innerHTML = cameraListHTML;
@@ -469,17 +574,17 @@ const CameraPlacement = () => {
469
574
 
470
575
  // Download
471
576
  const link = document.createElement('a');
472
- link.download = `camera-layout-${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}.png`;
473
- link.href = finalCanvas.toDataURL('image/png', 0.9);
577
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
578
+ link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
579
+ link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
474
580
  link.click();
475
581
 
476
- console.log('Export completed successfully');
477
582
 
478
583
  // Show success message
479
584
  Swal.fire({
480
585
  icon: 'success',
481
586
  title: 'Export Complete!',
482
- text: `Camera layout exported as ${link.download}`,
587
+ text: `Professional camera installation report exported as ${link.download}`,
483
588
  timer: 3000,
484
589
  showConfirmButton: false,
485
590
  toast: true,
@@ -900,6 +1005,56 @@ const CameraPlacement = () => {
900
1005
  transform: 'none',
901
1006
  zIndex: 2,
902
1007
  }}
1008
+ onMouseEnter={(e) => {
1009
+ // Only show tooltip if not hovering over rotation handle
1010
+ if (verkadaCamera && !e.target.closest(`.${styles.rotationHandle}`)) {
1011
+ // Add delay to prevent tooltip from showing during quick rotations
1012
+ const targetElement = e.currentTarget;
1013
+ targetElement._tooltipTimeout = setTimeout(() => {
1014
+ // Check if element still exists and is in DOM
1015
+ if (!targetElement || !targetElement.parentNode) {
1016
+ return;
1017
+ }
1018
+
1019
+
1020
+ const tooltip = document.createElement('div');
1021
+ tooltip.className = styles.cameraTooltip;
1022
+ tooltip.innerHTML = `
1023
+ <div class="${styles.tooltipImage}">
1024
+ <img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
1025
+ </div>
1026
+ <div class="${styles.tooltipContent}">
1027
+ <div class="${styles.tooltipTitle}">${verkadaCamera.name}</div>
1028
+ <div class="${styles.tooltipSpecs}">
1029
+ <div>Resolution: ${verkadaCamera.resolution}</div>
1030
+ <div>FOV: ${verkadaCamera.fov}°</div>
1031
+ <div>Environment: ${verkadaCamera.environment}</div>
1032
+ </div>
1033
+ </div>
1034
+ `;
1035
+
1036
+ // Position tooltip away from rotation handle
1037
+ tooltip.style.left = '50px';
1038
+ tooltip.style.top = '-10px';
1039
+
1040
+ targetElement.appendChild(tooltip);
1041
+ targetElement._tooltip = tooltip;
1042
+
1043
+ }, 300); // 300ms delay
1044
+ }
1045
+ }}
1046
+ onMouseLeave={(e) => {
1047
+ // Clear timeout if leaving before tooltip shows
1048
+ if (e.currentTarget._tooltipTimeout) {
1049
+ clearTimeout(e.currentTarget._tooltipTimeout);
1050
+ e.currentTarget._tooltipTimeout = null;
1051
+ }
1052
+ // Remove tooltip if it exists
1053
+ if (e.currentTarget._tooltip) {
1054
+ e.currentTarget.removeChild(e.currentTarget._tooltip);
1055
+ e.currentTarget._tooltip = null;
1056
+ }
1057
+ }}
903
1058
  onMouseDown={(e) => {
904
1059
  e.stopPropagation();
905
1060
  let isDragging = false;
@@ -950,36 +1105,90 @@ const CameraPlacement = () => {
950
1105
  style={{
951
1106
  transform: `rotate(${camera.direction || 0}deg)`,
952
1107
  }}
1108
+ onMouseEnter={(e) => {
1109
+ // Hide tooltip when hovering over rotation handle
1110
+ const container = e.currentTarget.parentElement.parentElement;
1111
+ if (container._tooltipTimeout) {
1112
+ clearTimeout(container._tooltipTimeout);
1113
+ container._tooltipTimeout = null;
1114
+ }
1115
+ if (container._tooltip) {
1116
+ container.removeChild(container._tooltip);
1117
+ container._tooltip = null;
1118
+ }
1119
+ }}
953
1120
  onMouseDown={(e) => {
1121
+
954
1122
  e.stopPropagation();
1123
+
1124
+ // Store references to prevent React event pooling issues
1125
+ const rotationHandle = e.currentTarget;
1126
+ const startMouseX = e.clientX;
1127
+ const startMouseY = e.clientY;
1128
+
955
1129
  let isRotating = false;
1130
+ let startAngle = 0;
956
1131
  let currentRotation = camera.direction || 0;
957
1132
 
958
- const markerRect = e.currentTarget.parentElement.getBoundingClientRect();
1133
+ // Get the center of the camera marker
1134
+ const markerContainer = rotationHandle.parentElement;
1135
+ const markerRect = markerContainer.getBoundingClientRect();
959
1136
  const centerX = markerRect.left + markerRect.width / 2;
960
1137
  const centerY = markerRect.top + markerRect.height / 2;
961
1138
 
1139
+
1140
+ // Calculate initial angle from center to mouse position
1141
+ const startDeltaX = startMouseX - centerX;
1142
+ const startDeltaY = startMouseY - centerY;
1143
+ startAngle = Math.atan2(startDeltaX, -startDeltaY) * 180 / Math.PI;
1144
+
1145
+
962
1146
  const handleRotationMove = (moveEvent) => {
963
- if (!isRotating) {
964
- isRotating = true;
965
- }
966
-
967
- const angle = Math.atan2(
968
- moveEvent.clientY - centerY,
969
- moveEvent.clientX - centerX
970
- );
1147
+ const deltaX = Math.abs(moveEvent.clientX - startMouseX);
1148
+ const deltaY = Math.abs(moveEvent.clientY - startMouseY);
971
1149
 
972
- const degrees = ((angle * 180) / Math.PI + 90 + 360) % 360;
973
- currentRotation = Math.round(degrees);
974
1150
 
975
- // Update rotation handle visual
976
- e.currentTarget.style.transform = `rotate(${currentRotation}deg)`;
1151
+ // Start rotating if mouse has moved more than 5 pixels
1152
+ if (!isRotating && (deltaX > 5 || deltaY > 5)) {
1153
+ isRotating = true;
1154
+ markerContainer.classList.add('rotating');
1155
+ }
977
1156
 
978
- // Update camera direction
979
- updateCamera(camera.id, { direction: currentRotation });
1157
+ if (isRotating) {
1158
+ // Calculate current angle from center to mouse position
1159
+ const currentDeltaX = moveEvent.clientX - centerX;
1160
+ const currentDeltaY = moveEvent.clientY - centerY;
1161
+ const currentAngle = Math.atan2(currentDeltaX, -currentDeltaY) * 180 / Math.PI;
1162
+
1163
+ // Calculate rotation delta
1164
+ let angleDelta = currentAngle - startAngle;
1165
+
1166
+ // Handle angle wrapping
1167
+ if (angleDelta > 180) angleDelta -= 360;
1168
+ if (angleDelta < -180) angleDelta += 360;
1169
+
1170
+ // Calculate new rotation
1171
+ let newRotation = (currentRotation + angleDelta + 360) % 360;
1172
+ newRotation = Math.round(newRotation);
1173
+
1174
+
1175
+ // Update rotation handle visual using stored reference
1176
+ if (rotationHandle && rotationHandle.style) {
1177
+ rotationHandle.style.transform = `rotate(${newRotation}deg)`;
1178
+ } else {
1179
+ console.error('🔄 Rotation handle is null or has no style property');
1180
+ }
1181
+
1182
+ // Update camera direction
1183
+ updateCamera(camera.id, { direction: newRotation });
1184
+ }
980
1185
  };
981
1186
 
982
1187
  const handleRotationEnd = () => {
1188
+
1189
+ if (isRotating) {
1190
+ markerContainer.classList.remove('rotating');
1191
+ }
983
1192
  document.removeEventListener('mousemove', handleRotationMove);
984
1193
  document.removeEventListener('mouseup', handleRotationEnd);
985
1194
  };
@@ -1065,32 +1274,65 @@ const CameraPlacement = () => {
1065
1274
  className={styles.cameraItem}
1066
1275
  >
1067
1276
  <div className={styles.cameraHeader}>
1068
- <h4 className={styles.cameraName}>
1277
+ <div className={styles.sidebarCameraInfo}>
1069
1278
  {verkadaCamera && (
1070
- <CameraIcon
1071
- icon={
1072
- verkadaCamera.icon
1073
- }
1074
- size={16}
1075
- />
1076
- )}{' '}
1077
- {camera.customName ||
1078
- camera.name}
1079
- </h4>
1080
- {verkadaCamera && (
1081
- <span
1082
- className={`${
1083
- styles.cameraType
1084
- } ${
1085
- styles[
1086
- verkadaCamera
1087
- .environment
1088
- ]
1089
- }`}
1279
+ <div className={styles.sidebarCameraImageContainer}>
1280
+ <img
1281
+ src={verkadaCamera.image}
1282
+ alt={verkadaCamera.name}
1283
+ className={styles.sidebarCameraImage}
1284
+ onLoad={(e) => {
1285
+ e.target.style.opacity = '1';
1286
+ }}
1287
+ onError={(e) => {
1288
+ // Fallback to icon if image fails to load
1289
+ e.target.style.display = 'none';
1290
+ e.target.nextSibling.style.display = 'flex';
1291
+ }}
1292
+ style={{opacity: '0.3'}}
1293
+ />
1294
+ <div className={styles.sidebarCameraIcon} style={{display: 'none'}}>
1295
+ <CameraIcon
1296
+ icon={verkadaCamera.icon}
1297
+ size={16}
1298
+ />
1299
+ </div>
1300
+ </div>
1301
+ )}
1302
+ <div className={styles.sidebarCameraText}>
1303
+ <h4 className={styles.cameraName}>
1304
+ {camera.customName || camera.name}
1305
+ </h4>
1306
+ {verkadaCamera && (
1307
+ <span
1308
+ className={`${styles.cameraType} ${styles[verkadaCamera.environment]}`}
1309
+ >
1310
+ {verkadaCamera.id}
1311
+ </span>
1312
+ )}
1313
+ </div>
1314
+ </div>
1315
+
1316
+ {/* Compact action buttons */}
1317
+ <div className={styles.cameraActions}>
1318
+ <button
1319
+ className={styles.iconBtn}
1320
+ onClick={() => {
1321
+ setSelectedCamera(camera);
1322
+ setShowModal(true);
1323
+ }}
1324
+ title="Edit camera"
1090
1325
  >
1091
- {verkadaCamera.id}
1092
- </span>
1093
- )}
1326
+ <Edit3 size={16} />
1327
+ </button>
1328
+ <button
1329
+ className={styles.iconBtn}
1330
+ onClick={() => removeCamera(camera.id)}
1331
+ title="Remove camera"
1332
+ >
1333
+ <Trash2 size={16} />
1334
+ </button>
1335
+ </div>
1094
1336
  </div>
1095
1337
 
1096
1338
  {verkadaCamera ? (
@@ -1446,25 +1688,6 @@ const CameraPlacement = () => {
1446
1688
  )}
1447
1689
  </div>
1448
1690
 
1449
- <div className={styles.cameraActions}>
1450
- <button
1451
- className={styles.editBtn}
1452
- onClick={() => {
1453
- setSelectedCamera(camera);
1454
- setShowModal(true);
1455
- }}
1456
- >
1457
- Edit
1458
- </button>
1459
- <button
1460
- className={styles.removeBtn}
1461
- onClick={() =>
1462
- removeCamera(camera.id)
1463
- }
1464
- >
1465
- Remove
1466
- </button>
1467
- </div>
1468
1691
  </div>
1469
1692
  );
1470
1693
  })
@@ -1479,7 +1702,9 @@ const CameraPlacement = () => {
1479
1702
  updateCamera(selectedCamera.id, updates);
1480
1703
  setShowModal(false);
1481
1704
  }}
1482
- onClose={() => setShowModal(false)}
1705
+ onClose={() => {
1706
+ setShowModal(false);
1707
+ }}
1483
1708
  />
1484
1709
  )}
1485
1710
  </div>
@@ -1487,9 +1712,11 @@ const CameraPlacement = () => {
1487
1712
  };
1488
1713
 
1489
1714
  const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1490
- const [customName, setCustomName] = useState(camera.customName || '');
1715
+ // Determine if this is editing an existing camera or adding a new one
1716
+ const isUpdating = Boolean(camera.verkadaModel);
1717
+
1491
1718
  const [selectedCategory, setSelectedCategory] = useState('all');
1492
- const [selectedCamera, setSelectedCamera] = useState(null);
1719
+ const [selectedCamera, setSelectedCamera] = useState(camera.verkadaModel ? getCameraById(camera.verkadaModel) : null);
1493
1720
 
1494
1721
  // Filter cameras based on selected category
1495
1722
  const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
@@ -1497,6 +1724,7 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1497
1724
  return cam.type === selectedCategory;
1498
1725
  });
1499
1726
 
1727
+
1500
1728
  const handleCameraSelect = (cameraModel) => {
1501
1729
  setSelectedCamera(cameraModel);
1502
1730
  };
@@ -1508,7 +1736,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1508
1736
  }
1509
1737
 
1510
1738
  onSave({
1511
- customName: customName.trim(),
1512
1739
  verkadaModel: selectedCamera.id,
1513
1740
  });
1514
1741
  };
@@ -1523,17 +1750,7 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1523
1750
 
1524
1751
  <div className={styles.modalContent}>
1525
1752
  <div className={styles.topSection}>
1526
- <div className={styles.formGroup}>
1527
- <label>Custom Name:</label>
1528
- <input
1529
- type="text"
1530
- value={customName}
1531
- onChange={(e) => setCustomName(e.target.value)}
1532
- placeholder="Enter camera name (optional)"
1533
- />
1534
- </div>
1535
-
1536
- <div className={styles.categoryFilter}>
1753
+ <div className={styles.categoryFilter}>
1537
1754
  <label>Filter by Category:</label>
1538
1755
  <div className={styles.categoryButtons}>
1539
1756
  <button
@@ -1563,8 +1780,24 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1563
1780
  onClick={() => handleCameraSelect(cam)}
1564
1781
  >
1565
1782
  <div className={styles.cardHeader}>
1566
- <div className={styles.cameraIcon}>
1567
- <CameraIcon icon={cam.icon} size={24} />
1783
+ <div className={styles.cameraImageContainer}>
1784
+ <img
1785
+ src={cam.image}
1786
+ alt={cam.name}
1787
+ className={styles.cameraImage}
1788
+ onLoad={(e) => {
1789
+ e.target.style.opacity = '1';
1790
+ }}
1791
+ onError={(e) => {
1792
+ // Fallback to icon if image fails to load
1793
+ e.target.style.display = 'none';
1794
+ e.target.nextSibling.style.display = 'flex';
1795
+ }}
1796
+ style={{opacity: '0.3'}}
1797
+ />
1798
+ <div className={styles.cameraIcon} style={{display: 'none'}}>
1799
+ <CameraIcon icon={cam.icon} size={24} />
1800
+ </div>
1568
1801
  </div>
1569
1802
  <div className={styles.cameraTitle}>
1570
1803
  <h4>{cam.name}</h4>
@@ -1626,7 +1859,7 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1626
1859
  disabled={!selectedCamera}
1627
1860
  className={styles.saveBtn}
1628
1861
  >
1629
- Add Camera
1862
+ {isUpdating ? 'Update Camera' : 'Add Camera'}
1630
1863
  </button>
1631
1864
  </div>
1632
1865
  </div>