@visns-studio/visns-components 5.15.5 → 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,6 +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 {
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';
6
21
  import 'mapbox-gl/dist/mapbox-gl.css';
7
22
  import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
8
23
  import styles from './styles/CameraPlacement.module.scss';
@@ -23,63 +38,149 @@ const CameraPlacement = () => {
23
38
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
24
39
  const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
25
40
  const [uploadedImage, setUploadedImage] = useState(null);
26
- const [addCameraMode, setAddCameraMode] = useState(false);
27
41
  const mapContainer = useRef(null);
28
42
  const mapRef = useRef(null);
29
43
  const geocoderRef = useRef(null);
30
44
  const imageContainer = useRef(null);
31
45
  const fileInputRef = useRef(null);
46
+ const [isDragOver, setIsDragOver] = useState(false);
47
+ const [isRotating, setIsRotating] = useState(false);
32
48
 
33
49
  // Mapbox configuration
34
- const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
50
+ const MAPBOX_ACCESS_TOKEN =
51
+ 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
35
52
 
36
53
  // Handle image upload
37
- const handleImageUpload = useCallback((event) => {
38
- const file = event.target.files[0];
39
- if (file && file.type.startsWith('image/')) {
40
- const reader = new FileReader();
41
- reader.onload = (e) => {
42
- setUploadedImage(e.target.result);
43
- setMode('image');
44
- // Reset cameras when switching to image mode
45
- setCameras([]);
46
- // Cleanup map if it exists
47
- if (map) {
48
- try {
49
- map.remove();
50
- } catch (error) {
51
- 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);
52
72
  }
53
- setMap(null);
54
- }
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,
55
113
  };
56
- reader.readAsDataURL(file);
114
+
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
+ }, []);
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);
57
142
  }
58
- }, [map]);
59
-
60
- // Handle image click for camera placement (only when in add camera mode)
61
- const handleImageClick = useCallback((event) => {
62
- if (mode !== 'image' || !imageContainer.current || !addCameraMode) return;
63
-
64
- const rect = imageContainer.current.getBoundingClientRect();
65
- const x = event.clientX - rect.left;
66
- const y = event.clientY - rect.top;
67
-
68
- const newCamera = {
69
- id: Date.now(),
70
- x: x, // Image coordinates instead of lat/lng
71
- y: y,
72
- name: `Camera ${cameras.length + 1}`,
73
- verkadaModel: null,
74
- customName: '',
75
- direction: 0,
76
- };
143
+ }, []);
77
144
 
78
- setCameras((prev) => [...prev, newCamera]);
79
- setSelectedCamera(newCamera);
80
- setShowModal(true);
81
- setAddCameraMode(false); // Exit add camera mode after placing
82
- }, [mode, cameras.length, addCameraMode]);
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
+ );
83
184
 
84
185
  // Initialize Mapbox GL map
85
186
  useEffect(() => {
@@ -183,30 +284,52 @@ const CameraPlacement = () => {
183
284
  }, [map, handleMapClick]);
184
285
 
185
286
  const updateCamera = useCallback((cameraId, updates) => {
186
- setCameras((prev) =>
187
- prev.map((cam) =>
287
+ setCameras((prev) => {
288
+ const updated = prev.map((cam) =>
188
289
  cam.id === cameraId ? { ...cam, ...updates } : cam
189
- )
190
- );
290
+ );
291
+ return updated;
292
+ });
191
293
  }, []);
192
294
 
193
295
  const removeCamera = useCallback(
194
296
  (cameraId) => {
195
- 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';
196
302
 
197
- // Remove FOV layer and source from map
198
- if (map) {
199
- const layerId = `fov-layer-${cameraId}`;
200
- const sourceId = `fov-${cameraId}`;
201
- if (map.getLayer(layerId)) {
202
- map.removeLayer(layerId);
203
- }
204
- if (map.getSource(sourceId)) {
205
- 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
+ }
206
329
  }
207
- }
330
+ });
208
331
  },
209
- [map]
332
+ [map, cameras]
210
333
  );
211
334
 
212
335
  // Save placement as JSON file
@@ -216,7 +339,7 @@ const CameraPlacement = () => {
216
339
  icon: 'warning',
217
340
  title: 'No Data to Save',
218
341
  text: 'Please add cameras or upload an image first.',
219
- confirmButtonColor: '#8b5cf6'
342
+ confirmButtonColor: '#8b5cf6',
220
343
  });
221
344
  return;
222
345
  }
@@ -224,12 +347,14 @@ const CameraPlacement = () => {
224
347
  const saveData = {
225
348
  version: '1.0',
226
349
  timestamp: new Date().toISOString(),
227
- image: uploadedImage ? {
228
- src: uploadedImage,
229
- name: 'uploaded-image'
230
- } : null,
350
+ image: uploadedImage
351
+ ? {
352
+ src: uploadedImage,
353
+ name: 'uploaded-image',
354
+ }
355
+ : null,
231
356
  mode: mode,
232
- cameras: cameras.map(camera => ({
357
+ cameras: cameras.map((camera) => ({
233
358
  id: camera.id,
234
359
  name: camera.name,
235
360
  customName: camera.customName,
@@ -237,15 +362,19 @@ const CameraPlacement = () => {
237
362
  y: camera.y,
238
363
  direction: camera.direction,
239
364
  verkadaModel: camera.verkadaModel,
240
- timestamp: camera.timestamp
241
- }))
365
+ timestamp: camera.timestamp,
366
+ })),
242
367
  };
243
368
 
244
369
  const dataStr = JSON.stringify(saveData, null, 2);
245
- const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
246
-
247
- const exportFileDefaultName = `camera-placement-${new Date().toISOString().split('T')[0]}.json`;
248
-
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
+
249
378
  const linkElement = document.createElement('a');
250
379
  linkElement.setAttribute('href', dataUri);
251
380
  linkElement.setAttribute('download', exportFileDefaultName);
@@ -259,7 +388,7 @@ const CameraPlacement = () => {
259
388
  timer: 3000,
260
389
  showConfirmButton: false,
261
390
  toast: true,
262
- position: 'top-end'
391
+ position: 'top-end',
263
392
  });
264
393
  }, [cameras, uploadedImage, mode]);
265
394
 
@@ -268,60 +397,61 @@ const CameraPlacement = () => {
268
397
  const input = document.createElement('input');
269
398
  input.type = 'file';
270
399
  input.accept = '.json';
271
-
400
+
272
401
  input.onchange = (event) => {
273
402
  const file = event.target.files[0];
274
403
  if (!file) return;
275
-
404
+
276
405
  const reader = new FileReader();
277
406
  reader.onload = (e) => {
278
407
  try {
279
408
  const saveData = JSON.parse(e.target.result);
280
-
409
+
281
410
  // Validate data structure
282
411
  if (!saveData.version || !Array.isArray(saveData.cameras)) {
283
412
  Swal.fire({
284
413
  icon: 'error',
285
414
  title: 'Invalid File',
286
415
  text: 'Please select a valid camera placement file.',
287
- confirmButtonColor: '#f59e0b'
416
+ confirmButtonColor: '#f59e0b',
288
417
  });
289
418
  return;
290
419
  }
291
-
420
+
292
421
  // Load image if present
293
422
  if (saveData.image && saveData.image.src) {
294
423
  setUploadedImage(saveData.image.src);
295
424
  }
296
-
425
+
297
426
  // Set mode
298
427
  if (saveData.mode) {
299
428
  setMode(saveData.mode);
300
429
  }
301
-
430
+
302
431
  // Load cameras
303
432
  setCameras(saveData.cameras || []);
304
-
433
+
305
434
  Swal.fire({
306
435
  icon: 'success',
307
436
  title: 'File Loaded!',
308
- text: `Successfully loaded ${saveData.cameras?.length || 0} cameras from ${file.name}`,
309
- confirmButtonColor: '#f59e0b'
437
+ text: `Successfully loaded ${
438
+ saveData.cameras?.length || 0
439
+ } cameras from ${file.name}`,
440
+ confirmButtonColor: '#f59e0b',
310
441
  });
311
-
312
442
  } catch (error) {
313
443
  console.error('Error loading file:', error);
314
444
  Swal.fire({
315
445
  icon: 'error',
316
446
  title: 'File Error',
317
- text: 'Error reading file. Please ensure it\'s a valid JSON file.',
318
- confirmButtonColor: '#f59e0b'
447
+ text: "Error reading file. Please ensure it's a valid JSON file.",
448
+ confirmButtonColor: '#f59e0b',
319
449
  });
320
450
  }
321
451
  };
322
452
  reader.readAsText(file);
323
453
  };
324
-
454
+
325
455
  input.click();
326
456
  }, []);
327
457
 
@@ -332,18 +462,20 @@ const CameraPlacement = () => {
332
462
  icon: 'warning',
333
463
  title: 'No Cameras',
334
464
  text: 'Please add cameras first before exporting.',
335
- confirmButtonColor: '#10b981'
465
+ confirmButtonColor: '#10b981',
336
466
  });
337
467
  return;
338
468
  }
339
469
 
340
- const imageContainer = document.querySelector(`.${styles.imageContainer}`);
470
+ const imageContainer = document.querySelector(
471
+ `.${styles.imageContainer}`
472
+ );
341
473
  if (!imageContainer) {
342
474
  Swal.fire({
343
475
  icon: 'warning',
344
476
  title: 'No Image',
345
477
  text: 'Please upload an image first before exporting.',
346
- confirmButtonColor: '#10b981'
478
+ confirmButtonColor: '#10b981',
347
479
  });
348
480
  return;
349
481
  }
@@ -354,17 +486,26 @@ const CameraPlacement = () => {
354
486
  allowTaint: true,
355
487
  scale: 1.2,
356
488
  backgroundColor: '#f8fafc',
357
- logging: false
489
+ logging: false,
358
490
  });
359
491
 
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
365
-
492
+ // Calculate dimensions for professional report layout
493
+ const cameraListWidth = 550; // Fixed width for professional report
494
+ const headerHeight = 140; // Space for report header
495
+ const summaryHeight = 120; // Space for project summary
496
+ const cameraHeight = 180; // More space per camera card
497
+ const footerHeight = 300; // Space for legend and notes
498
+ const totalCameraListHeight =
499
+ headerHeight +
500
+ summaryHeight +
501
+ cameras.length * cameraHeight +
502
+ footerHeight;
503
+
366
504
  // Use the larger of image height or camera list height
367
- const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
505
+ const finalHeight = Math.max(
506
+ imageCanvas.height,
507
+ totalCameraListHeight
508
+ );
368
509
  const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
369
510
 
370
511
  // Create the composite container with calculated dimensions
@@ -377,7 +518,8 @@ const CameraPlacement = () => {
377
518
  exportContainer.style.backgroundColor = '#ffffff';
378
519
  exportContainer.style.display = 'flex';
379
520
  exportContainer.style.gap = '10px';
380
- 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';
381
523
  document.body.appendChild(exportContainer);
382
524
 
383
525
  // Left side: Image
@@ -397,7 +539,7 @@ const CameraPlacement = () => {
397
539
  scaledCanvas.width = imageCanvas.width;
398
540
  scaledCanvas.height = imageCanvas.height;
399
541
  ctx.drawImage(imageCanvas, 0, 0);
400
-
542
+
401
543
  imageDiv.appendChild(scaledCanvas);
402
544
  exportContainer.appendChild(imageDiv);
403
545
 
@@ -410,48 +552,235 @@ const CameraPlacement = () => {
410
552
  cameraListDiv.style.overflow = 'visible';
411
553
  cameraListDiv.style.boxSizing = 'border-box';
412
554
 
413
- // Generate optimized camera list HTML
555
+ // Generate professional report-style HTML
414
556
  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>
557
+ <!-- Report Header -->
558
+ <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);">
559
+ <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>
560
+ <div style="text-align: center; margin-bottom: 24px;">
561
+ <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;">
562
+ PROJECT LAYOUT & SPECIFICATIONS
563
+ </div>
564
+ </div>
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;">
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>
575
+ <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
576
+ </div>
419
577
  </div>
420
578
 
421
- ${cameras.map((camera) => {
422
- const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
423
- const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
424
-
425
- 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>
579
+
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 `
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;">
590
+ <!-- Camera Header -->
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;">
596
+ <div style="display: flex; align-items: center; gap: 16px;">
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>
598
+ <div style="flex: 1;">
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>
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>
605
+ </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
+ }
432
611
  </div>
433
612
  </div>
434
613
 
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
- `}
614
+ <!-- Camera Details -->
615
+ <div style="padding: 20px;">
616
+ ${
617
+ verkadaCamera
618
+ ? `
619
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
620
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
621
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
622
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
623
+ verkadaCamera.resolution
624
+ }</div>
625
+ </div>
626
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
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>
628
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
629
+ verkadaCamera.fov
630
+ }°</div>
631
+ </div>
632
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${
633
+ verkadaCamera.environment ===
634
+ 'indoor'
635
+ ? '#10b981'
636
+ : '#f59e0b'
637
+ };">
638
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">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>
642
+ </div>
643
+ <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
644
+ <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
645
+ <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
646
+ verkadaCamera.irRange
647
+ }m</div>
648
+ </div>
649
+ </div>
650
+
651
+ <!-- Position & Direction -->
652
+ <div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
653
+ <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>
654
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
655
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
656
+ <div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</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>
662
+ </div>
663
+ <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
664
+ <div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</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>
678
+ </div>
679
+ </div>
680
+ </div>
681
+
682
+ <!-- Features -->
683
+ ${
684
+ verkadaCamera.features &&
685
+ verkadaCamera.features.length > 0
686
+ ? `
687
+ <div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
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>
689
+ <div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
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
+ }
707
+ </div>
708
+ </div>
709
+ `
710
+ : ''
711
+ }
712
+ `
713
+ : `
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;">
715
+ <div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
716
+ <div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
717
+ </div>
718
+ `
719
+ }
720
+ </div>
445
721
  </div>
446
722
  `;
447
- }).join('')}
723
+ })
724
+ .join('')}
725
+
726
+ <!-- Report Footer -->
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);">
728
+ <!-- Legend Section -->
729
+ <div style="margin-bottom: 24px;">
730
+ <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>
731
+ <div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
732
+ <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);">
733
+ <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>
734
+ <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
735
+ </div>
736
+ <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);">
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>
738
+ <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
739
+ </div>
740
+ ${
741
+ cameras.some(
742
+ (c) => getCameraById(c.verkadaModel)?.ptz
743
+ )
744
+ ? `
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);">
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>
747
+ <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
748
+ </div>`
749
+ : ''
750
+ }
751
+ </div>
752
+ </div>
753
+
754
+ <!-- Technical Notes -->
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);">
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>
757
+ <div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
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>
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>
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>
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>
766
+ </div>
767
+ </div>
768
+
769
+ <!-- Report ID & Disclaimer -->
770
+ <div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
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>
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>
775
+ </div>
776
+ </div>
448
777
  `;
449
778
 
450
779
  cameraListDiv.innerHTML = cameraListHTML;
451
780
  exportContainer.appendChild(cameraListDiv);
452
781
 
453
782
  // Wait for rendering
454
- await new Promise(resolve => setTimeout(resolve, 100));
783
+ await new Promise((resolve) => setTimeout(resolve, 100));
455
784
 
456
785
  // Capture the composite with exact dimensions
457
786
  const finalCanvas = await html2canvas(exportContainer, {
@@ -461,7 +790,7 @@ const CameraPlacement = () => {
461
790
  backgroundColor: '#ffffff',
462
791
  logging: false,
463
792
  width: finalWidth,
464
- height: finalHeight
793
+ height: finalHeight,
465
794
  });
466
795
 
467
796
  // Clean up
@@ -469,30 +798,31 @@ const CameraPlacement = () => {
469
798
 
470
799
  // Download
471
800
  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);
801
+ const timestamp = new Date()
802
+ .toISOString()
803
+ .slice(0, 19)
804
+ .replace(/[:.]/g, '-');
805
+ link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
806
+ link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
474
807
  link.click();
475
808
 
476
- console.log('Export completed successfully');
477
-
478
809
  // Show success message
479
810
  Swal.fire({
480
811
  icon: 'success',
481
812
  title: 'Export Complete!',
482
- text: `Camera layout exported as ${link.download}`,
813
+ text: `Professional camera installation report exported as ${link.download}`,
483
814
  timer: 3000,
484
815
  showConfirmButton: false,
485
816
  toast: true,
486
- position: 'top-end'
817
+ position: 'top-end',
487
818
  });
488
-
489
819
  } catch (error) {
490
820
  console.error('Error exporting:', error);
491
821
  Swal.fire({
492
822
  icon: 'error',
493
823
  title: 'Export Failed',
494
824
  text: 'Export failed. Please try again or take a manual screenshot.',
495
- confirmButtonColor: '#10b981'
825
+ confirmButtonColor: '#10b981',
496
826
  });
497
827
  }
498
828
  }, [cameras]);
@@ -510,12 +840,15 @@ const CameraPlacement = () => {
510
840
  try {
511
841
  const style = map.getStyle();
512
842
  if (style && style.sources) {
513
- existingSources = Object.keys(style.sources).filter(
514
- (id) => id.startsWith('fov-')
843
+ existingSources = Object.keys(style.sources).filter((id) =>
844
+ id.startsWith('fov-')
515
845
  );
516
846
  }
517
847
  } catch (error) {
518
- 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
+ );
519
852
  }
520
853
  existingSources.forEach((sourceId) => {
521
854
  try {
@@ -527,7 +860,10 @@ const CameraPlacement = () => {
527
860
  map.removeSource(sourceId);
528
861
  }
529
862
  } catch (error) {
530
- console.warn(`Could not remove layer/source ${sourceId}:`, error);
863
+ console.warn(
864
+ `Could not remove layer/source ${sourceId}:`,
865
+ error
866
+ );
531
867
  }
532
868
  });
533
869
 
@@ -574,6 +910,7 @@ const CameraPlacement = () => {
574
910
  const handleRotationStart = (e) => {
575
911
  e.stopPropagation();
576
912
  isRotating = true;
913
+ setIsRotating(true);
577
914
  const rect = markerContainer.getBoundingClientRect();
578
915
  const centerX = rect.left + rect.width / 2;
579
916
  const centerY = rect.top + rect.height / 2;
@@ -607,6 +944,7 @@ const CameraPlacement = () => {
607
944
 
608
945
  const handleRotationEnd = () => {
609
946
  isRotating = false;
947
+ setIsRotating(false);
610
948
  markerContainer.classList.remove('rotating');
611
949
  document.removeEventListener('mousemove', handleRotationMove);
612
950
  document.removeEventListener('mouseup', handleRotationEnd);
@@ -753,552 +1091,872 @@ const CameraPlacement = () => {
753
1091
  });
754
1092
  }, [map, cameras, mode]);
755
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
+ );
756
1111
 
757
1112
  return (
758
1113
  <div className={styles.cameraPlacement}>
759
1114
  <div className={styles.header}>
760
- <h1 className={styles.title}>Camera Placement Tool</h1>
761
- <div className={styles.controls}>
762
- <div className={styles.leftControls}>
763
- <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}
764
1120
  onClick={() => fileInputRef.current?.click()}
1121
+ tooltip="Upload Image"
765
1122
  className={styles.uploadBtn}
766
- >
767
- Upload Image
768
- </button>
769
- {uploadedImage && (
770
- <button
771
- onClick={() => setAddCameraMode(!addCameraMode)}
772
- className={`${styles.addCameraBtn} ${addCameraMode ? styles.activeControl : ''}`}
773
- >
774
- {addCameraMode ? 'Cancel Add' : 'Add Camera'}
775
- </button>
776
- )}
777
- </div>
778
- <div className={styles.rightControls}>
779
- <button
1123
+ />
1124
+ <IconButton
1125
+ icon={Save}
780
1126
  onClick={saveAsFile}
781
- className={styles.saveFileBtn}
782
1127
  disabled={!cameras.length && !uploadedImage}
783
- title="Save camera placement as JSON file"
784
- >
785
- Save File
786
- </button>
787
- <button
1128
+ tooltip="Save camera placement as JSON file"
1129
+ className={styles.saveFileBtn}
1130
+ />
1131
+ <IconButton
1132
+ icon={FolderOpen}
788
1133
  onClick={loadFromFile}
1134
+ tooltip="Load camera placement from JSON file"
789
1135
  className={styles.loadFileBtn}
790
- title="Load camera placement from JSON file"
791
- >
792
- Load File
793
- </button>
794
- <button
1136
+ />
1137
+ <IconButton
1138
+ icon={sidebarCollapsed ? Eye : EyeOff}
795
1139
  onClick={() =>
796
1140
  setSidebarCollapsed(!sidebarCollapsed)
797
1141
  }
798
- >
799
- {sidebarCollapsed ? 'Show' : 'Hide'} Cameras
800
- </button>
801
- <button
802
- className={styles.exportBtn}
1142
+ tooltip={`${
1143
+ sidebarCollapsed ? 'Show' : 'Hide'
1144
+ } Cameras`}
1145
+ />
1146
+ <IconButton
1147
+ icon={Download}
803
1148
  onClick={exportMap}
804
1149
  disabled={mode === 'image' && !uploadedImage}
805
- >
806
- Export {mode === 'map' ? 'Map' : 'Image'}
807
- </button>
1150
+ tooltip={`Export ${
1151
+ mode === 'map' ? 'Map' : 'Image'
1152
+ }`}
1153
+ className={styles.exportBtn}
1154
+ />
808
1155
  </div>
809
1156
  </div>
810
1157
  </div>
811
1158
 
812
1159
  <div className={styles.mainContent}>
813
- <div
814
- className={`${styles.imageContainer} ${addCameraMode ? styles.addMode : ''} ${sidebarCollapsed ? styles.sidebarCollapsed : ''}`}
815
- ref={imageContainer}
1160
+ <div
1161
+ className={`${styles.imageContainer} ${
1162
+ uploadedImage ? styles.addMode : ''
1163
+ } ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
1164
+ isDragOver ? styles.dragOver : ''
1165
+ }`}
1166
+ ref={imageContainer}
816
1167
  onClick={handleImageClick}
1168
+ onDragOver={handleDragOver}
1169
+ onDragEnter={handleDragEnter}
1170
+ onDragLeave={handleDragLeave}
1171
+ onDrop={handleDrop}
817
1172
  >
818
- {uploadedImage ? (
819
- <div className={styles.imageWrapper}>
820
- <img
821
- src={uploadedImage}
822
- alt="Floor plan or site layout"
823
- className={styles.uploadedImage}
824
- />
825
- {/* Render FOV triangles first (behind markers) */}
826
- {cameras.map((camera) => {
827
- const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
828
- if (!verkadaCamera) return null;
829
-
830
- let fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov;
831
-
832
- // Handle special FOV cases
833
- if (fov >= 360) fov = 120; // PTZ cameras showing max practical FOV
834
- else if (fov >= 270) fov = 150; // Multi-sensor cameras
835
- else if (fov >= 180) fov = 130; // Fisheye cameras
836
-
837
- const fovRad = (fov * Math.PI) / 180;
838
-
839
- // Calculate distance based on FOV - wider angles get shorter distances
840
- // Base distance of 100px for 60° FOV, scaled inversely with bounds
841
- const baseFov = 60; // Reference FOV angle
842
- const baseDistance = verkadaCamera.ptz ? 120 : 100;
843
- let distance = baseDistance * (baseFov / fov);
844
-
845
- // Apply bounds: min 30px, max 200px
846
- distance = Math.max(30, Math.min(200, distance));
847
- 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`;
848
1241
 
849
- // Calculate FOV triangle points in pixels
850
- const point1X = camera.x + distance * Math.cos(directionRad - fovRad / 2);
851
- const point1Y = camera.y + distance * Math.sin(directionRad - fovRad / 2);
852
- const point2X = camera.x + distance * Math.cos(directionRad + fovRad / 2);
853
- const point2Y = camera.y + distance * Math.sin(directionRad + fovRad / 2);
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
+ })}
854
1267
 
855
- const fovColor = verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b';
856
-
857
- // Create SVG path for the triangle
858
- const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
859
-
860
- return (
861
- <svg
862
- key={`fov-${camera.id}`}
863
- className={styles.fovTriangle}
864
- style={{
865
- position: 'absolute',
866
- top: 0,
867
- left: 0,
868
- width: '100%',
869
- height: '100%',
870
- pointerEvents: 'none',
871
- zIndex: 1,
872
- }}
873
- >
874
- <path
875
- d={pathData}
876
- fill={fovColor}
877
- fillOpacity="0.25"
878
- stroke={fovColor}
879
- strokeOpacity="0.5"
880
- strokeWidth="2"
881
- />
882
- </svg>
883
- );
884
- })}
885
-
886
- {/* Render camera markers on image */}
887
- {cameras.map((camera) => {
888
- const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
889
- const environment = verkadaCamera ? verkadaCamera.environment : 'outdoor';
890
- const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
891
-
892
- return (
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;
1281
+
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(() => {
1306
+ // Check if element still exists and is in DOM
1307
+ if (
1308
+ !targetElement ||
1309
+ !targetElement.parentNode
1310
+ ) {
1311
+ return;
1312
+ }
1313
+
1314
+ const tooltip =
1315
+ document.createElement(
1316
+ 'div'
1317
+ );
1318
+ tooltip.className =
1319
+ styles.cameraTooltip;
1320
+ tooltip.innerHTML = `
1321
+ <div class="${styles.tooltipImage}">
1322
+ <img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
1323
+ </div>
1324
+ <div class="${styles.tooltipContent}">
1325
+ <div class="${styles.tooltipTitle}">${verkadaCamera.name}</div>
1326
+ <div class="${styles.tooltipSpecs}">
1327
+ <div>Resolution: ${verkadaCamera.resolution}</div>
1328
+ <div>FOV: ${verkadaCamera.fov}°</div>
1329
+ <div>Environment: ${verkadaCamera.environment}</div>
1330
+ </div>
1331
+ </div>
1332
+ `;
1333
+
1334
+ // Position tooltip away from rotation handle
1335
+ tooltip.style.left =
1336
+ '50px';
1337
+ tooltip.style.top =
1338
+ '-10px';
1339
+
1340
+ targetElement.appendChild(
1341
+ tooltip
1342
+ );
1343
+ targetElement._tooltip =
1344
+ tooltip;
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;
1393
+ }
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
+ });
1408
+ }
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);
1422
+ }
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
+ >
893
1444
  <div
894
- key={camera.id}
895
- className={`camera-marker-container ${styles.imageMarker}`}
896
- style={{
897
- position: 'absolute',
898
- left: camera.x - 20, // Adjusted for smaller circular marker
899
- top: camera.y - 20, // Adjusted for smaller circular marker
900
- transform: 'none',
901
- zIndex: 2,
902
- }}
903
- onMouseDown={(e) => {
904
- e.stopPropagation();
905
- let isDragging = false;
906
- let startTime = Date.now();
907
- const startX = e.clientX;
908
- const startY = e.clientY;
909
- const containerRect = imageContainer.current.getBoundingClientRect();
910
-
911
- const handleMouseMove = (moveEvent) => {
912
- const deltaX = Math.abs(moveEvent.clientX - startX);
913
- const deltaY = Math.abs(moveEvent.clientY - startY);
914
-
915
- // Start dragging if moved more than 5px
916
- if (!isDragging && (deltaX > 5 || deltaY > 5)) {
917
- isDragging = true;
918
- }
919
-
920
- if (isDragging) {
921
- const newX = moveEvent.clientX - containerRect.left;
922
- const newY = moveEvent.clientY - containerRect.top;
923
-
924
- // Update camera position
925
- updateCamera(camera.id, { x: newX, y: newY });
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;
926
1481
  }
927
- };
928
-
929
- const handleMouseUp = () => {
930
- const clickDuration = Date.now() - startTime;
931
-
932
- // If it was a quick click without dragging, show modal
933
- if (!isDragging && clickDuration < 200) {
934
- setSelectedCamera(camera);
935
- setShowModal(true);
1482
+ if (container._tooltip) {
1483
+ container.removeChild(
1484
+ container._tooltip
1485
+ );
1486
+ container._tooltip =
1487
+ null;
936
1488
  }
937
-
938
- document.removeEventListener('mousemove', handleMouseMove);
939
- document.removeEventListener('mouseup', handleMouseUp);
940
- };
941
-
942
- document.addEventListener('mousemove', handleMouseMove);
943
- document.addEventListener('mouseup', handleMouseUp);
944
- }}
945
- >
946
- <div className={`${styles.circularMarker} ${environment} ${verkadaCamera?.ptz ? styles.ptzMarker : ''}`}>
947
- <div className={styles.markerNumber}>{cameraNumber}</div>
948
- <div
949
- className={styles.rotationHandle}
950
- style={{
951
- transform: `rotate(${camera.direction || 0}deg)`,
952
- }}
953
- onMouseDown={(e) => {
954
- e.stopPropagation();
955
- let isRotating = false;
956
- let currentRotation = camera.direction || 0;
957
-
958
- const markerRect = e.currentTarget.parentElement.getBoundingClientRect();
959
- const centerX = markerRect.left + markerRect.width / 2;
960
- const centerY = markerRect.top + markerRect.height / 2;
961
-
962
- const handleRotationMove = (moveEvent) => {
963
- if (!isRotating) {
964
- isRotating = true;
965
- }
966
-
967
- const angle = Math.atan2(
968
- moveEvent.clientY - centerY,
969
- moveEvent.clientX - centerX
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'
970
1553
  );
971
-
972
- const degrees = ((angle * 180) / Math.PI + 90 + 360) % 360;
973
- currentRotation = Math.round(degrees);
974
-
975
- // Update rotation handle visual
976
- e.currentTarget.style.transform = `rotate(${currentRotation}deg)`;
977
-
1554
+ }
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
+ );
1609
+ }
1610
+
978
1611
  // Update camera direction
979
- updateCamera(camera.id, { direction: currentRotation });
980
- };
981
-
982
- const handleRotationEnd = () => {
983
- document.removeEventListener('mousemove', handleRotationMove);
984
- document.removeEventListener('mouseup', handleRotationEnd);
1612
+ updateCamera(
1613
+ camera.id,
1614
+ {
1615
+ direction:
1616
+ newRotation,
1617
+ }
1618
+ );
1619
+ }
1620
+ };
1621
+
1622
+ const handleRotationEnd =
1623
+ () => {
1624
+ if (isRotating) {
1625
+ markerContainer.classList.remove(
1626
+ 'rotating'
1627
+ );
1628
+ }
1629
+ setIsRotating(
1630
+ false
1631
+ );
1632
+ document.removeEventListener(
1633
+ 'mousemove',
1634
+ handleRotationMove
1635
+ );
1636
+ document.removeEventListener(
1637
+ 'mouseup',
1638
+ handleRotationEnd
1639
+ );
985
1640
  };
986
-
987
- document.addEventListener('mousemove', handleRotationMove);
988
- document.addEventListener('mouseup', handleRotationEnd);
989
- }}
990
- />
991
- </div>
1641
+
1642
+ document.addEventListener(
1643
+ 'mousemove',
1644
+ handleRotationMove
1645
+ );
1646
+ document.addEventListener(
1647
+ 'mouseup',
1648
+ handleRotationEnd
1649
+ );
1650
+ }}
1651
+ />
992
1652
  </div>
993
- );
994
- })}
1653
+ </div>
1654
+ );
1655
+ })}
1656
+ </div>
1657
+ ) : (
1658
+ <div className={styles.uploadPlaceholder}>
1659
+ <div className={styles.uploadIcon}>
1660
+ <FileImage size={48} />
995
1661
  </div>
996
- ) : (
997
- <div className={styles.uploadPlaceholder}>
998
- <div className={styles.uploadIcon}>📁</div>
999
- <h3>Upload an Image</h3>
1000
- <p>Click "Upload Image" to add a floor plan, site layout, or any image where you want to place cameras.</p>
1001
- <button
1002
- onClick={() => fileInputRef.current?.click()}
1003
- className={styles.uploadBtn}
1004
- >
1005
- Choose Image File
1006
- </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>
1007
1683
  </div>
1008
- )}
1009
- </div>
1684
+ </div>
1685
+ )}
1010
1686
  </div>
1011
-
1012
- {/* Hidden file input */}
1013
- <input
1014
- type="file"
1015
- ref={fileInputRef}
1016
- onChange={handleImageUpload}
1017
- accept="image/*"
1018
- style={{ display: 'none' }}
1019
- />
1687
+ </div>
1020
1688
 
1021
- <div
1022
- className={`${styles.sidebar} ${
1023
- sidebarCollapsed ? styles.collapsed : ''
1024
- }`}
1025
- >
1026
- <div className={styles.sidebarHeader}>
1027
- <h3 className={styles.sidebarTitle}>Camera List</h3>
1028
- <p className={styles.cameraCount}>
1029
- {cameras.length} camera
1030
- {cameras.length !== 1 ? 's' : ''} placed
1031
- </p>
1032
- </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>
1033
1710
 
1034
- <div className={styles.camerasList}>
1035
- {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>
1036
1725
  <div
1037
1726
  style={{
1038
- textAlign: 'center',
1039
- color: '#6c757d',
1040
- padding: '40px 20px',
1041
- fontStyle: 'italic',
1727
+ fontSize: '12px',
1728
+ marginTop: '8px',
1042
1729
  }}
1043
1730
  >
1044
- <div>
1045
- Click "Add Camera" to place your first camera
1046
- </div>
1047
- <div
1048
- style={{
1049
- fontSize: '12px',
1050
- marginTop: '8px',
1051
- }}
1052
- >
1053
- Once placed, you can drag cameras to reposition them and use the rotation handle to adjust direction
1054
- </div>
1731
+ Once placed, you can drag cameras to reposition
1732
+ them and use the rotation handle to adjust
1733
+ direction
1055
1734
  </div>
1056
- ) : (
1057
- cameras.map((camera) => {
1058
- const verkadaCamera = camera.verkadaModel
1059
- ? getCameraById(camera.verkadaModel)
1060
- : null;
1735
+ </div>
1736
+ ) : (
1737
+ cameras.map((camera) => {
1738
+ const verkadaCamera = camera.verkadaModel
1739
+ ? getCameraById(camera.verkadaModel)
1740
+ : null;
1061
1741
 
1062
- return (
1063
- <div
1064
- key={camera.id}
1065
- className={styles.cameraItem}
1066
- >
1067
- <div className={styles.cameraHeader}>
1068
- <h4 className={styles.cameraName}>
1069
- {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
- }`}
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
+ }
1090
1756
  >
1091
- {verkadaCamera.id}
1092
- </span>
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}
1793
+ />
1794
+ </div>
1795
+ </div>
1093
1796
  )}
1797
+ <div
1798
+ className={
1799
+ styles.sidebarCameraText
1800
+ }
1801
+ >
1802
+ <h4
1803
+ className={
1804
+ styles.cameraName
1805
+ }
1806
+ >
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
+ )}
1824
+ </div>
1825
+ </div>
1826
+
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>
1094
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>
1095
1862
 
1096
- {verkadaCamera ? (
1097
1863
  <div
1098
- className={styles.cameraDetails}
1864
+ className={
1865
+ modalStyles.cameraSpecGrid
1866
+ }
1099
1867
  >
1100
1868
  <div
1101
1869
  className={
1102
- modalStyles.cameraModelName
1870
+ modalStyles.specItem
1103
1871
  }
1104
1872
  >
1105
- <strong>
1106
- {verkadaCamera.name}
1107
- </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>
1108
1889
  </div>
1109
-
1110
1890
  <div
1111
1891
  className={
1112
- modalStyles.cameraSpecGrid
1892
+ modalStyles.specItem
1113
1893
  }
1114
1894
  >
1115
- <div
1895
+ <span
1116
1896
  className={
1117
- modalStyles.specItem
1897
+ modalStyles.specLabel
1118
1898
  }
1119
1899
  >
1120
- <span
1121
- className={
1122
- modalStyles.specLabel
1123
- }
1124
- >
1125
- Resolution:
1126
- </span>
1127
- <span
1128
- className={
1129
- modalStyles.specValue
1130
- }
1131
- >
1132
- {
1133
- verkadaCamera.resolution
1134
- }
1135
- </span>
1136
- </div>
1137
- <div
1900
+ FOV:
1901
+ </span>
1902
+ <span
1138
1903
  className={
1139
- modalStyles.specItem
1904
+ modalStyles.specValue
1140
1905
  }
1141
1906
  >
1142
- <span
1143
- className={
1144
- modalStyles.specLabel
1145
- }
1146
- >
1147
- FOV:
1148
- </span>
1149
- <span
1150
- className={
1151
- modalStyles.specValue
1152
- }
1153
- >
1154
- {verkadaCamera.fov}°
1155
- </span>
1156
- </div>
1157
- <div
1907
+ {verkadaCamera.fov}°
1908
+ </span>
1909
+ </div>
1910
+ <div
1911
+ className={
1912
+ modalStyles.specItem
1913
+ }
1914
+ >
1915
+ <span
1158
1916
  className={
1159
- modalStyles.specItem
1917
+ modalStyles.specLabel
1160
1918
  }
1161
1919
  >
1162
- <span
1163
- className={
1164
- modalStyles.specLabel
1165
- }
1166
- >
1167
- Environment:
1168
- </span>
1169
- <span
1170
- className={`${
1171
- modalStyles.specValue
1172
- } ${
1173
- modalStyles[
1174
- verkadaCamera
1175
- .environment
1176
- ]
1177
- }`}
1178
- >
1179
- {
1180
- verkadaCamera.environment
1181
- }
1182
- </span>
1183
- </div>
1184
- <div
1185
- className={
1186
- modalStyles.specItem
1187
- }
1920
+ Environment:
1921
+ </span>
1922
+ <span
1923
+ className={`${
1924
+ modalStyles.specValue
1925
+ } ${
1926
+ modalStyles[
1927
+ verkadaCamera
1928
+ .environment
1929
+ ]
1930
+ }`}
1188
1931
  >
1189
- <span
1190
- className={
1191
- modalStyles.specLabel
1192
- }
1193
- >
1194
- IR Range:
1195
- </span>
1196
- <span
1197
- className={
1198
- modalStyles.specValue
1199
- }
1200
- >
1201
- {
1202
- verkadaCamera.irRange
1203
- }
1204
- m
1205
- </span>
1206
- </div>
1207
- <div
1932
+ {
1933
+ verkadaCamera.environment
1934
+ }
1935
+ </span>
1936
+ </div>
1937
+ <div
1938
+ className={
1939
+ modalStyles.specItem
1940
+ }
1941
+ >
1942
+ <span
1208
1943
  className={
1209
- modalStyles.specItem
1944
+ modalStyles.specLabel
1210
1945
  }
1211
1946
  >
1212
- <span
1213
- className={
1214
- modalStyles.specLabel
1215
- }
1216
- >
1217
- Zoom:
1218
- </span>
1219
- <span
1220
- className={
1221
- modalStyles.specValue
1222
- }
1223
- >
1224
- {verkadaCamera.zoom}
1225
- </span>
1226
- </div>
1227
- <div
1947
+ IR Range:
1948
+ </span>
1949
+ <span
1228
1950
  className={
1229
- modalStyles.specItem
1951
+ modalStyles.specValue
1230
1952
  }
1231
1953
  >
1232
- <span
1233
- className={
1234
- modalStyles.specLabel
1235
- }
1236
- >
1237
- Rating:
1238
- </span>
1239
- <span
1240
- className={
1241
- modalStyles.specValue
1242
- }
1243
- >
1244
- {
1245
- verkadaCamera.rating
1246
- }
1247
- </span>
1248
- </div>
1249
- {verkadaCamera.ptz && (
1250
- <>
1251
- <div
1252
- className={
1253
- modalStyles.specItem
1254
- }
1255
- >
1256
- <span
1257
- className={
1258
- modalStyles.specLabel
1259
- }
1260
- >
1261
- Pan:
1262
- </span>
1263
- <span
1264
- className={
1265
- modalStyles.specValue
1266
- }
1267
- >
1268
- {
1269
- verkadaCamera.pan
1270
- }
1271
- </span>
1272
- </div>
1273
- <div
1274
- className={
1275
- modalStyles.specItem
1276
- }
1277
- >
1278
- <span
1279
- className={
1280
- modalStyles.specLabel
1281
- }
1282
- >
1283
- Tilt:
1284
- </span>
1285
- <span
1286
- className={
1287
- modalStyles.specValue
1288
- }
1289
- >
1290
- {
1291
- verkadaCamera.tilt
1292
- }
1293
- </span>
1294
- </div>
1295
- </>
1296
- )}
1954
+ {verkadaCamera.irRange}m
1955
+ </span>
1297
1956
  </div>
1298
-
1299
1957
  <div
1300
1958
  className={
1301
- modalStyles.cameraFeatures
1959
+ modalStyles.specItem
1302
1960
  }
1303
1961
  >
1304
1962
  <span
@@ -1306,172 +1964,231 @@ const CameraPlacement = () => {
1306
1964
  modalStyles.specLabel
1307
1965
  }
1308
1966
  >
1309
- Features:
1967
+ Zoom:
1310
1968
  </span>
1311
- <div
1969
+ <span
1312
1970
  className={
1313
- modalStyles.featuresList
1971
+ modalStyles.specValue
1314
1972
  }
1315
1973
  >
1316
- {verkadaCamera.features.map(
1317
- (
1318
- feature,
1319
- index
1320
- ) => (
1321
- <span
1322
- key={index}
1323
- className={
1324
- modalStyles.featureTag
1325
- }
1326
- >
1327
- {feature}
1328
- </span>
1329
- )
1330
- )}
1331
- </div>
1974
+ {verkadaCamera.zoom}
1975
+ </span>
1332
1976
  </div>
1333
-
1334
1977
  <div
1335
1978
  className={
1336
- modalStyles.directionControl
1979
+ modalStyles.specItem
1337
1980
  }
1338
1981
  >
1339
- <label>
1340
- Direction:{' '}
1341
- {camera.direction}°
1342
- </label>
1343
- <input
1344
- type="range"
1345
- min="0"
1346
- max="360"
1347
- value={camera.direction}
1348
- onChange={(e) =>
1349
- updateCamera(
1350
- camera.id,
1351
- {
1352
- direction:
1353
- parseInt(
1354
- e
1355
- .target
1356
- .value
1357
- ),
1358
- }
1359
- )
1360
- }
1982
+ <span
1361
1983
  className={
1362
- modalStyles.directionSlider
1984
+ modalStyles.specLabel
1363
1985
  }
1364
- />
1365
- <div
1986
+ >
1987
+ Rating:
1988
+ </span>
1989
+ <span
1366
1990
  className={
1367
- modalStyles.directionHelper
1991
+ modalStyles.specValue
1368
1992
  }
1369
1993
  >
1370
- 0° = North, 90° = East,
1371
- 180° = South, 270° =
1372
- West
1373
- </div>
1994
+ {verkadaCamera.rating}
1995
+ </span>
1374
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
+ )}
1375
2045
  </div>
1376
- ) : (
2046
+
1377
2047
  <div
1378
- className={styles.cameraDetails}
2048
+ className={
2049
+ modalStyles.cameraFeatures
2050
+ }
1379
2051
  >
1380
- <div
2052
+ <span
1381
2053
  className={
1382
- modalStyles.noModel
2054
+ modalStyles.specLabel
1383
2055
  }
1384
2056
  >
1385
- No Verkada model selected -
1386
- Click "Edit" to choose a
1387
- camera
1388
- </div>
2057
+ Features:
2058
+ </span>
1389
2059
  <div
1390
2060
  className={
1391
- modalStyles.directionControl
2061
+ modalStyles.featuresList
1392
2062
  }
1393
2063
  >
1394
- <label>
1395
- Direction:{' '}
1396
- {camera.direction}°
1397
- </label>
1398
- <input
1399
- type="range"
1400
- min="0"
1401
- max="360"
1402
- value={camera.direction}
1403
- onChange={(e) =>
1404
- updateCamera(
1405
- camera.id,
1406
- {
1407
- direction:
1408
- parseInt(
1409
- e
1410
- .target
1411
- .value
1412
- ),
2064
+ {verkadaCamera.features.map(
2065
+ (feature, index) => (
2066
+ <span
2067
+ key={index}
2068
+ className={
2069
+ modalStyles.featureTag
1413
2070
  }
1414
- )
1415
- }
1416
- className={
1417
- modalStyles.directionSlider
1418
- }
1419
- />
1420
- <div
1421
- className={
1422
- modalStyles.directionHelper
1423
- }
1424
- >
1425
- 0° = North, 90° = East,
1426
- 180° = South, 270° =
1427
- West
1428
- </div>
2071
+ >
2072
+ {feature}
2073
+ </span>
2074
+ )
2075
+ )}
1429
2076
  </div>
1430
2077
  </div>
1431
- )}
1432
2078
 
1433
- <div className={styles.cameraCoords}>
1434
- <span className={styles.coordLabel}>
1435
- Location:
1436
- </span>
1437
- {mode === 'map' ? (
1438
- <>
1439
- {camera.lat?.toFixed(6)},{' '}
1440
- {camera.lng?.toFixed(6)}
1441
- </>
1442
- ) : (
1443
- <>
1444
- X: {Math.round(camera.x)}, Y: {Math.round(camera.y)}
1445
- </>
1446
- )}
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>
1447
2118
  </div>
1448
-
1449
- <div className={styles.cameraActions}>
1450
- <button
1451
- className={styles.editBtn}
1452
- onClick={() => {
1453
- setSelectedCamera(camera);
1454
- setShowModal(true);
1455
- }}
2119
+ ) : (
2120
+ <div className={styles.cameraDetails}>
2121
+ <div
2122
+ className={modalStyles.noModel}
1456
2123
  >
1457
- Edit
1458
- </button>
1459
- <button
1460
- className={styles.removeBtn}
1461
- onClick={() =>
1462
- removeCamera(camera.id)
2124
+ No Verkada model selected -
2125
+ Click "Edit" to choose a camera
2126
+ </div>
2127
+ <div
2128
+ className={
2129
+ modalStyles.directionControl
1463
2130
  }
1464
2131
  >
1465
- Remove
1466
- </button>
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>
1467
2166
  </div>
2167
+ )}
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
+ )}
1468
2184
  </div>
1469
- );
1470
- })
1471
- )}
1472
- </div>
2185
+ </div>
2186
+ );
2187
+ })
2188
+ )}
1473
2189
  </div>
1474
-
2190
+ </div>
2191
+
1475
2192
  {showModal && selectedCamera && (
1476
2193
  <CameraSelectionModal
1477
2194
  camera={selectedCamera}
@@ -1479,7 +2196,9 @@ const CameraPlacement = () => {
1479
2196
  updateCamera(selectedCamera.id, updates);
1480
2197
  setShowModal(false);
1481
2198
  }}
1482
- onClose={() => setShowModal(false)}
2199
+ onClose={() => {
2200
+ setShowModal(false);
2201
+ }}
1483
2202
  />
1484
2203
  )}
1485
2204
  </div>
@@ -1487,9 +2206,13 @@ const CameraPlacement = () => {
1487
2206
  };
1488
2207
 
1489
2208
  const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1490
- const [customName, setCustomName] = useState(camera.customName || '');
2209
+ // Determine if this is editing an existing camera or adding a new one
2210
+ const isUpdating = Boolean(camera.verkadaModel);
2211
+
1491
2212
  const [selectedCategory, setSelectedCategory] = useState('all');
1492
- const [selectedCamera, setSelectedCamera] = useState(null);
2213
+ const [selectedCamera, setSelectedCamera] = useState(
2214
+ camera.verkadaModel ? getCameraById(camera.verkadaModel) : null
2215
+ );
1493
2216
 
1494
2217
  // Filter cameras based on selected category
1495
2218
  const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
@@ -1508,7 +2231,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1508
2231
  }
1509
2232
 
1510
2233
  onSave({
1511
- customName: customName.trim(),
1512
2234
  verkadaModel: selectedCamera.id,
1513
2235
  });
1514
2236
  };
@@ -1518,39 +2240,43 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1518
2240
  <div className={styles.cameraSelectionModal}>
1519
2241
  <div className={styles.modalHeader}>
1520
2242
  <h3>Select Verkada Camera</h3>
1521
- <button className={styles.closeBtn} onClick={onClose}>×</button>
2243
+ <button className={styles.closeBtn} onClick={onClose}>
2244
+ ×
2245
+ </button>
1522
2246
  </div>
1523
2247
 
1524
2248
  <div className={styles.modalContent}>
1525
2249
  <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
2250
  <div className={styles.categoryFilter}>
1537
2251
  <label>Filter by Category:</label>
1538
2252
  <div className={styles.categoryButtons}>
1539
2253
  <button
1540
- className={selectedCategory === 'all' ? styles.active : ''}
2254
+ className={
2255
+ selectedCategory === 'all'
2256
+ ? styles.active
2257
+ : ''
2258
+ }
1541
2259
  onClick={() => setSelectedCategory('all')}
1542
2260
  >
1543
2261
  All Cameras
1544
2262
  </button>
1545
- {Object.entries(CAMERA_CATEGORIES).map(([key, category]) => (
1546
- <button
1547
- key={key}
1548
- className={selectedCategory === key ? styles.active : ''}
1549
- onClick={() => setSelectedCategory(key)}
1550
- >
1551
- {category.name}
1552
- </button>
1553
- ))}
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
+ )}
1554
2280
  </div>
1555
2281
  </div>
1556
2282
  </div>
@@ -1559,48 +2285,104 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1559
2285
  {filteredCameras.map((cam) => (
1560
2286
  <div
1561
2287
  key={cam.id}
1562
- className={`${styles.cameraCard} ${selectedCamera?.id === cam.id ? styles.selectedCard : ''}`}
2288
+ className={`${styles.cameraCard} ${
2289
+ selectedCamera?.id === cam.id
2290
+ ? styles.selectedCard
2291
+ : ''
2292
+ }`}
1563
2293
  onClick={() => handleCameraSelect(cam)}
1564
2294
  >
1565
2295
  <div className={styles.cardHeader}>
1566
- <div className={styles.cameraIcon}>
1567
- <CameraIcon icon={cam.icon} size={24} />
2296
+ <div
2297
+ className={styles.cameraImageContainer}
2298
+ >
2299
+ <img
2300
+ src={cam.image}
2301
+ alt={cam.name}
2302
+ className={styles.cameraImage}
2303
+ onLoad={(e) => {
2304
+ e.target.style.opacity = '1';
2305
+ }}
2306
+ onError={(e) => {
2307
+ // Fallback to icon if image fails to load
2308
+ e.target.style.display = 'none';
2309
+ e.target.nextSibling.style.display =
2310
+ 'flex';
2311
+ }}
2312
+ style={{ opacity: '0.3' }}
2313
+ />
2314
+ <div
2315
+ className={styles.cameraIcon}
2316
+ style={{ display: 'none' }}
2317
+ >
2318
+ <CameraIcon
2319
+ icon={cam.icon}
2320
+ size={24}
2321
+ />
2322
+ </div>
1568
2323
  </div>
1569
2324
  <div className={styles.cameraTitle}>
1570
2325
  <h4>{cam.name}</h4>
1571
- <span className={`${styles.environmentBadge} ${styles[cam.environment]}`}>
2326
+ <span
2327
+ className={`${
2328
+ styles.environmentBadge
2329
+ } ${styles[cam.environment]}`}
2330
+ >
1572
2331
  {cam.environment}
1573
2332
  </span>
1574
2333
  </div>
1575
2334
  </div>
1576
-
2335
+
1577
2336
  <div className={styles.cardBody}>
1578
2337
  <div className={styles.specRow}>
1579
- <span className={styles.specLabel}>Resolution:</span>
1580
- <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>
1581
2344
  </div>
1582
2345
  <div className={styles.specRow}>
1583
- <span className={styles.specLabel}>FOV:</span>
1584
- <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>
1585
2352
  </div>
1586
2353
  <div className={styles.specRow}>
1587
- <span className={styles.specLabel}>Range:</span>
1588
- <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>
1589
2360
  </div>
1590
2361
  {cam.ptz && (
1591
- <div className={styles.ptzBadge}>PTZ</div>
2362
+ <div className={styles.ptzBadge}>
2363
+ PTZ
2364
+ </div>
1592
2365
  )}
1593
2366
  </div>
1594
2367
 
1595
2368
  <div className={styles.cardFooter}>
1596
2369
  <div className={styles.features}>
1597
- {cam.features?.slice(0, 2).map((feature, index) => (
1598
- <span key={index} className={styles.featureBadge}>
1599
- {feature}
1600
- </span>
1601
- ))}
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
+ ))}
1602
2382
  {cam.features?.length > 2 && (
1603
- <span className={styles.moreFeatures}>
2383
+ <span
2384
+ className={styles.moreFeatures}
2385
+ >
1604
2386
  +{cam.features.length - 2} more
1605
2387
  </span>
1606
2388
  )}
@@ -1621,12 +2403,12 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1621
2403
  <button onClick={onClose} className={styles.cancelBtn}>
1622
2404
  Cancel
1623
2405
  </button>
1624
- <button
1625
- onClick={handleSave}
2406
+ <button
2407
+ onClick={handleSave}
1626
2408
  disabled={!selectedCamera}
1627
2409
  className={styles.saveBtn}
1628
2410
  >
1629
- Add Camera
2411
+ {isUpdating ? 'Update Camera' : 'Add Camera'}
1630
2412
  </button>
1631
2413
  </div>
1632
2414
  </div>