@visns-studio/visns-components 5.15.6 → 5.15.8

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