@visns-studio/visns-components 5.15.6 → 5.15.7

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,7 +3,21 @@ 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
+ import {
7
+ Edit3,
8
+ Trash2,
9
+ Upload,
10
+ Save,
11
+ FolderOpen,
12
+ Eye,
13
+ EyeOff,
14
+ Download,
15
+ FileImage,
16
+ Lightbulb,
17
+ Mail,
18
+ Users,
19
+ FileText,
20
+ } from 'lucide-react';
7
21
  import 'mapbox-gl/dist/mapbox-gl.css';
8
22
  import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
9
23
  import styles from './styles/CameraPlacement.module.scss';
@@ -24,64 +38,149 @@ const CameraPlacement = () => {
24
38
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
25
39
  const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
26
40
  const [uploadedImage, setUploadedImage] = useState(null);
27
- const [addCameraMode, setAddCameraMode] = useState(false);
28
41
  const mapContainer = useRef(null);
29
42
  const mapRef = useRef(null);
30
43
  const geocoderRef = useRef(null);
31
44
  const imageContainer = useRef(null);
32
45
  const fileInputRef = useRef(null);
46
+ const [isDragOver, setIsDragOver] = useState(false);
47
+ const [isRotating, setIsRotating] = useState(false);
33
48
 
34
49
  // Mapbox configuration
35
- const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
50
+ const MAPBOX_ACCESS_TOKEN =
51
+ 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
36
52
 
37
53
  // Handle image upload
38
- const handleImageUpload = useCallback((event) => {
39
- const file = event.target.files[0];
40
- if (file && file.type.startsWith('image/')) {
41
- const reader = new FileReader();
42
- reader.onload = (e) => {
43
- setUploadedImage(e.target.result);
44
- setMode('image');
45
- // Reset cameras when switching to image mode
46
- setCameras([]);
47
- // Cleanup map if it exists
48
- if (map) {
49
- try {
50
- map.remove();
51
- } catch (error) {
52
- console.warn('Error removing map:', error);
54
+ const handleImageUpload = useCallback(
55
+ (event) => {
56
+ const file = event.target.files[0];
57
+ if (file && file.type.startsWith('image/')) {
58
+ const reader = new FileReader();
59
+ reader.onload = (e) => {
60
+ setUploadedImage(e.target.result);
61
+ setMode('image');
62
+ // Reset cameras when switching to image mode
63
+ setCameras([]);
64
+ // Cleanup map if it exists
65
+ if (map) {
66
+ try {
67
+ map.remove();
68
+ } catch (error) {
69
+ console.warn('Error removing map:', error);
70
+ }
71
+ setMap(null);
53
72
  }
54
- setMap(null);
55
- }
73
+ };
74
+ reader.readAsDataURL(file);
75
+ }
76
+ },
77
+ [map]
78
+ );
79
+
80
+ // Handle image click for camera placement (only when image is uploaded)
81
+ const handleImageClick = useCallback(
82
+ (event) => {
83
+ if (
84
+ mode !== 'image' ||
85
+ !imageContainer.current ||
86
+ !uploadedImage ||
87
+ isRotating
88
+ )
89
+ return;
90
+
91
+ // Check if we clicked on a camera marker or rotation handle
92
+ const clickedElement = event.target;
93
+ if (
94
+ clickedElement.closest('.camera-marker-container') ||
95
+ clickedElement.closest(`.${styles.rotationHandle}`) ||
96
+ clickedElement.closest(`.${styles.circularMarker}`)
97
+ ) {
98
+ return; // Don't add camera if clicking on existing camera elements
99
+ }
100
+
101
+ const rect = imageContainer.current.getBoundingClientRect();
102
+ const x = event.clientX - rect.left;
103
+ const y = event.clientY - rect.top;
104
+
105
+ const newCamera = {
106
+ id: Date.now(),
107
+ x: x, // Image coordinates instead of lat/lng
108
+ y: y,
109
+ name: `Camera ${cameras.length + 1}`,
110
+ verkadaModel: null,
111
+ customName: '',
112
+ direction: 0,
56
113
  };
57
- reader.readAsDataURL(file);
58
- }
59
- }, [map]);
60
-
61
- // Handle image click for camera placement (only when in add camera mode)
62
- const handleImageClick = useCallback((event) => {
63
- if (mode !== 'image' || !imageContainer.current || !addCameraMode) return;
64
-
65
- const rect = imageContainer.current.getBoundingClientRect();
66
- const x = event.clientX - rect.left;
67
- const y = event.clientY - rect.top;
68
-
69
- const newCamera = {
70
- id: Date.now(),
71
- x: x, // Image coordinates instead of lat/lng
72
- y: y,
73
- name: `Camera ${cameras.length + 1}`,
74
- verkadaModel: null,
75
- customName: '',
76
- direction: 0,
77
- };
78
114
 
79
- setCameras((prev) => [...prev, newCamera]);
80
- setSelectedCamera(newCamera);
81
- setShowModal(true);
82
- setAddCameraMode(false); // Exit add camera mode after placing
83
- }, [mode, cameras.length, addCameraMode]);
115
+ setCameras((prev) => [...prev, newCamera]);
116
+ setSelectedCamera(newCamera);
117
+ setShowModal(true);
118
+ },
119
+ [mode, cameras.length, uploadedImage, isRotating]
120
+ );
121
+
122
+ // Drag and drop handlers
123
+ const handleDragOver = useCallback((event) => {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ event.dataTransfer.dropEffect = 'copy';
127
+ setIsDragOver(true);
128
+ }, []);
84
129
 
130
+ const handleDragEnter = useCallback((event) => {
131
+ event.preventDefault();
132
+ event.stopPropagation();
133
+ setIsDragOver(true);
134
+ }, []);
135
+
136
+ const handleDragLeave = useCallback((event) => {
137
+ event.preventDefault();
138
+ event.stopPropagation();
139
+ // Only set to false if we're leaving the main container
140
+ if (event.currentTarget === event.target) {
141
+ setIsDragOver(false);
142
+ }
143
+ }, []);
144
+
145
+ const handleDrop = useCallback(
146
+ (event) => {
147
+ event.preventDefault();
148
+ event.stopPropagation();
149
+ setIsDragOver(false);
150
+
151
+ const files = event.dataTransfer.files;
152
+ if (files && files.length > 0) {
153
+ const file = files[0];
154
+ if (file.type.startsWith('image/')) {
155
+ const reader = new FileReader();
156
+ reader.onload = (e) => {
157
+ setUploadedImage(e.target.result);
158
+ setMode('image');
159
+ // Reset cameras when switching to image mode
160
+ setCameras([]);
161
+ // Cleanup map if it exists
162
+ if (map) {
163
+ try {
164
+ map.remove();
165
+ } catch (error) {
166
+ console.warn('Error removing map:', error);
167
+ }
168
+ setMap(null);
169
+ }
170
+ };
171
+ reader.readAsDataURL(file);
172
+ } else {
173
+ Swal.fire({
174
+ icon: 'error',
175
+ title: 'Invalid File Type',
176
+ text: 'Please upload an image file (JPG, PNG, GIF, etc.)',
177
+ confirmButtonColor: '#ef4444',
178
+ });
179
+ }
180
+ }
181
+ },
182
+ [map]
183
+ );
85
184
 
86
185
  // Initialize Mapbox GL map
87
186
  useEffect(() => {
@@ -195,21 +294,42 @@ const CameraPlacement = () => {
195
294
 
196
295
  const removeCamera = useCallback(
197
296
  (cameraId) => {
198
- setCameras((prev) => prev.filter((cam) => cam.id !== cameraId));
297
+ // Find camera info for confirmation message
298
+ const camera = cameras.find((cam) => cam.id === cameraId);
299
+ const cameraName = camera
300
+ ? camera.customName || camera.name
301
+ : 'this camera';
199
302
 
200
- // Remove FOV layer and source from map
201
- if (map) {
202
- const layerId = `fov-layer-${cameraId}`;
203
- const sourceId = `fov-${cameraId}`;
204
- if (map.getLayer(layerId)) {
205
- map.removeLayer(layerId);
206
- }
207
- if (map.getSource(sourceId)) {
208
- map.removeSource(sourceId);
303
+ Swal.fire({
304
+ title: 'Delete Camera?',
305
+ text: `Are you sure you want to delete ${cameraName}? This action cannot be undone.`,
306
+ icon: 'warning',
307
+ showCancelButton: true,
308
+ confirmButtonColor: '#ef4444',
309
+ cancelButtonColor: '#6b7280',
310
+ confirmButtonText: 'Yes, delete it!',
311
+ cancelButtonText: 'Cancel',
312
+ }).then((result) => {
313
+ if (result.isConfirmed) {
314
+ setCameras((prev) =>
315
+ prev.filter((cam) => cam.id !== cameraId)
316
+ );
317
+
318
+ // Remove FOV layer and source from map
319
+ if (map) {
320
+ const layerId = `fov-layer-${cameraId}`;
321
+ const sourceId = `fov-${cameraId}`;
322
+ if (map.getLayer(layerId)) {
323
+ map.removeLayer(layerId);
324
+ }
325
+ if (map.getSource(sourceId)) {
326
+ map.removeSource(sourceId);
327
+ }
328
+ }
209
329
  }
210
- }
330
+ });
211
331
  },
212
- [map]
332
+ [map, cameras]
213
333
  );
214
334
 
215
335
  // Save placement as JSON file
@@ -219,7 +339,7 @@ const CameraPlacement = () => {
219
339
  icon: 'warning',
220
340
  title: 'No Data to Save',
221
341
  text: 'Please add cameras or upload an image first.',
222
- confirmButtonColor: '#8b5cf6'
342
+ confirmButtonColor: '#8b5cf6',
223
343
  });
224
344
  return;
225
345
  }
@@ -227,12 +347,14 @@ const CameraPlacement = () => {
227
347
  const saveData = {
228
348
  version: '1.0',
229
349
  timestamp: new Date().toISOString(),
230
- image: uploadedImage ? {
231
- src: uploadedImage,
232
- name: 'uploaded-image'
233
- } : null,
350
+ image: uploadedImage
351
+ ? {
352
+ src: uploadedImage,
353
+ name: 'uploaded-image',
354
+ }
355
+ : null,
234
356
  mode: mode,
235
- cameras: cameras.map(camera => ({
357
+ cameras: cameras.map((camera) => ({
236
358
  id: camera.id,
237
359
  name: camera.name,
238
360
  customName: camera.customName,
@@ -240,15 +362,19 @@ const CameraPlacement = () => {
240
362
  y: camera.y,
241
363
  direction: camera.direction,
242
364
  verkadaModel: camera.verkadaModel,
243
- timestamp: camera.timestamp
244
- }))
365
+ timestamp: camera.timestamp,
366
+ })),
245
367
  };
246
368
 
247
369
  const dataStr = JSON.stringify(saveData, null, 2);
248
- const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
249
-
250
- const exportFileDefaultName = `camera-placement-${new Date().toISOString().split('T')[0]}.json`;
251
-
370
+ const dataUri =
371
+ 'data:application/json;charset=utf-8,' +
372
+ encodeURIComponent(dataStr);
373
+
374
+ const exportFileDefaultName = `camera-placement-${
375
+ new Date().toISOString().split('T')[0]
376
+ }.json`;
377
+
252
378
  const linkElement = document.createElement('a');
253
379
  linkElement.setAttribute('href', dataUri);
254
380
  linkElement.setAttribute('download', exportFileDefaultName);
@@ -262,7 +388,7 @@ const CameraPlacement = () => {
262
388
  timer: 3000,
263
389
  showConfirmButton: false,
264
390
  toast: true,
265
- position: 'top-end'
391
+ position: 'top-end',
266
392
  });
267
393
  }, [cameras, uploadedImage, mode]);
268
394
 
@@ -271,60 +397,61 @@ const CameraPlacement = () => {
271
397
  const input = document.createElement('input');
272
398
  input.type = 'file';
273
399
  input.accept = '.json';
274
-
400
+
275
401
  input.onchange = (event) => {
276
402
  const file = event.target.files[0];
277
403
  if (!file) return;
278
-
404
+
279
405
  const reader = new FileReader();
280
406
  reader.onload = (e) => {
281
407
  try {
282
408
  const saveData = JSON.parse(e.target.result);
283
-
409
+
284
410
  // Validate data structure
285
411
  if (!saveData.version || !Array.isArray(saveData.cameras)) {
286
412
  Swal.fire({
287
413
  icon: 'error',
288
414
  title: 'Invalid File',
289
415
  text: 'Please select a valid camera placement file.',
290
- confirmButtonColor: '#f59e0b'
416
+ confirmButtonColor: '#f59e0b',
291
417
  });
292
418
  return;
293
419
  }
294
-
420
+
295
421
  // Load image if present
296
422
  if (saveData.image && saveData.image.src) {
297
423
  setUploadedImage(saveData.image.src);
298
424
  }
299
-
425
+
300
426
  // Set mode
301
427
  if (saveData.mode) {
302
428
  setMode(saveData.mode);
303
429
  }
304
-
430
+
305
431
  // Load cameras
306
432
  setCameras(saveData.cameras || []);
307
-
433
+
308
434
  Swal.fire({
309
435
  icon: 'success',
310
436
  title: 'File Loaded!',
311
- text: `Successfully loaded ${saveData.cameras?.length || 0} cameras from ${file.name}`,
312
- confirmButtonColor: '#f59e0b'
437
+ text: `Successfully loaded ${
438
+ saveData.cameras?.length || 0
439
+ } cameras from ${file.name}`,
440
+ confirmButtonColor: '#f59e0b',
313
441
  });
314
-
315
442
  } catch (error) {
316
443
  console.error('Error loading file:', error);
317
444
  Swal.fire({
318
445
  icon: 'error',
319
446
  title: 'File Error',
320
- text: 'Error reading file. Please ensure it\'s a valid JSON file.',
321
- confirmButtonColor: '#f59e0b'
447
+ text: "Error reading file. Please ensure it's a valid JSON file.",
448
+ confirmButtonColor: '#f59e0b',
322
449
  });
323
450
  }
324
451
  };
325
452
  reader.readAsText(file);
326
453
  };
327
-
454
+
328
455
  input.click();
329
456
  }, []);
330
457
 
@@ -335,18 +462,20 @@ const CameraPlacement = () => {
335
462
  icon: 'warning',
336
463
  title: 'No Cameras',
337
464
  text: 'Please add cameras first before exporting.',
338
- confirmButtonColor: '#10b981'
465
+ confirmButtonColor: '#10b981',
339
466
  });
340
467
  return;
341
468
  }
342
469
 
343
- const imageContainer = document.querySelector(`.${styles.imageContainer}`);
470
+ const imageContainer = document.querySelector(
471
+ `.${styles.imageContainer}`
472
+ );
344
473
  if (!imageContainer) {
345
474
  Swal.fire({
346
475
  icon: 'warning',
347
476
  title: 'No Image',
348
477
  text: 'Please upload an image first before exporting.',
349
- confirmButtonColor: '#10b981'
478
+ confirmButtonColor: '#10b981',
350
479
  });
351
480
  return;
352
481
  }
@@ -357,7 +486,7 @@ const CameraPlacement = () => {
357
486
  allowTaint: true,
358
487
  scale: 1.2,
359
488
  backgroundColor: '#f8fafc',
360
- logging: false
489
+ logging: false,
361
490
  });
362
491
 
363
492
  // Calculate dimensions for professional report layout
@@ -366,10 +495,17 @@ const CameraPlacement = () => {
366
495
  const summaryHeight = 120; // Space for project summary
367
496
  const cameraHeight = 180; // More space per camera card
368
497
  const footerHeight = 300; // Space for legend and notes
369
- const totalCameraListHeight = headerHeight + summaryHeight + (cameras.length * cameraHeight) + footerHeight;
370
-
498
+ const totalCameraListHeight =
499
+ headerHeight +
500
+ summaryHeight +
501
+ cameras.length * cameraHeight +
502
+ footerHeight;
503
+
371
504
  // Use the larger of image height or camera list height
372
- const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
505
+ const finalHeight = Math.max(
506
+ imageCanvas.height,
507
+ totalCameraListHeight
508
+ );
373
509
  const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
374
510
 
375
511
  // Create the composite container with calculated dimensions
@@ -382,7 +518,8 @@ const CameraPlacement = () => {
382
518
  exportContainer.style.backgroundColor = '#ffffff';
383
519
  exportContainer.style.display = 'flex';
384
520
  exportContainer.style.gap = '10px';
385
- exportContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
521
+ exportContainer.style.fontFamily =
522
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
386
523
  document.body.appendChild(exportContainer);
387
524
 
388
525
  // Left side: Image
@@ -402,7 +539,7 @@ const CameraPlacement = () => {
402
539
  scaledCanvas.width = imageCanvas.width;
403
540
  scaledCanvas.height = imageCanvas.height;
404
541
  ctx.drawImage(imageCanvas, 0, 0);
405
-
542
+
406
543
  imageDiv.appendChild(scaledCanvas);
407
544
  exportContainer.appendChild(imageDiv);
408
545
 
@@ -426,50 +563,88 @@ const CameraPlacement = () => {
426
563
  </div>
427
564
  </div>
428
565
  <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>
566
+ <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString(
567
+ 'en-US',
568
+ {
569
+ weekday: 'long',
570
+ year: 'numeric',
571
+ month: 'long',
572
+ day: 'numeric',
573
+ }
574
+ )}</div>
430
575
  <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
431
576
  </div>
432
577
  </div>
433
578
 
434
579
 
435
- ${cameras.map((camera, index) => {
436
- const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
437
- // Generate sequential camera number
438
- const cameraNumber = index + 1;
439
-
440
- return `
580
+ ${cameras
581
+ .map((camera, index) => {
582
+ const verkadaCamera = camera.verkadaModel
583
+ ? getCameraById(camera.verkadaModel)
584
+ : null;
585
+ // Generate sequential camera number
586
+ const cameraNumber = index + 1;
587
+
588
+ return `
441
589
  <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
590
  <!-- 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;">
591
+ <div style="background: ${
592
+ verkadaCamera?.environment === 'indoor'
593
+ ? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)'
594
+ : 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'
595
+ }; color: white; padding: 16px 20px; position: relative;">
444
596
  <div style="display: flex; align-items: center; gap: 16px;">
445
597
  <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
598
  <div style="flex: 1;">
447
599
  <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>
600
+ <div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${
601
+ verkadaCamera
602
+ ? verkadaCamera.name
603
+ : 'Model Not Selected'
604
+ }</div>
449
605
  </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>' : ''}
606
+ ${
607
+ verkadaCamera?.ptz
608
+ ? '<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>'
609
+ : ''
610
+ }
451
611
  </div>
452
612
  </div>
453
613
 
454
614
  <!-- Camera Details -->
455
615
  <div style="padding: 20px;">
456
- ${verkadaCamera ? `
616
+ ${
617
+ verkadaCamera
618
+ ? `
457
619
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
458
620
  <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
459
621
  <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>
622
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
623
+ verkadaCamera.resolution
624
+ }</div>
461
625
  </div>
462
626
  <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
463
627
  <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>
628
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
629
+ verkadaCamera.fov
630
+ }°</div>
465
631
  </div>
466
- <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b'};">
632
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${
633
+ verkadaCamera.environment ===
634
+ 'indoor'
635
+ ? '#10b981'
636
+ : '#f59e0b'
637
+ };">
467
638
  <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>
639
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${
640
+ verkadaCamera.environment
641
+ }</div>
469
642
  </div>
470
643
  <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
471
644
  <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>
645
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
646
+ verkadaCamera.irRange
647
+ }m</div>
473
648
  </div>
474
649
  </div>
475
650
 
@@ -479,37 +654,74 @@ const CameraPlacement = () => {
479
654
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
480
655
  <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
481
656
  <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>
657
+ <div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${
658
+ camera.direction
659
+ ? `${camera.direction}° from North`
660
+ : 'Not set'
661
+ }</div>
483
662
  </div>
484
663
  <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
485
664
  <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>
665
+ <div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${
666
+ mode === 'map'
667
+ ? `${camera.lat?.toFixed(
668
+ 6
669
+ )}, ${camera.lng?.toFixed(
670
+ 6
671
+ )}`
672
+ : `X: ${Math.round(
673
+ camera.x
674
+ )}, Y: ${Math.round(
675
+ camera.y
676
+ )}`
677
+ }</div>
487
678
  </div>
488
679
  </div>
489
680
  </div>
490
681
 
491
682
  <!-- Features -->
492
- ${verkadaCamera.features && verkadaCamera.features.length > 0 ? `
683
+ ${
684
+ verkadaCamera.features &&
685
+ verkadaCamera.features.length > 0
686
+ ? `
493
687
  <div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
494
688
  <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
689
  <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>` : ''}
690
+ ${verkadaCamera.features
691
+ .slice(0, 6)
692
+ .map(
693
+ (feature) =>
694
+ `<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>`
695
+ )
696
+ .join('')}
697
+ ${
698
+ verkadaCamera.features
699
+ .length > 6
700
+ ? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${
701
+ verkadaCamera
702
+ .features
703
+ .length - 6
704
+ } more</span>`
705
+ : ''
706
+ }
500
707
  </div>
501
708
  </div>
502
- ` : ''}
503
- ` : `
709
+ `
710
+ : ''
711
+ }
712
+ `
713
+ : `
504
714
  <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
715
  <div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
506
716
  <div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
507
717
  </div>
508
- `}
718
+ `
719
+ }
509
720
  </div>
510
721
  </div>
511
722
  `;
512
- }).join('')}
723
+ })
724
+ .join('')}
513
725
 
514
726
  <!-- Report Footer -->
515
727
  <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);">
@@ -525,28 +737,40 @@ const CameraPlacement = () => {
525
737
  <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
738
  <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
527
739
  </div>
528
- ${cameras.some(c => getCameraById(c.verkadaModel)?.ptz) ? `
740
+ ${
741
+ cameras.some(
742
+ (c) => getCameraById(c.verkadaModel)?.ptz
743
+ )
744
+ ? `
529
745
  <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
746
  <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
747
  <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
532
- </div>` : ''}
748
+ </div>`
749
+ : ''
750
+ }
533
751
  </div>
534
752
  </div>
535
753
 
536
754
  <!-- Technical Notes -->
537
755
  <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>
756
+ <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
757
  <div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
540
758
  <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
759
  <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>
760
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${
761
+ mode === 'map'
762
+ ? 'Geographic coordinates (Latitude, Longitude) in decimal degrees'
763
+ : 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'
764
+ }</div>
543
765
  <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
766
  </div>
545
767
  </div>
546
768
 
547
769
  <!-- Report ID & Disclaimer -->
548
770
  <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>
771
+ <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()
772
+ .toString(36)
773
+ .toUpperCase()}</span></div>
550
774
  <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
775
  </div>
552
776
  </div>
@@ -556,7 +780,7 @@ const CameraPlacement = () => {
556
780
  exportContainer.appendChild(cameraListDiv);
557
781
 
558
782
  // Wait for rendering
559
- await new Promise(resolve => setTimeout(resolve, 100));
783
+ await new Promise((resolve) => setTimeout(resolve, 100));
560
784
 
561
785
  // Capture the composite with exact dimensions
562
786
  const finalCanvas = await html2canvas(exportContainer, {
@@ -566,7 +790,7 @@ const CameraPlacement = () => {
566
790
  backgroundColor: '#ffffff',
567
791
  logging: false,
568
792
  width: finalWidth,
569
- height: finalHeight
793
+ height: finalHeight,
570
794
  });
571
795
 
572
796
  // Clean up
@@ -574,12 +798,14 @@ const CameraPlacement = () => {
574
798
 
575
799
  // Download
576
800
  const link = document.createElement('a');
577
- const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
801
+ const timestamp = new Date()
802
+ .toISOString()
803
+ .slice(0, 19)
804
+ .replace(/[:.]/g, '-');
578
805
  link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
579
806
  link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
580
807
  link.click();
581
808
 
582
-
583
809
  // Show success message
584
810
  Swal.fire({
585
811
  icon: 'success',
@@ -588,16 +814,15 @@ const CameraPlacement = () => {
588
814
  timer: 3000,
589
815
  showConfirmButton: false,
590
816
  toast: true,
591
- position: 'top-end'
817
+ position: 'top-end',
592
818
  });
593
-
594
819
  } catch (error) {
595
820
  console.error('Error exporting:', error);
596
821
  Swal.fire({
597
822
  icon: 'error',
598
823
  title: 'Export Failed',
599
824
  text: 'Export failed. Please try again or take a manual screenshot.',
600
- confirmButtonColor: '#10b981'
825
+ confirmButtonColor: '#10b981',
601
826
  });
602
827
  }
603
828
  }, [cameras]);
@@ -615,12 +840,15 @@ const CameraPlacement = () => {
615
840
  try {
616
841
  const style = map.getStyle();
617
842
  if (style && style.sources) {
618
- existingSources = Object.keys(style.sources).filter(
619
- (id) => id.startsWith('fov-')
843
+ existingSources = Object.keys(style.sources).filter((id) =>
844
+ id.startsWith('fov-')
620
845
  );
621
846
  }
622
847
  } catch (error) {
623
- console.warn('Could not access map style, skipping FOV layer cleanup:', error);
848
+ console.warn(
849
+ 'Could not access map style, skipping FOV layer cleanup:',
850
+ error
851
+ );
624
852
  }
625
853
  existingSources.forEach((sourceId) => {
626
854
  try {
@@ -632,7 +860,10 @@ const CameraPlacement = () => {
632
860
  map.removeSource(sourceId);
633
861
  }
634
862
  } catch (error) {
635
- console.warn(`Could not remove layer/source ${sourceId}:`, error);
863
+ console.warn(
864
+ `Could not remove layer/source ${sourceId}:`,
865
+ error
866
+ );
636
867
  }
637
868
  });
638
869
 
@@ -679,6 +910,7 @@ const CameraPlacement = () => {
679
910
  const handleRotationStart = (e) => {
680
911
  e.stopPropagation();
681
912
  isRotating = true;
913
+ setIsRotating(true);
682
914
  const rect = markerContainer.getBoundingClientRect();
683
915
  const centerX = rect.left + rect.width / 2;
684
916
  const centerY = rect.top + rect.height / 2;
@@ -712,6 +944,7 @@ const CameraPlacement = () => {
712
944
 
713
945
  const handleRotationEnd = () => {
714
946
  isRotating = false;
947
+ setIsRotating(false);
715
948
  markerContainer.classList.remove('rotating');
716
949
  document.removeEventListener('mousemove', handleRotationMove);
717
950
  document.removeEventListener('mouseup', handleRotationEnd);
@@ -858,167 +1091,232 @@ const CameraPlacement = () => {
858
1091
  });
859
1092
  }, [map, cameras, mode]);
860
1093
 
1094
+ // Icon button with tooltip component
1095
+ const IconButton = ({
1096
+ icon: Icon,
1097
+ onClick,
1098
+ disabled = false,
1099
+ tooltip,
1100
+ className = '',
1101
+ }) => (
1102
+ <button
1103
+ onClick={onClick}
1104
+ disabled={disabled}
1105
+ className={`${styles.iconButton} ${className}`}
1106
+ title={tooltip}
1107
+ >
1108
+ <Icon size={20} />
1109
+ </button>
1110
+ );
861
1111
 
862
1112
  return (
863
1113
  <div className={styles.cameraPlacement}>
864
1114
  <div className={styles.header}>
865
- <h1 className={styles.title}>Camera Placement Tool</h1>
866
- <div className={styles.controls}>
867
- <div className={styles.leftControls}>
868
- <button
1115
+ <div className={styles.headerContent}>
1116
+ <h1 className={styles.title}>Site Layout Planner</h1>
1117
+ <div className={styles.controls}>
1118
+ <IconButton
1119
+ icon={Upload}
869
1120
  onClick={() => fileInputRef.current?.click()}
1121
+ tooltip="Upload Image"
870
1122
  className={styles.uploadBtn}
871
- >
872
- Upload Image
873
- </button>
874
- {uploadedImage && (
875
- <button
876
- onClick={() => setAddCameraMode(!addCameraMode)}
877
- className={`${styles.addCameraBtn} ${addCameraMode ? styles.activeControl : ''}`}
878
- >
879
- {addCameraMode ? 'Cancel Add' : 'Add Camera'}
880
- </button>
881
- )}
882
- </div>
883
- <div className={styles.rightControls}>
884
- <button
1123
+ />
1124
+ <IconButton
1125
+ icon={Save}
885
1126
  onClick={saveAsFile}
886
- className={styles.saveFileBtn}
887
1127
  disabled={!cameras.length && !uploadedImage}
888
- title="Save camera placement as JSON file"
889
- >
890
- Save File
891
- </button>
892
- <button
1128
+ tooltip="Save camera placement as JSON file"
1129
+ className={styles.saveFileBtn}
1130
+ />
1131
+ <IconButton
1132
+ icon={FolderOpen}
893
1133
  onClick={loadFromFile}
1134
+ tooltip="Load camera placement from JSON file"
894
1135
  className={styles.loadFileBtn}
895
- title="Load camera placement from JSON file"
896
- >
897
- Load File
898
- </button>
899
- <button
1136
+ />
1137
+ <IconButton
1138
+ icon={sidebarCollapsed ? Eye : EyeOff}
900
1139
  onClick={() =>
901
1140
  setSidebarCollapsed(!sidebarCollapsed)
902
1141
  }
903
- >
904
- {sidebarCollapsed ? 'Show' : 'Hide'} Cameras
905
- </button>
906
- <button
907
- className={styles.exportBtn}
1142
+ tooltip={`${
1143
+ sidebarCollapsed ? 'Show' : 'Hide'
1144
+ } Cameras`}
1145
+ />
1146
+ <IconButton
1147
+ icon={Download}
908
1148
  onClick={exportMap}
909
1149
  disabled={mode === 'image' && !uploadedImage}
910
- >
911
- Export {mode === 'map' ? 'Map' : 'Image'}
912
- </button>
1150
+ tooltip={`Export ${
1151
+ mode === 'map' ? 'Map' : 'Image'
1152
+ }`}
1153
+ className={styles.exportBtn}
1154
+ />
913
1155
  </div>
914
1156
  </div>
915
1157
  </div>
916
1158
 
917
1159
  <div className={styles.mainContent}>
918
- <div
919
- className={`${styles.imageContainer} ${addCameraMode ? styles.addMode : ''} ${sidebarCollapsed ? styles.sidebarCollapsed : ''}`}
920
- ref={imageContainer}
1160
+ <div
1161
+ className={`${styles.imageContainer} ${
1162
+ uploadedImage ? styles.addMode : ''
1163
+ } ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
1164
+ isDragOver ? styles.dragOver : ''
1165
+ }`}
1166
+ ref={imageContainer}
921
1167
  onClick={handleImageClick}
1168
+ onDragOver={handleDragOver}
1169
+ onDragEnter={handleDragEnter}
1170
+ onDragLeave={handleDragLeave}
1171
+ onDrop={handleDrop}
922
1172
  >
923
- {uploadedImage ? (
924
- <div className={styles.imageWrapper}>
925
- <img
926
- src={uploadedImage}
927
- alt="Floor plan or site layout"
928
- className={styles.uploadedImage}
929
- />
930
- {/* Render FOV triangles first (behind markers) */}
931
- {cameras.map((camera) => {
932
- const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
933
- if (!verkadaCamera) return null;
934
-
935
- let fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov;
936
-
937
- // Handle special FOV cases
938
- if (fov >= 360) fov = 120; // PTZ cameras showing max practical FOV
939
- else if (fov >= 270) fov = 150; // Multi-sensor cameras
940
- else if (fov >= 180) fov = 130; // Fisheye cameras
941
-
942
- const fovRad = (fov * Math.PI) / 180;
943
-
944
- // Calculate distance based on FOV - wider angles get shorter distances
945
- // Base distance of 100px for 60° FOV, scaled inversely with bounds
946
- const baseFov = 60; // Reference FOV angle
947
- const baseDistance = verkadaCamera.ptz ? 120 : 100;
948
- let distance = baseDistance * (baseFov / fov);
949
-
950
- // Apply bounds: min 30px, max 200px
951
- distance = Math.max(30, Math.min(200, distance));
952
- const directionRad = (camera.direction * Math.PI) / 180;
1173
+ {uploadedImage ? (
1174
+ <div className={styles.imageWrapper}>
1175
+ <img
1176
+ src={uploadedImage}
1177
+ alt="Floor plan or site layout"
1178
+ className={styles.uploadedImage}
1179
+ />
1180
+ {/* Render FOV triangles first (behind markers) */}
1181
+ {cameras.map((camera) => {
1182
+ const verkadaCamera = camera.verkadaModel
1183
+ ? getCameraById(camera.verkadaModel)
1184
+ : null;
1185
+ if (!verkadaCamera) return null;
1186
+
1187
+ let fov = verkadaCamera.ptz
1188
+ ? 90
1189
+ : verkadaCamera.fov;
1190
+
1191
+ // Handle special FOV cases
1192
+ if (fov >= 360)
1193
+ fov = 120; // PTZ cameras showing max practical FOV
1194
+ else if (fov >= 270)
1195
+ fov = 150; // Multi-sensor cameras
1196
+ else if (fov >= 180) fov = 130; // Fisheye cameras
1197
+
1198
+ const fovRad = (fov * Math.PI) / 180;
1199
+
1200
+ // Calculate distance based on FOV - wider angles get shorter distances
1201
+ // Base distance of 100px for 60° FOV, scaled inversely with bounds
1202
+ const baseFov = 60; // Reference FOV angle
1203
+ const baseDistance = verkadaCamera.ptz
1204
+ ? 120
1205
+ : 100;
1206
+ let distance = baseDistance * (baseFov / fov);
1207
+
1208
+ // Apply bounds: min 30px, max 200px
1209
+ distance = Math.max(
1210
+ 30,
1211
+ Math.min(200, distance)
1212
+ );
1213
+ const directionRad =
1214
+ (camera.direction * Math.PI) / 180;
1215
+
1216
+ // Calculate FOV triangle points in pixels
1217
+ const point1X =
1218
+ camera.x +
1219
+ distance *
1220
+ Math.cos(directionRad - fovRad / 2);
1221
+ const point1Y =
1222
+ camera.y +
1223
+ distance *
1224
+ Math.sin(directionRad - fovRad / 2);
1225
+ const point2X =
1226
+ camera.x +
1227
+ distance *
1228
+ Math.cos(directionRad + fovRad / 2);
1229
+ const point2Y =
1230
+ camera.y +
1231
+ distance *
1232
+ Math.sin(directionRad + fovRad / 2);
1233
+
1234
+ const fovColor =
1235
+ verkadaCamera.environment === 'indoor'
1236
+ ? '#10b981'
1237
+ : '#f59e0b';
1238
+
1239
+ // Create SVG path for the triangle
1240
+ const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
1241
+
1242
+ return (
1243
+ <svg
1244
+ key={`fov-${camera.id}`}
1245
+ className={styles.fovTriangle}
1246
+ style={{
1247
+ position: 'absolute',
1248
+ top: 0,
1249
+ left: 0,
1250
+ width: '100%',
1251
+ height: '100%',
1252
+ pointerEvents: 'none',
1253
+ zIndex: 1,
1254
+ }}
1255
+ >
1256
+ <path
1257
+ d={pathData}
1258
+ fill={fovColor}
1259
+ fillOpacity="0.25"
1260
+ stroke={fovColor}
1261
+ strokeOpacity="0.5"
1262
+ strokeWidth="2"
1263
+ />
1264
+ </svg>
1265
+ );
1266
+ })}
953
1267
 
954
- // Calculate FOV triangle points in pixels
955
- const point1X = camera.x + distance * Math.cos(directionRad - fovRad / 2);
956
- const point1Y = camera.y + distance * Math.sin(directionRad - fovRad / 2);
957
- const point2X = camera.x + distance * Math.cos(directionRad + fovRad / 2);
958
- const point2Y = camera.y + distance * Math.sin(directionRad + fovRad / 2);
1268
+ {/* Render camera markers on image */}
1269
+ {cameras.map((camera) => {
1270
+ const verkadaCamera = camera.verkadaModel
1271
+ ? getCameraById(camera.verkadaModel)
1272
+ : null;
1273
+ const environment = verkadaCamera
1274
+ ? verkadaCamera.environment
1275
+ : 'outdoor';
1276
+ const cameraNumber =
1277
+ (camera.customName || camera.name).replace(
1278
+ /\D/g,
1279
+ ''
1280
+ ) || camera.id;
959
1281
 
960
- const fovColor = verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b';
961
-
962
- // Create SVG path for the triangle
963
- const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
964
-
965
- return (
966
- <svg
967
- key={`fov-${camera.id}`}
968
- className={styles.fovTriangle}
969
- style={{
970
- position: 'absolute',
971
- top: 0,
972
- left: 0,
973
- width: '100%',
974
- height: '100%',
975
- pointerEvents: 'none',
976
- zIndex: 1,
977
- }}
978
- >
979
- <path
980
- d={pathData}
981
- fill={fovColor}
982
- fillOpacity="0.25"
983
- stroke={fovColor}
984
- strokeOpacity="0.5"
985
- strokeWidth="2"
986
- />
987
- </svg>
988
- );
989
- })}
990
-
991
- {/* Render camera markers on image */}
992
- {cameras.map((camera) => {
993
- const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
994
- const environment = verkadaCamera ? verkadaCamera.environment : 'outdoor';
995
- const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
996
-
997
- return (
998
- <div
999
- key={camera.id}
1000
- className={`camera-marker-container ${styles.imageMarker}`}
1001
- style={{
1002
- position: 'absolute',
1003
- left: camera.x - 20, // Adjusted for smaller circular marker
1004
- top: camera.y - 20, // Adjusted for smaller circular marker
1005
- transform: 'none',
1006
- zIndex: 2,
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(() => {
1282
+ return (
1283
+ <div
1284
+ key={camera.id}
1285
+ className={`camera-marker-container ${styles.imageMarker}`}
1286
+ style={{
1287
+ position: 'absolute',
1288
+ left: camera.x - 20, // Adjusted for smaller circular marker
1289
+ top: camera.y - 20, // Adjusted for smaller circular marker
1290
+ transform: 'none',
1291
+ zIndex: 2,
1292
+ }}
1293
+ onMouseEnter={(e) => {
1294
+ // Only show tooltip if not hovering over rotation handle
1295
+ if (
1296
+ verkadaCamera &&
1297
+ !e.target.closest(
1298
+ `.${styles.rotationHandle}`
1299
+ )
1300
+ ) {
1301
+ // Add delay to prevent tooltip from showing during quick rotations
1302
+ const targetElement =
1303
+ e.currentTarget;
1304
+ targetElement._tooltipTimeout =
1305
+ setTimeout(() => {
1014
1306
  // Check if element still exists and is in DOM
1015
- if (!targetElement || !targetElement.parentNode) {
1307
+ if (
1308
+ !targetElement ||
1309
+ !targetElement.parentNode
1310
+ ) {
1016
1311
  return;
1017
1312
  }
1018
-
1019
-
1020
- const tooltip = document.createElement('div');
1021
- tooltip.className = styles.cameraTooltip;
1313
+
1314
+ const tooltip =
1315
+ document.createElement(
1316
+ 'div'
1317
+ );
1318
+ tooltip.className =
1319
+ styles.cameraTooltip;
1022
1320
  tooltip.innerHTML = `
1023
1321
  <div class="${styles.tooltipImage}">
1024
1322
  <img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
@@ -1032,515 +1330,633 @@ const CameraPlacement = () => {
1032
1330
  </div>
1033
1331
  </div>
1034
1332
  `;
1035
-
1333
+
1036
1334
  // 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
-
1335
+ tooltip.style.left =
1336
+ '50px';
1337
+ tooltip.style.top =
1338
+ '-10px';
1339
+
1340
+ targetElement.appendChild(
1341
+ tooltip
1342
+ );
1343
+ targetElement._tooltip =
1344
+ tooltip;
1043
1345
  }, 300); // 300ms delay
1346
+ }
1347
+ }}
1348
+ onMouseLeave={(e) => {
1349
+ // Clear timeout if leaving before tooltip shows
1350
+ if (
1351
+ e.currentTarget._tooltipTimeout
1352
+ ) {
1353
+ clearTimeout(
1354
+ e.currentTarget
1355
+ ._tooltipTimeout
1356
+ );
1357
+ e.currentTarget._tooltipTimeout =
1358
+ null;
1359
+ }
1360
+ // Remove tooltip if it exists
1361
+ if (e.currentTarget._tooltip) {
1362
+ e.currentTarget.removeChild(
1363
+ e.currentTarget._tooltip
1364
+ );
1365
+ e.currentTarget._tooltip = null;
1366
+ }
1367
+ }}
1368
+ onMouseDown={(e) => {
1369
+ e.stopPropagation();
1370
+ let isDragging = false;
1371
+ let startTime = Date.now();
1372
+ const startX = e.clientX;
1373
+ const startY = e.clientY;
1374
+ const containerRect =
1375
+ imageContainer.current.getBoundingClientRect();
1376
+
1377
+ const handleMouseMove = (
1378
+ moveEvent
1379
+ ) => {
1380
+ const deltaX = Math.abs(
1381
+ moveEvent.clientX - startX
1382
+ );
1383
+ const deltaY = Math.abs(
1384
+ moveEvent.clientY - startY
1385
+ );
1386
+
1387
+ // Start dragging if moved more than 5px
1388
+ if (
1389
+ !isDragging &&
1390
+ (deltaX > 5 || deltaY > 5)
1391
+ ) {
1392
+ isDragging = true;
1044
1393
  }
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;
1394
+
1395
+ if (isDragging) {
1396
+ const newX =
1397
+ moveEvent.clientX -
1398
+ containerRect.left;
1399
+ const newY =
1400
+ moveEvent.clientY -
1401
+ containerRect.top;
1402
+
1403
+ // Update camera position
1404
+ updateCamera(camera.id, {
1405
+ x: newX,
1406
+ y: newY,
1407
+ });
1051
1408
  }
1052
- // Remove tooltip if it exists
1053
- if (e.currentTarget._tooltip) {
1054
- e.currentTarget.removeChild(e.currentTarget._tooltip);
1055
- e.currentTarget._tooltip = null;
1409
+ };
1410
+
1411
+ const handleMouseUp = () => {
1412
+ const clickDuration =
1413
+ Date.now() - startTime;
1414
+
1415
+ // If it was a quick click without dragging, show modal
1416
+ if (
1417
+ !isDragging &&
1418
+ clickDuration < 200
1419
+ ) {
1420
+ setSelectedCamera(camera);
1421
+ setShowModal(true);
1056
1422
  }
1057
- }}
1058
- onMouseDown={(e) => {
1059
- e.stopPropagation();
1060
- let isDragging = false;
1061
- let startTime = Date.now();
1062
- const startX = e.clientX;
1063
- const startY = e.clientY;
1064
- const containerRect = imageContainer.current.getBoundingClientRect();
1065
-
1066
- const handleMouseMove = (moveEvent) => {
1067
- const deltaX = Math.abs(moveEvent.clientX - startX);
1068
- const deltaY = Math.abs(moveEvent.clientY - startY);
1069
-
1070
- // Start dragging if moved more than 5px
1071
- if (!isDragging && (deltaX > 5 || deltaY > 5)) {
1072
- isDragging = true;
1073
- }
1074
-
1075
- if (isDragging) {
1076
- const newX = moveEvent.clientX - containerRect.left;
1077
- const newY = moveEvent.clientY - containerRect.top;
1078
-
1079
- // Update camera position
1080
- updateCamera(camera.id, { x: newX, y: newY });
1423
+
1424
+ document.removeEventListener(
1425
+ 'mousemove',
1426
+ handleMouseMove
1427
+ );
1428
+ document.removeEventListener(
1429
+ 'mouseup',
1430
+ handleMouseUp
1431
+ );
1432
+ };
1433
+
1434
+ document.addEventListener(
1435
+ 'mousemove',
1436
+ handleMouseMove
1437
+ );
1438
+ document.addEventListener(
1439
+ 'mouseup',
1440
+ handleMouseUp
1441
+ );
1442
+ }}
1443
+ >
1444
+ <div
1445
+ className={`${
1446
+ styles.circularMarker
1447
+ } ${environment} ${
1448
+ verkadaCamera?.ptz
1449
+ ? styles.ptzMarker
1450
+ : ''
1451
+ }`}
1452
+ >
1453
+ <div
1454
+ className={styles.markerNumber}
1455
+ >
1456
+ {cameraNumber}
1457
+ </div>
1458
+ <div
1459
+ className={
1460
+ styles.rotationHandle
1461
+ }
1462
+ style={{
1463
+ transform: `rotate(${
1464
+ camera.direction || 0
1465
+ }deg)`,
1466
+ }}
1467
+ onMouseEnter={(e) => {
1468
+ // Hide tooltip when hovering over rotation handle
1469
+ const container =
1470
+ e.currentTarget
1471
+ .parentElement
1472
+ .parentElement;
1473
+ if (
1474
+ container._tooltipTimeout
1475
+ ) {
1476
+ clearTimeout(
1477
+ container._tooltipTimeout
1478
+ );
1479
+ container._tooltipTimeout =
1480
+ null;
1081
1481
  }
1082
- };
1083
-
1084
- const handleMouseUp = () => {
1085
- const clickDuration = Date.now() - startTime;
1086
-
1087
- // If it was a quick click without dragging, show modal
1088
- if (!isDragging && clickDuration < 200) {
1089
- setSelectedCamera(camera);
1090
- setShowModal(true);
1482
+ if (container._tooltip) {
1483
+ container.removeChild(
1484
+ container._tooltip
1485
+ );
1486
+ container._tooltip =
1487
+ null;
1091
1488
  }
1092
-
1093
- document.removeEventListener('mousemove', handleMouseMove);
1094
- document.removeEventListener('mouseup', handleMouseUp);
1095
- };
1096
-
1097
- document.addEventListener('mousemove', handleMouseMove);
1098
- document.addEventListener('mouseup', handleMouseUp);
1099
- }}
1100
- >
1101
- <div className={`${styles.circularMarker} ${environment} ${verkadaCamera?.ptz ? styles.ptzMarker : ''}`}>
1102
- <div className={styles.markerNumber}>{cameraNumber}</div>
1103
- <div
1104
- className={styles.rotationHandle}
1105
- style={{
1106
- transform: `rotate(${camera.direction || 0}deg)`,
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;
1489
+ }}
1490
+ onMouseDown={(e) => {
1491
+ e.stopPropagation();
1492
+
1493
+ // Store references to prevent React event pooling issues
1494
+ const rotationHandle =
1495
+ e.currentTarget;
1496
+ const startMouseX =
1497
+ e.clientX;
1498
+ const startMouseY =
1499
+ e.clientY;
1500
+
1501
+ let isRotating = false;
1502
+ let startAngle = 0;
1503
+ let currentRotation =
1504
+ camera.direction || 0;
1505
+
1506
+ // Get the center of the camera marker
1507
+ const markerContainer =
1508
+ rotationHandle.parentElement;
1509
+ const markerRect =
1510
+ markerContainer.getBoundingClientRect();
1511
+ const centerX =
1512
+ markerRect.left +
1513
+ markerRect.width / 2;
1514
+ const centerY =
1515
+ markerRect.top +
1516
+ markerRect.height / 2;
1517
+
1518
+ // Calculate initial angle from center to mouse position
1519
+ const startDeltaX =
1520
+ startMouseX - centerX;
1521
+ const startDeltaY =
1522
+ startMouseY - centerY;
1523
+ startAngle =
1524
+ (Math.atan2(
1525
+ startDeltaX,
1526
+ -startDeltaY
1527
+ ) *
1528
+ 180) /
1529
+ Math.PI;
1530
+
1531
+ const handleRotationMove = (
1532
+ moveEvent
1533
+ ) => {
1534
+ const deltaX = Math.abs(
1535
+ moveEvent.clientX -
1536
+ startMouseX
1537
+ );
1538
+ const deltaY = Math.abs(
1539
+ moveEvent.clientY -
1540
+ startMouseY
1541
+ );
1542
+
1543
+ // Start rotating if mouse has moved more than 5 pixels
1544
+ if (
1545
+ !isRotating &&
1546
+ (deltaX > 5 ||
1547
+ deltaY > 5)
1548
+ ) {
1549
+ isRotating = true;
1550
+ setIsRotating(true);
1551
+ markerContainer.classList.add(
1552
+ 'rotating'
1553
+ );
1114
1554
  }
1115
- if (container._tooltip) {
1116
- container.removeChild(container._tooltip);
1117
- container._tooltip = null;
1118
- }
1119
- }}
1120
- onMouseDown={(e) => {
1121
-
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
-
1129
- let isRotating = false;
1130
- let startAngle = 0;
1131
- let currentRotation = camera.direction || 0;
1132
-
1133
- // Get the center of the camera marker
1134
- const markerContainer = rotationHandle.parentElement;
1135
- const markerRect = markerContainer.getBoundingClientRect();
1136
- const centerX = markerRect.left + markerRect.width / 2;
1137
- const centerY = markerRect.top + markerRect.height / 2;
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
-
1146
- const handleRotationMove = (moveEvent) => {
1147
- const deltaX = Math.abs(moveEvent.clientX - startMouseX);
1148
- const deltaY = Math.abs(moveEvent.clientY - startMouseY);
1149
-
1150
-
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');
1555
+
1556
+ if (isRotating) {
1557
+ // Calculate current angle from center to mouse position
1558
+ const currentDeltaX =
1559
+ moveEvent.clientX -
1560
+ centerX;
1561
+ const currentDeltaY =
1562
+ moveEvent.clientY -
1563
+ centerY;
1564
+ const currentAngle =
1565
+ (Math.atan2(
1566
+ currentDeltaX,
1567
+ -currentDeltaY
1568
+ ) *
1569
+ 180) /
1570
+ Math.PI;
1571
+
1572
+ // Calculate rotation delta
1573
+ let angleDelta =
1574
+ currentAngle -
1575
+ startAngle;
1576
+
1577
+ // Handle angle wrapping
1578
+ if (
1579
+ angleDelta > 180
1580
+ )
1581
+ angleDelta -= 360;
1582
+ if (
1583
+ angleDelta <
1584
+ -180
1585
+ )
1586
+ angleDelta += 360;
1587
+
1588
+ // Calculate new rotation
1589
+ let newRotation =
1590
+ (currentRotation +
1591
+ angleDelta +
1592
+ 360) %
1593
+ 360;
1594
+ newRotation =
1595
+ Math.round(
1596
+ newRotation
1597
+ );
1598
+
1599
+ // Update rotation handle visual using stored reference
1600
+ if (
1601
+ rotationHandle &&
1602
+ rotationHandle.style
1603
+ ) {
1604
+ rotationHandle.style.transform = `rotate(${newRotation}deg)`;
1605
+ } else {
1606
+ console.error(
1607
+ 'Rotation handle is null or has no style property'
1608
+ );
1155
1609
  }
1156
-
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');
1610
+
1611
+ // Update camera direction
1612
+ updateCamera(
1613
+ camera.id,
1614
+ {
1615
+ direction:
1616
+ newRotation,
1180
1617
  }
1181
-
1182
- // Update camera direction
1183
- updateCamera(camera.id, { direction: newRotation });
1184
- }
1185
- };
1186
-
1187
- const handleRotationEnd = () => {
1188
-
1618
+ );
1619
+ }
1620
+ };
1621
+
1622
+ const handleRotationEnd =
1623
+ () => {
1189
1624
  if (isRotating) {
1190
- markerContainer.classList.remove('rotating');
1625
+ markerContainer.classList.remove(
1626
+ 'rotating'
1627
+ );
1191
1628
  }
1192
- document.removeEventListener('mousemove', handleRotationMove);
1193
- document.removeEventListener('mouseup', handleRotationEnd);
1629
+ setIsRotating(
1630
+ false
1631
+ );
1632
+ document.removeEventListener(
1633
+ 'mousemove',
1634
+ handleRotationMove
1635
+ );
1636
+ document.removeEventListener(
1637
+ 'mouseup',
1638
+ handleRotationEnd
1639
+ );
1194
1640
  };
1195
-
1196
- document.addEventListener('mousemove', handleRotationMove);
1197
- document.addEventListener('mouseup', handleRotationEnd);
1198
- }}
1199
- />
1200
- </div>
1641
+
1642
+ document.addEventListener(
1643
+ 'mousemove',
1644
+ handleRotationMove
1645
+ );
1646
+ document.addEventListener(
1647
+ 'mouseup',
1648
+ handleRotationEnd
1649
+ );
1650
+ }}
1651
+ />
1201
1652
  </div>
1202
- );
1203
- })}
1653
+ </div>
1654
+ );
1655
+ })}
1656
+ </div>
1657
+ ) : (
1658
+ <div className={styles.uploadPlaceholder}>
1659
+ <div className={styles.uploadIcon}>
1660
+ <FileImage size={48} />
1204
1661
  </div>
1205
- ) : (
1206
- <div className={styles.uploadPlaceholder}>
1207
- <div className={styles.uploadIcon}>📁</div>
1208
- <h3>Upload an Image</h3>
1209
- <p>Click "Upload Image" to add a floor plan, site layout, or any image where you want to place cameras.</p>
1210
- <button
1211
- onClick={() => fileInputRef.current?.click()}
1212
- className={styles.uploadBtn}
1213
- >
1214
- Choose Image File
1215
- </button>
1662
+ <h3>Upload an Image</h3>
1663
+ <p>
1664
+ Drag and drop an image file here, or click the
1665
+ upload button to add a floor plan, site layout,
1666
+ or any image where you want to place cameras.
1667
+ </p>
1668
+ <button
1669
+ onClick={() => fileInputRef.current?.click()}
1670
+ className={styles.uploadBtn}
1671
+ >
1672
+ Choose Image File
1673
+ </button>
1674
+ <div className={styles.dragDropHint}>
1675
+ <Lightbulb
1676
+ size={16}
1677
+ className={styles.hintIcon}
1678
+ />
1679
+ <span>
1680
+ You can also drag and drop image files
1681
+ directly onto this area
1682
+ </span>
1216
1683
  </div>
1217
- )}
1218
- </div>
1684
+ </div>
1685
+ )}
1219
1686
  </div>
1220
-
1221
- {/* Hidden file input */}
1222
- <input
1223
- type="file"
1224
- ref={fileInputRef}
1225
- onChange={handleImageUpload}
1226
- accept="image/*"
1227
- style={{ display: 'none' }}
1228
- />
1687
+ </div>
1229
1688
 
1230
- <div
1231
- className={`${styles.sidebar} ${
1232
- sidebarCollapsed ? styles.collapsed : ''
1233
- }`}
1234
- >
1235
- <div className={styles.sidebarHeader}>
1236
- <h3 className={styles.sidebarTitle}>Camera List</h3>
1237
- <p className={styles.cameraCount}>
1238
- {cameras.length} camera
1239
- {cameras.length !== 1 ? 's' : ''} placed
1240
- </p>
1241
- </div>
1689
+ {/* Hidden file input */}
1690
+ <input
1691
+ type="file"
1692
+ ref={fileInputRef}
1693
+ onChange={handleImageUpload}
1694
+ accept="image/*"
1695
+ style={{ display: 'none' }}
1696
+ />
1697
+
1698
+ <div
1699
+ className={`${styles.sidebar} ${
1700
+ sidebarCollapsed ? styles.collapsed : ''
1701
+ }`}
1702
+ >
1703
+ <div className={styles.sidebarHeader}>
1704
+ <h3 className={styles.sidebarTitle}>Camera List</h3>
1705
+ <p className={styles.cameraCount}>
1706
+ {cameras.length} camera
1707
+ {cameras.length !== 1 ? 's' : ''} placed
1708
+ </p>
1709
+ </div>
1242
1710
 
1243
- <div className={styles.camerasList}>
1244
- {cameras.length === 0 ? (
1711
+ <div className={styles.camerasList}>
1712
+ {cameras.length === 0 ? (
1713
+ <div
1714
+ style={{
1715
+ textAlign: 'center',
1716
+ color: '#6c757d',
1717
+ padding: '40px 20px',
1718
+ fontStyle: 'italic',
1719
+ }}
1720
+ >
1721
+ <div>
1722
+ Upload an image first, then click anywhere on it
1723
+ to place cameras
1724
+ </div>
1245
1725
  <div
1246
1726
  style={{
1247
- textAlign: 'center',
1248
- color: '#6c757d',
1249
- padding: '40px 20px',
1250
- fontStyle: 'italic',
1727
+ fontSize: '12px',
1728
+ marginTop: '8px',
1251
1729
  }}
1252
1730
  >
1253
- <div>
1254
- Click "Add Camera" to place your first camera
1255
- </div>
1256
- <div
1257
- style={{
1258
- fontSize: '12px',
1259
- marginTop: '8px',
1260
- }}
1261
- >
1262
- Once placed, you can drag cameras to reposition them and use the rotation handle to adjust direction
1263
- </div>
1731
+ Once placed, you can drag cameras to reposition
1732
+ them and use the rotation handle to adjust
1733
+ direction
1264
1734
  </div>
1265
- ) : (
1266
- cameras.map((camera) => {
1267
- const verkadaCamera = camera.verkadaModel
1268
- ? getCameraById(camera.verkadaModel)
1269
- : null;
1735
+ </div>
1736
+ ) : (
1737
+ cameras.map((camera) => {
1738
+ const verkadaCamera = camera.verkadaModel
1739
+ ? getCameraById(camera.verkadaModel)
1740
+ : null;
1270
1741
 
1271
- return (
1272
- <div
1273
- key={camera.id}
1274
- className={styles.cameraItem}
1275
- >
1276
- <div className={styles.cameraHeader}>
1277
- <div className={styles.sidebarCameraInfo}>
1278
- {verkadaCamera && (
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'}}
1742
+ return (
1743
+ <div
1744
+ key={camera.id}
1745
+ className={styles.cameraItem}
1746
+ >
1747
+ <div className={styles.cameraHeader}>
1748
+ <div
1749
+ className={styles.sidebarCameraInfo}
1750
+ >
1751
+ {verkadaCamera && (
1752
+ <div
1753
+ className={
1754
+ styles.sidebarCameraImageContainer
1755
+ }
1756
+ >
1757
+ <img
1758
+ src={
1759
+ verkadaCamera.image
1760
+ }
1761
+ alt={verkadaCamera.name}
1762
+ className={
1763
+ styles.sidebarCameraImage
1764
+ }
1765
+ onLoad={(e) => {
1766
+ e.target.style.opacity =
1767
+ '1';
1768
+ }}
1769
+ onError={(e) => {
1770
+ // Fallback to icon if image fails to load
1771
+ e.target.style.display =
1772
+ 'none';
1773
+ e.target.nextSibling.style.display =
1774
+ 'flex';
1775
+ }}
1776
+ style={{
1777
+ opacity: '0.3',
1778
+ }}
1779
+ />
1780
+ <div
1781
+ className={
1782
+ styles.sidebarCameraIcon
1783
+ }
1784
+ style={{
1785
+ display: 'none',
1786
+ }}
1787
+ >
1788
+ <CameraIcon
1789
+ icon={
1790
+ verkadaCamera.icon
1791
+ }
1792
+ size={16}
1293
1793
  />
1294
- <div className={styles.sidebarCameraIcon} style={{display: 'none'}}>
1295
- <CameraIcon
1296
- icon={verkadaCamera.icon}
1297
- size={16}
1298
- />
1299
- </div>
1300
1794
  </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
1795
  </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"
1325
- >
1326
- <Edit3 size={16} />
1327
- </button>
1328
- <button
1329
- className={styles.iconBtn}
1330
- onClick={() => removeCamera(camera.id)}
1331
- title="Remove camera"
1796
+ )}
1797
+ <div
1798
+ className={
1799
+ styles.sidebarCameraText
1800
+ }
1801
+ >
1802
+ <h4
1803
+ className={
1804
+ styles.cameraName
1805
+ }
1332
1806
  >
1333
- <Trash2 size={16} />
1334
- </button>
1807
+ {camera.customName ||
1808
+ camera.name}
1809
+ </h4>
1810
+ {verkadaCamera && (
1811
+ <span
1812
+ className={`${
1813
+ styles.cameraType
1814
+ } ${
1815
+ styles[
1816
+ verkadaCamera
1817
+ .environment
1818
+ ]
1819
+ }`}
1820
+ >
1821
+ {verkadaCamera.id}
1822
+ </span>
1823
+ )}
1335
1824
  </div>
1336
1825
  </div>
1337
1826
 
1338
- {verkadaCamera ? (
1827
+ {/* Compact action buttons */}
1828
+ <div className={styles.cameraActions}>
1829
+ <button
1830
+ className={styles.iconBtn}
1831
+ onClick={() => {
1832
+ setSelectedCamera(camera);
1833
+ setShowModal(true);
1834
+ }}
1835
+ title="Edit camera"
1836
+ >
1837
+ <Edit3 size={16} />
1838
+ </button>
1839
+ <button
1840
+ className={styles.iconBtn}
1841
+ onClick={() =>
1842
+ removeCamera(camera.id)
1843
+ }
1844
+ title="Remove camera"
1845
+ >
1846
+ <Trash2 size={16} />
1847
+ </button>
1848
+ </div>
1849
+ </div>
1850
+
1851
+ {verkadaCamera ? (
1852
+ <div className={styles.cameraDetails}>
1853
+ <div
1854
+ className={
1855
+ modalStyles.cameraModelName
1856
+ }
1857
+ >
1858
+ <strong>
1859
+ {verkadaCamera.name}
1860
+ </strong>
1861
+ </div>
1862
+
1339
1863
  <div
1340
- className={styles.cameraDetails}
1864
+ className={
1865
+ modalStyles.cameraSpecGrid
1866
+ }
1341
1867
  >
1342
1868
  <div
1343
1869
  className={
1344
- modalStyles.cameraModelName
1870
+ modalStyles.specItem
1345
1871
  }
1346
1872
  >
1347
- <strong>
1348
- {verkadaCamera.name}
1349
- </strong>
1873
+ <span
1874
+ className={
1875
+ modalStyles.specLabel
1876
+ }
1877
+ >
1878
+ Resolution:
1879
+ </span>
1880
+ <span
1881
+ className={
1882
+ modalStyles.specValue
1883
+ }
1884
+ >
1885
+ {
1886
+ verkadaCamera.resolution
1887
+ }
1888
+ </span>
1350
1889
  </div>
1351
-
1352
1890
  <div
1353
1891
  className={
1354
- modalStyles.cameraSpecGrid
1892
+ modalStyles.specItem
1355
1893
  }
1356
1894
  >
1357
- <div
1895
+ <span
1358
1896
  className={
1359
- modalStyles.specItem
1897
+ modalStyles.specLabel
1360
1898
  }
1361
1899
  >
1362
- <span
1363
- className={
1364
- modalStyles.specLabel
1365
- }
1366
- >
1367
- Resolution:
1368
- </span>
1369
- <span
1370
- className={
1371
- modalStyles.specValue
1372
- }
1373
- >
1374
- {
1375
- verkadaCamera.resolution
1376
- }
1377
- </span>
1378
- </div>
1379
- <div
1900
+ FOV:
1901
+ </span>
1902
+ <span
1380
1903
  className={
1381
- modalStyles.specItem
1904
+ modalStyles.specValue
1382
1905
  }
1383
1906
  >
1384
- <span
1385
- className={
1386
- modalStyles.specLabel
1387
- }
1388
- >
1389
- FOV:
1390
- </span>
1391
- <span
1392
- className={
1393
- modalStyles.specValue
1394
- }
1395
- >
1396
- {verkadaCamera.fov}°
1397
- </span>
1398
- </div>
1399
- <div
1907
+ {verkadaCamera.fov}°
1908
+ </span>
1909
+ </div>
1910
+ <div
1911
+ className={
1912
+ modalStyles.specItem
1913
+ }
1914
+ >
1915
+ <span
1400
1916
  className={
1401
- modalStyles.specItem
1917
+ modalStyles.specLabel
1402
1918
  }
1403
1919
  >
1404
- <span
1405
- className={
1406
- modalStyles.specLabel
1407
- }
1408
- >
1409
- Environment:
1410
- </span>
1411
- <span
1412
- className={`${
1413
- modalStyles.specValue
1414
- } ${
1415
- modalStyles[
1416
- verkadaCamera
1417
- .environment
1418
- ]
1419
- }`}
1420
- >
1421
- {
1422
- verkadaCamera.environment
1423
- }
1424
- </span>
1425
- </div>
1426
- <div
1427
- className={
1428
- modalStyles.specItem
1429
- }
1920
+ Environment:
1921
+ </span>
1922
+ <span
1923
+ className={`${
1924
+ modalStyles.specValue
1925
+ } ${
1926
+ modalStyles[
1927
+ verkadaCamera
1928
+ .environment
1929
+ ]
1930
+ }`}
1430
1931
  >
1431
- <span
1432
- className={
1433
- modalStyles.specLabel
1434
- }
1435
- >
1436
- IR Range:
1437
- </span>
1438
- <span
1439
- className={
1440
- modalStyles.specValue
1441
- }
1442
- >
1443
- {
1444
- verkadaCamera.irRange
1445
- }
1446
- m
1447
- </span>
1448
- </div>
1449
- <div
1932
+ {
1933
+ verkadaCamera.environment
1934
+ }
1935
+ </span>
1936
+ </div>
1937
+ <div
1938
+ className={
1939
+ modalStyles.specItem
1940
+ }
1941
+ >
1942
+ <span
1450
1943
  className={
1451
- modalStyles.specItem
1944
+ modalStyles.specLabel
1452
1945
  }
1453
1946
  >
1454
- <span
1455
- className={
1456
- modalStyles.specLabel
1457
- }
1458
- >
1459
- Zoom:
1460
- </span>
1461
- <span
1462
- className={
1463
- modalStyles.specValue
1464
- }
1465
- >
1466
- {verkadaCamera.zoom}
1467
- </span>
1468
- </div>
1469
- <div
1947
+ IR Range:
1948
+ </span>
1949
+ <span
1470
1950
  className={
1471
- modalStyles.specItem
1951
+ modalStyles.specValue
1472
1952
  }
1473
1953
  >
1474
- <span
1475
- className={
1476
- modalStyles.specLabel
1477
- }
1478
- >
1479
- Rating:
1480
- </span>
1481
- <span
1482
- className={
1483
- modalStyles.specValue
1484
- }
1485
- >
1486
- {
1487
- verkadaCamera.rating
1488
- }
1489
- </span>
1490
- </div>
1491
- {verkadaCamera.ptz && (
1492
- <>
1493
- <div
1494
- className={
1495
- modalStyles.specItem
1496
- }
1497
- >
1498
- <span
1499
- className={
1500
- modalStyles.specLabel
1501
- }
1502
- >
1503
- Pan:
1504
- </span>
1505
- <span
1506
- className={
1507
- modalStyles.specValue
1508
- }
1509
- >
1510
- {
1511
- verkadaCamera.pan
1512
- }
1513
- </span>
1514
- </div>
1515
- <div
1516
- className={
1517
- modalStyles.specItem
1518
- }
1519
- >
1520
- <span
1521
- className={
1522
- modalStyles.specLabel
1523
- }
1524
- >
1525
- Tilt:
1526
- </span>
1527
- <span
1528
- className={
1529
- modalStyles.specValue
1530
- }
1531
- >
1532
- {
1533
- verkadaCamera.tilt
1534
- }
1535
- </span>
1536
- </div>
1537
- </>
1538
- )}
1954
+ {verkadaCamera.irRange}m
1955
+ </span>
1539
1956
  </div>
1540
-
1541
1957
  <div
1542
1958
  className={
1543
- modalStyles.cameraFeatures
1959
+ modalStyles.specItem
1544
1960
  }
1545
1961
  >
1546
1962
  <span
@@ -1548,153 +1964,231 @@ const CameraPlacement = () => {
1548
1964
  modalStyles.specLabel
1549
1965
  }
1550
1966
  >
1551
- Features:
1967
+ Zoom:
1552
1968
  </span>
1553
- <div
1969
+ <span
1554
1970
  className={
1555
- modalStyles.featuresList
1971
+ modalStyles.specValue
1556
1972
  }
1557
1973
  >
1558
- {verkadaCamera.features.map(
1559
- (
1560
- feature,
1561
- index
1562
- ) => (
1563
- <span
1564
- key={index}
1565
- className={
1566
- modalStyles.featureTag
1567
- }
1568
- >
1569
- {feature}
1570
- </span>
1571
- )
1572
- )}
1573
- </div>
1974
+ {verkadaCamera.zoom}
1975
+ </span>
1574
1976
  </div>
1575
-
1576
1977
  <div
1577
1978
  className={
1578
- modalStyles.directionControl
1979
+ modalStyles.specItem
1579
1980
  }
1580
1981
  >
1581
- <label>
1582
- Direction:{' '}
1583
- {camera.direction}°
1584
- </label>
1585
- <input
1586
- type="range"
1587
- min="0"
1588
- max="360"
1589
- value={camera.direction}
1590
- onChange={(e) =>
1591
- updateCamera(
1592
- camera.id,
1593
- {
1594
- direction:
1595
- parseInt(
1596
- e
1597
- .target
1598
- .value
1599
- ),
1600
- }
1601
- )
1602
- }
1982
+ <span
1603
1983
  className={
1604
- modalStyles.directionSlider
1984
+ modalStyles.specLabel
1605
1985
  }
1606
- />
1607
- <div
1986
+ >
1987
+ Rating:
1988
+ </span>
1989
+ <span
1608
1990
  className={
1609
- modalStyles.directionHelper
1991
+ modalStyles.specValue
1610
1992
  }
1611
1993
  >
1612
- 0° = North, 90° = East,
1613
- 180° = South, 270° =
1614
- West
1615
- </div>
1994
+ {verkadaCamera.rating}
1995
+ </span>
1616
1996
  </div>
1997
+ {verkadaCamera.ptz && (
1998
+ <>
1999
+ <div
2000
+ className={
2001
+ modalStyles.specItem
2002
+ }
2003
+ >
2004
+ <span
2005
+ className={
2006
+ modalStyles.specLabel
2007
+ }
2008
+ >
2009
+ Pan:
2010
+ </span>
2011
+ <span
2012
+ className={
2013
+ modalStyles.specValue
2014
+ }
2015
+ >
2016
+ {
2017
+ verkadaCamera.pan
2018
+ }
2019
+ </span>
2020
+ </div>
2021
+ <div
2022
+ className={
2023
+ modalStyles.specItem
2024
+ }
2025
+ >
2026
+ <span
2027
+ className={
2028
+ modalStyles.specLabel
2029
+ }
2030
+ >
2031
+ Tilt:
2032
+ </span>
2033
+ <span
2034
+ className={
2035
+ modalStyles.specValue
2036
+ }
2037
+ >
2038
+ {
2039
+ verkadaCamera.tilt
2040
+ }
2041
+ </span>
2042
+ </div>
2043
+ </>
2044
+ )}
1617
2045
  </div>
1618
- ) : (
2046
+
1619
2047
  <div
1620
- className={styles.cameraDetails}
2048
+ className={
2049
+ modalStyles.cameraFeatures
2050
+ }
1621
2051
  >
1622
- <div
2052
+ <span
1623
2053
  className={
1624
- modalStyles.noModel
2054
+ modalStyles.specLabel
1625
2055
  }
1626
2056
  >
1627
- No Verkada model selected -
1628
- Click "Edit" to choose a
1629
- camera
1630
- </div>
2057
+ Features:
2058
+ </span>
1631
2059
  <div
1632
2060
  className={
1633
- modalStyles.directionControl
2061
+ modalStyles.featuresList
1634
2062
  }
1635
2063
  >
1636
- <label>
1637
- Direction:{' '}
1638
- {camera.direction}°
1639
- </label>
1640
- <input
1641
- type="range"
1642
- min="0"
1643
- max="360"
1644
- value={camera.direction}
1645
- onChange={(e) =>
1646
- updateCamera(
1647
- camera.id,
1648
- {
1649
- direction:
1650
- parseInt(
1651
- e
1652
- .target
1653
- .value
1654
- ),
2064
+ {verkadaCamera.features.map(
2065
+ (feature, index) => (
2066
+ <span
2067
+ key={index}
2068
+ className={
2069
+ modalStyles.featureTag
1655
2070
  }
1656
- )
1657
- }
1658
- className={
1659
- modalStyles.directionSlider
1660
- }
1661
- />
1662
- <div
1663
- className={
1664
- modalStyles.directionHelper
1665
- }
1666
- >
1667
- 0° = North, 90° = East,
1668
- 180° = South, 270° =
1669
- West
1670
- </div>
2071
+ >
2072
+ {feature}
2073
+ </span>
2074
+ )
2075
+ )}
1671
2076
  </div>
1672
2077
  </div>
1673
- )}
1674
2078
 
1675
- <div className={styles.cameraCoords}>
1676
- <span className={styles.coordLabel}>
1677
- Location:
1678
- </span>
1679
- {mode === 'map' ? (
1680
- <>
1681
- {camera.lat?.toFixed(6)},{' '}
1682
- {camera.lng?.toFixed(6)}
1683
- </>
1684
- ) : (
1685
- <>
1686
- X: {Math.round(camera.x)}, Y: {Math.round(camera.y)}
1687
- </>
1688
- )}
2079
+ <div
2080
+ className={
2081
+ modalStyles.directionControl
2082
+ }
2083
+ >
2084
+ <label>
2085
+ Direction:{' '}
2086
+ {camera.direction}°
2087
+ </label>
2088
+ <input
2089
+ type="range"
2090
+ min="0"
2091
+ max="360"
2092
+ value={camera.direction}
2093
+ onChange={(e) =>
2094
+ updateCamera(
2095
+ camera.id,
2096
+ {
2097
+ direction:
2098
+ parseInt(
2099
+ e.target
2100
+ .value
2101
+ ),
2102
+ }
2103
+ )
2104
+ }
2105
+ className={
2106
+ modalStyles.directionSlider
2107
+ }
2108
+ />
2109
+ <div
2110
+ className={
2111
+ modalStyles.directionHelper
2112
+ }
2113
+ >
2114
+ 0° = North, 90° = East, 180°
2115
+ = South, 270° = West
2116
+ </div>
2117
+ </div>
1689
2118
  </div>
2119
+ ) : (
2120
+ <div className={styles.cameraDetails}>
2121
+ <div
2122
+ className={modalStyles.noModel}
2123
+ >
2124
+ No Verkada model selected -
2125
+ Click "Edit" to choose a camera
2126
+ </div>
2127
+ <div
2128
+ className={
2129
+ modalStyles.directionControl
2130
+ }
2131
+ >
2132
+ <label>
2133
+ Direction:{' '}
2134
+ {camera.direction}°
2135
+ </label>
2136
+ <input
2137
+ type="range"
2138
+ min="0"
2139
+ max="360"
2140
+ value={camera.direction}
2141
+ onChange={(e) =>
2142
+ updateCamera(
2143
+ camera.id,
2144
+ {
2145
+ direction:
2146
+ parseInt(
2147
+ e.target
2148
+ .value
2149
+ ),
2150
+ }
2151
+ )
2152
+ }
2153
+ className={
2154
+ modalStyles.directionSlider
2155
+ }
2156
+ />
2157
+ <div
2158
+ className={
2159
+ modalStyles.directionHelper
2160
+ }
2161
+ >
2162
+ 0° = North, 90° = East, 180°
2163
+ = South, 270° = West
2164
+ </div>
2165
+ </div>
2166
+ </div>
2167
+ )}
1690
2168
 
2169
+ <div className={styles.cameraCoords}>
2170
+ <span className={styles.coordLabel}>
2171
+ Location:
2172
+ </span>
2173
+ {mode === 'map' ? (
2174
+ <>
2175
+ {camera.lat?.toFixed(6)},{' '}
2176
+ {camera.lng?.toFixed(6)}
2177
+ </>
2178
+ ) : (
2179
+ <>
2180
+ X: {Math.round(camera.x)}, Y:{' '}
2181
+ {Math.round(camera.y)}
2182
+ </>
2183
+ )}
1691
2184
  </div>
1692
- );
1693
- })
1694
- )}
1695
- </div>
2185
+ </div>
2186
+ );
2187
+ })
2188
+ )}
1696
2189
  </div>
1697
-
2190
+ </div>
2191
+
1698
2192
  {showModal && selectedCamera && (
1699
2193
  <CameraSelectionModal
1700
2194
  camera={selectedCamera}
@@ -1714,9 +2208,11 @@ const CameraPlacement = () => {
1714
2208
  const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1715
2209
  // Determine if this is editing an existing camera or adding a new one
1716
2210
  const isUpdating = Boolean(camera.verkadaModel);
1717
-
2211
+
1718
2212
  const [selectedCategory, setSelectedCategory] = useState('all');
1719
- const [selectedCamera, setSelectedCamera] = useState(camera.verkadaModel ? getCameraById(camera.verkadaModel) : null);
2213
+ const [selectedCamera, setSelectedCamera] = useState(
2214
+ camera.verkadaModel ? getCameraById(camera.verkadaModel) : null
2215
+ );
1720
2216
 
1721
2217
  // Filter cameras based on selected category
1722
2218
  const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
@@ -1724,7 +2220,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1724
2220
  return cam.type === selectedCategory;
1725
2221
  });
1726
2222
 
1727
-
1728
2223
  const handleCameraSelect = (cameraModel) => {
1729
2224
  setSelectedCamera(cameraModel);
1730
2225
  };
@@ -1745,29 +2240,43 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1745
2240
  <div className={styles.cameraSelectionModal}>
1746
2241
  <div className={styles.modalHeader}>
1747
2242
  <h3>Select Verkada Camera</h3>
1748
- <button className={styles.closeBtn} onClick={onClose}>×</button>
2243
+ <button className={styles.closeBtn} onClick={onClose}>
2244
+ ×
2245
+ </button>
1749
2246
  </div>
1750
2247
 
1751
2248
  <div className={styles.modalContent}>
1752
2249
  <div className={styles.topSection}>
1753
- <div className={styles.categoryFilter}>
2250
+ <div className={styles.categoryFilter}>
1754
2251
  <label>Filter by Category:</label>
1755
2252
  <div className={styles.categoryButtons}>
1756
2253
  <button
1757
- className={selectedCategory === 'all' ? styles.active : ''}
2254
+ className={
2255
+ selectedCategory === 'all'
2256
+ ? styles.active
2257
+ : ''
2258
+ }
1758
2259
  onClick={() => setSelectedCategory('all')}
1759
2260
  >
1760
2261
  All Cameras
1761
2262
  </button>
1762
- {Object.entries(CAMERA_CATEGORIES).map(([key, category]) => (
1763
- <button
1764
- key={key}
1765
- className={selectedCategory === key ? styles.active : ''}
1766
- onClick={() => setSelectedCategory(key)}
1767
- >
1768
- {category.name}
1769
- </button>
1770
- ))}
2263
+ {Object.entries(CAMERA_CATEGORIES).map(
2264
+ ([key, category]) => (
2265
+ <button
2266
+ key={key}
2267
+ className={
2268
+ selectedCategory === key
2269
+ ? styles.active
2270
+ : ''
2271
+ }
2272
+ onClick={() =>
2273
+ setSelectedCategory(key)
2274
+ }
2275
+ >
2276
+ {category.name}
2277
+ </button>
2278
+ )
2279
+ )}
1771
2280
  </div>
1772
2281
  </div>
1773
2282
  </div>
@@ -1776,13 +2285,19 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1776
2285
  {filteredCameras.map((cam) => (
1777
2286
  <div
1778
2287
  key={cam.id}
1779
- className={`${styles.cameraCard} ${selectedCamera?.id === cam.id ? styles.selectedCard : ''}`}
2288
+ className={`${styles.cameraCard} ${
2289
+ selectedCamera?.id === cam.id
2290
+ ? styles.selectedCard
2291
+ : ''
2292
+ }`}
1780
2293
  onClick={() => handleCameraSelect(cam)}
1781
2294
  >
1782
2295
  <div className={styles.cardHeader}>
1783
- <div className={styles.cameraImageContainer}>
1784
- <img
1785
- src={cam.image}
2296
+ <div
2297
+ className={styles.cameraImageContainer}
2298
+ >
2299
+ <img
2300
+ src={cam.image}
1786
2301
  alt={cam.name}
1787
2302
  className={styles.cameraImage}
1788
2303
  onLoad={(e) => {
@@ -1791,49 +2306,83 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1791
2306
  onError={(e) => {
1792
2307
  // Fallback to icon if image fails to load
1793
2308
  e.target.style.display = 'none';
1794
- e.target.nextSibling.style.display = 'flex';
2309
+ e.target.nextSibling.style.display =
2310
+ 'flex';
1795
2311
  }}
1796
- style={{opacity: '0.3'}}
2312
+ style={{ opacity: '0.3' }}
1797
2313
  />
1798
- <div className={styles.cameraIcon} style={{display: 'none'}}>
1799
- <CameraIcon icon={cam.icon} size={24} />
2314
+ <div
2315
+ className={styles.cameraIcon}
2316
+ style={{ display: 'none' }}
2317
+ >
2318
+ <CameraIcon
2319
+ icon={cam.icon}
2320
+ size={24}
2321
+ />
1800
2322
  </div>
1801
2323
  </div>
1802
2324
  <div className={styles.cameraTitle}>
1803
2325
  <h4>{cam.name}</h4>
1804
- <span className={`${styles.environmentBadge} ${styles[cam.environment]}`}>
2326
+ <span
2327
+ className={`${
2328
+ styles.environmentBadge
2329
+ } ${styles[cam.environment]}`}
2330
+ >
1805
2331
  {cam.environment}
1806
2332
  </span>
1807
2333
  </div>
1808
2334
  </div>
1809
-
2335
+
1810
2336
  <div className={styles.cardBody}>
1811
2337
  <div className={styles.specRow}>
1812
- <span className={styles.specLabel}>Resolution:</span>
1813
- <span className={styles.specValue}>{cam.resolution}</span>
2338
+ <span className={styles.specLabel}>
2339
+ Resolution:
2340
+ </span>
2341
+ <span className={styles.specValue}>
2342
+ {cam.resolution}
2343
+ </span>
1814
2344
  </div>
1815
2345
  <div className={styles.specRow}>
1816
- <span className={styles.specLabel}>FOV:</span>
1817
- <span className={styles.specValue}>{cam.fov}°</span>
2346
+ <span className={styles.specLabel}>
2347
+ FOV:
2348
+ </span>
2349
+ <span className={styles.specValue}>
2350
+ {cam.fov}°
2351
+ </span>
1818
2352
  </div>
1819
2353
  <div className={styles.specRow}>
1820
- <span className={styles.specLabel}>Range:</span>
1821
- <span className={styles.specValue}>{cam.irRange}m IR</span>
2354
+ <span className={styles.specLabel}>
2355
+ Range:
2356
+ </span>
2357
+ <span className={styles.specValue}>
2358
+ {cam.irRange}m IR
2359
+ </span>
1822
2360
  </div>
1823
2361
  {cam.ptz && (
1824
- <div className={styles.ptzBadge}>PTZ</div>
2362
+ <div className={styles.ptzBadge}>
2363
+ PTZ
2364
+ </div>
1825
2365
  )}
1826
2366
  </div>
1827
2367
 
1828
2368
  <div className={styles.cardFooter}>
1829
2369
  <div className={styles.features}>
1830
- {cam.features?.slice(0, 2).map((feature, index) => (
1831
- <span key={index} className={styles.featureBadge}>
1832
- {feature}
1833
- </span>
1834
- ))}
2370
+ {cam.features
2371
+ ?.slice(0, 2)
2372
+ .map((feature, index) => (
2373
+ <span
2374
+ key={index}
2375
+ className={
2376
+ styles.featureBadge
2377
+ }
2378
+ >
2379
+ {feature}
2380
+ </span>
2381
+ ))}
1835
2382
  {cam.features?.length > 2 && (
1836
- <span className={styles.moreFeatures}>
2383
+ <span
2384
+ className={styles.moreFeatures}
2385
+ >
1837
2386
  +{cam.features.length - 2} more
1838
2387
  </span>
1839
2388
  )}
@@ -1854,8 +2403,8 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1854
2403
  <button onClick={onClose} className={styles.cancelBtn}>
1855
2404
  Cancel
1856
2405
  </button>
1857
- <button
1858
- onClick={handleSave}
2406
+ <button
2407
+ onClick={handleSave}
1859
2408
  disabled={!selectedCamera}
1860
2409
  className={styles.saveBtn}
1861
2410
  >