@visns-studio/visns-components 5.15.4 → 5.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1871 @@
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
+ import mapboxgl from 'mapbox-gl';
3
+ import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
4
+ import html2canvas from 'html2canvas';
5
+ import Swal from 'sweetalert2';
6
+ import { Edit3, Trash2 } from 'lucide-react';
7
+ import 'mapbox-gl/dist/mapbox-gl.css';
8
+ import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
9
+ import styles from './styles/CameraPlacement.module.scss';
10
+ import modalStyles from './styles/CameraPlacementModal.module.scss';
11
+ import './styles/CameraMarkers.scss';
12
+ import {
13
+ VERKADA_CAMERAS,
14
+ CAMERA_CATEGORIES,
15
+ getCameraById,
16
+ } from './data/verkadaCameras';
17
+ import CameraIcon from './utils/CameraIcon';
18
+
19
+ const CameraPlacement = () => {
20
+ const [cameras, setCameras] = useState([]);
21
+ const [selectedCamera, setSelectedCamera] = useState(null);
22
+ const [showModal, setShowModal] = useState(false);
23
+ const [map, setMap] = useState(null);
24
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
25
+ const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
26
+ const [uploadedImage, setUploadedImage] = useState(null);
27
+ const [addCameraMode, setAddCameraMode] = useState(false);
28
+ const mapContainer = useRef(null);
29
+ const mapRef = useRef(null);
30
+ const geocoderRef = useRef(null);
31
+ const imageContainer = useRef(null);
32
+ const fileInputRef = useRef(null);
33
+
34
+ // Mapbox configuration
35
+ const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
36
+
37
+ // 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);
53
+ }
54
+ setMap(null);
55
+ }
56
+ };
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
+
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]);
84
+
85
+
86
+ // Initialize Mapbox GL map
87
+ useEffect(() => {
88
+ if (!mapContainer.current || mode !== 'map') return;
89
+
90
+ // Set Mapbox access token
91
+ mapboxgl.accessToken = MAPBOX_ACCESS_TOKEN;
92
+
93
+ const mapInstance = new mapboxgl.Map({
94
+ container: mapContainer.current,
95
+ style: 'mapbox://styles/mapbox/satellite-streets-v12', // Start with satellite imagery
96
+ center: [115.8605, -31.9505], // Perth, Australia default
97
+ zoom: 16,
98
+ maxZoom: 22, // Allow higher zoom for better detail
99
+ minZoom: 10,
100
+ bearing: 0,
101
+ pitch: 0,
102
+ preserveDrawingBuffer: true, // Required for canvas export
103
+ });
104
+
105
+ // Add Mapbox geocoder
106
+ const geocoder = new MapboxGeocoder({
107
+ accessToken: MAPBOX_ACCESS_TOKEN,
108
+ mapboxgl: mapboxgl,
109
+ placeholder: 'Search for addresses...',
110
+ proximity: {
111
+ longitude: 144.9631,
112
+ latitude: -37.8136,
113
+ },
114
+ });
115
+
116
+ mapInstance.on('load', () => {
117
+ setMap(mapInstance);
118
+ mapRef.current = mapInstance;
119
+
120
+ // Add zoom controls to the map using Mapbox GL
121
+ const zoomControl = new mapboxgl.NavigationControl({
122
+ showCompass: false, // Hide compass, only show zoom buttons
123
+ showZoom: true,
124
+ });
125
+ mapInstance.addControl(zoomControl, 'top-right');
126
+
127
+ // Add fullscreen control using Mapbox GL
128
+ const fullscreenControl = new mapboxgl.FullscreenControl();
129
+ mapInstance.addControl(fullscreenControl, 'top-right');
130
+
131
+ // Add Mapbox geocoder to the map
132
+ if (geocoderRef.current) {
133
+ geocoderRef.current.appendChild(geocoder.onAdd(mapInstance));
134
+ }
135
+
136
+ // Handle geocoder result for Mapbox
137
+ geocoder.on('result', (e) => {
138
+ // Mapbox geocoder result format
139
+ const coordinates = e.result.center;
140
+ if (coordinates) {
141
+ mapInstance.flyTo({
142
+ center: coordinates,
143
+ zoom: Math.min(Math.max(mapInstance.getZoom(), 18), 20), // Allow zoom up to 20 for better detail
144
+ });
145
+ }
146
+ });
147
+ });
148
+
149
+ return () => {
150
+ mapInstance.remove();
151
+ };
152
+ }, [mode]);
153
+
154
+ // Handle map click to add camera
155
+ const handleMapClick = useCallback(
156
+ (e) => {
157
+ if (!map || mode !== 'map') return;
158
+
159
+ const { lng, lat } = e.lngLat;
160
+ const newCamera = {
161
+ id: Date.now(),
162
+ lng,
163
+ lat,
164
+ name: `Camera ${cameras.length + 1}`,
165
+ verkadaModel: null, // Will be selected in modal
166
+ customName: '',
167
+ direction: 0,
168
+ };
169
+
170
+ setCameras((prev) => [...prev, newCamera]);
171
+ setSelectedCamera(newCamera);
172
+ setShowModal(true);
173
+ },
174
+ [map, cameras.length, mode]
175
+ );
176
+
177
+ // Add click listener to map
178
+ useEffect(() => {
179
+ if (!map) return;
180
+
181
+ map.on('click', handleMapClick);
182
+ return () => {
183
+ map.off('click', handleMapClick);
184
+ };
185
+ }, [map, handleMapClick]);
186
+
187
+ const updateCamera = useCallback((cameraId, updates) => {
188
+ setCameras((prev) => {
189
+ const updated = prev.map((cam) =>
190
+ cam.id === cameraId ? { ...cam, ...updates } : cam
191
+ );
192
+ return updated;
193
+ });
194
+ }, []);
195
+
196
+ const removeCamera = useCallback(
197
+ (cameraId) => {
198
+ setCameras((prev) => prev.filter((cam) => cam.id !== cameraId));
199
+
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);
209
+ }
210
+ }
211
+ },
212
+ [map]
213
+ );
214
+
215
+ // Save placement as JSON file
216
+ const saveAsFile = useCallback(() => {
217
+ if (!cameras.length && !uploadedImage) {
218
+ Swal.fire({
219
+ icon: 'warning',
220
+ title: 'No Data to Save',
221
+ text: 'Please add cameras or upload an image first.',
222
+ confirmButtonColor: '#8b5cf6'
223
+ });
224
+ return;
225
+ }
226
+
227
+ const saveData = {
228
+ version: '1.0',
229
+ timestamp: new Date().toISOString(),
230
+ image: uploadedImage ? {
231
+ src: uploadedImage,
232
+ name: 'uploaded-image'
233
+ } : null,
234
+ mode: mode,
235
+ cameras: cameras.map(camera => ({
236
+ id: camera.id,
237
+ name: camera.name,
238
+ customName: camera.customName,
239
+ x: camera.x,
240
+ y: camera.y,
241
+ direction: camera.direction,
242
+ verkadaModel: camera.verkadaModel,
243
+ timestamp: camera.timestamp
244
+ }))
245
+ };
246
+
247
+ 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
+
252
+ const linkElement = document.createElement('a');
253
+ linkElement.setAttribute('href', dataUri);
254
+ linkElement.setAttribute('download', exportFileDefaultName);
255
+ linkElement.click();
256
+
257
+ // Show success message
258
+ Swal.fire({
259
+ icon: 'success',
260
+ title: 'File Saved!',
261
+ text: `Camera placement saved as ${exportFileDefaultName}`,
262
+ timer: 3000,
263
+ showConfirmButton: false,
264
+ toast: true,
265
+ position: 'top-end'
266
+ });
267
+ }, [cameras, uploadedImage, mode]);
268
+
269
+ // Load placement from JSON file
270
+ const loadFromFile = useCallback(() => {
271
+ const input = document.createElement('input');
272
+ input.type = 'file';
273
+ input.accept = '.json';
274
+
275
+ input.onchange = (event) => {
276
+ const file = event.target.files[0];
277
+ if (!file) return;
278
+
279
+ const reader = new FileReader();
280
+ reader.onload = (e) => {
281
+ try {
282
+ const saveData = JSON.parse(e.target.result);
283
+
284
+ // Validate data structure
285
+ if (!saveData.version || !Array.isArray(saveData.cameras)) {
286
+ Swal.fire({
287
+ icon: 'error',
288
+ title: 'Invalid File',
289
+ text: 'Please select a valid camera placement file.',
290
+ confirmButtonColor: '#f59e0b'
291
+ });
292
+ return;
293
+ }
294
+
295
+ // Load image if present
296
+ if (saveData.image && saveData.image.src) {
297
+ setUploadedImage(saveData.image.src);
298
+ }
299
+
300
+ // Set mode
301
+ if (saveData.mode) {
302
+ setMode(saveData.mode);
303
+ }
304
+
305
+ // Load cameras
306
+ setCameras(saveData.cameras || []);
307
+
308
+ Swal.fire({
309
+ icon: 'success',
310
+ title: 'File Loaded!',
311
+ text: `Successfully loaded ${saveData.cameras?.length || 0} cameras from ${file.name}`,
312
+ confirmButtonColor: '#f59e0b'
313
+ });
314
+
315
+ } catch (error) {
316
+ console.error('Error loading file:', error);
317
+ Swal.fire({
318
+ icon: 'error',
319
+ title: 'File Error',
320
+ text: 'Error reading file. Please ensure it\'s a valid JSON file.',
321
+ confirmButtonColor: '#f59e0b'
322
+ });
323
+ }
324
+ };
325
+ reader.readAsText(file);
326
+ };
327
+
328
+ input.click();
329
+ }, []);
330
+
331
+ const exportMap = useCallback(async () => {
332
+ try {
333
+ if (!cameras.length) {
334
+ Swal.fire({
335
+ icon: 'warning',
336
+ title: 'No Cameras',
337
+ 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'
350
+ });
351
+ return;
352
+ }
353
+
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
361
+ });
362
+
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
+ `;
554
+
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
+ });
571
+
572
+ // Clean up
573
+ document.body.removeChild(exportContainer);
574
+
575
+ // Download
576
+ 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
580
+ link.click();
581
+
582
+
583
+ // Show success message
584
+ Swal.fire({
585
+ 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'
592
+ });
593
+
594
+ } catch (error) {
595
+ console.error('Error exporting:', error);
596
+ Swal.fire({
597
+ icon: 'error',
598
+ title: 'Export Failed',
599
+ text: 'Export failed. Please try again or take a manual screenshot.',
600
+ confirmButtonColor: '#10b981'
601
+ });
602
+ }
603
+ }, [cameras]);
604
+
605
+ // Update cameras with markers on map
606
+ useEffect(() => {
607
+ if (!map || mode !== 'map') return;
608
+
609
+ // Clear existing markers
610
+ const existingMarkers = document.querySelectorAll('.camera-map-marker');
611
+ existingMarkers.forEach((marker) => marker.remove());
612
+
613
+ // Clear existing FOV layers
614
+ let existingSources = [];
615
+ try {
616
+ const style = map.getStyle();
617
+ if (style && style.sources) {
618
+ existingSources = Object.keys(style.sources).filter(
619
+ (id) => id.startsWith('fov-')
620
+ );
621
+ }
622
+ } catch (error) {
623
+ console.warn('Could not access map style, skipping FOV layer cleanup:', error);
624
+ }
625
+ existingSources.forEach((sourceId) => {
626
+ try {
627
+ const layerId = `fov-layer-${sourceId.replace('fov-', '')}`;
628
+ if (map.getLayer(layerId)) {
629
+ map.removeLayer(layerId);
630
+ }
631
+ if (map.getSource(sourceId)) {
632
+ map.removeSource(sourceId);
633
+ }
634
+ } catch (error) {
635
+ console.warn(`Could not remove layer/source ${sourceId}:`, error);
636
+ }
637
+ });
638
+
639
+ // Add camera markers and FOV
640
+ cameras.forEach((camera, index) => {
641
+ const verkadaCamera = camera.verkadaModel
642
+ ? getCameraById(camera.verkadaModel)
643
+ : null;
644
+
645
+ // Create main marker container
646
+ const markerContainer = document.createElement('div');
647
+ markerContainer.className = 'camera-marker-container';
648
+
649
+ // Create marker element
650
+ const markerEl = document.createElement('div');
651
+ const cameraType = verkadaCamera ? verkadaCamera.type : 'unknown';
652
+ const environment = verkadaCamera
653
+ ? verkadaCamera.environment
654
+ : 'outdoor';
655
+
656
+ markerEl.className = `camera-map-marker ${cameraType} ${environment}`;
657
+
658
+ // Extract camera number from name or use index + 1
659
+ const cameraNumber =
660
+ (camera.customName || camera.name).replace(/\D/g, '') ||
661
+ index + 1;
662
+
663
+ markerEl.innerHTML = `
664
+ <div class="camera-marker-content">
665
+ <div class="camera-number">${cameraNumber}</div>
666
+ </div>
667
+ `;
668
+
669
+ // Create rotation handle
670
+ const rotationHandle = document.createElement('div');
671
+ rotationHandle.className = 'rotation-handle';
672
+ rotationHandle.innerHTML = '<div class="rotation-icon"></div>';
673
+
674
+ // Add rotation functionality
675
+ let isRotating = false;
676
+ let startAngle = 0;
677
+ let currentRotation = camera.direction || 0;
678
+
679
+ const handleRotationStart = (e) => {
680
+ e.stopPropagation();
681
+ isRotating = true;
682
+ const rect = markerContainer.getBoundingClientRect();
683
+ const centerX = rect.left + rect.width / 2;
684
+ const centerY = rect.top + rect.height / 2;
685
+ startAngle =
686
+ (Math.atan2(e.clientY - centerY, e.clientX - centerX) *
687
+ 180) /
688
+ Math.PI;
689
+ markerContainer.classList.add('rotating');
690
+ document.addEventListener('mousemove', handleRotationMove);
691
+ document.addEventListener('mouseup', handleRotationEnd);
692
+ };
693
+
694
+ const handleRotationMove = (e) => {
695
+ if (!isRotating) return;
696
+ const rect = markerContainer.getBoundingClientRect();
697
+ const centerX = rect.left + rect.width / 2;
698
+ const centerY = rect.top + rect.height / 2;
699
+ const currentAngle =
700
+ (Math.atan2(e.clientY - centerY, e.clientX - centerX) *
701
+ 180) /
702
+ Math.PI;
703
+ const deltaAngle = currentAngle - startAngle;
704
+ const newRotation = (currentRotation + deltaAngle + 360) % 360;
705
+
706
+ // Update visual rotation
707
+ rotationHandle.style.transform = `rotate(${newRotation}deg)`;
708
+
709
+ // Update camera direction in real-time
710
+ updateCamera(camera.id, { direction: Math.round(newRotation) });
711
+ };
712
+
713
+ const handleRotationEnd = () => {
714
+ isRotating = false;
715
+ markerContainer.classList.remove('rotating');
716
+ document.removeEventListener('mousemove', handleRotationMove);
717
+ document.removeEventListener('mouseup', handleRotationEnd);
718
+
719
+ // Get final rotation value
720
+ const transform = rotationHandle.style.transform;
721
+ const match = transform.match(/rotate\(([^)]+)deg\)/);
722
+ if (match) {
723
+ currentRotation = parseFloat(match[1]);
724
+ updateCamera(camera.id, {
725
+ direction: Math.round(currentRotation),
726
+ });
727
+ }
728
+ };
729
+
730
+ // Add event listeners
731
+ rotationHandle.addEventListener('mousedown', handleRotationStart);
732
+
733
+ // Set initial rotation
734
+ rotationHandle.style.transform = `rotate(${currentRotation}deg)`;
735
+
736
+ // Click handler for main marker (not rotation handle)
737
+ markerEl.onclick = () => {
738
+ if (!isRotating) {
739
+ setSelectedCamera(camera);
740
+ setShowModal(true);
741
+ }
742
+ };
743
+
744
+ // Assemble marker structure
745
+ markerContainer.appendChild(markerEl);
746
+ markerContainer.appendChild(rotationHandle);
747
+
748
+ // Add marker to map with drag functionality using Mapbox GL
749
+ const marker = new mapboxgl.Marker({
750
+ element: markerContainer,
751
+ draggable: true,
752
+ anchor: 'center', // Ensure marker is centered on coordinates
753
+ })
754
+ .setLngLat([camera.lng, camera.lat])
755
+ .addTo(map);
756
+
757
+ // Handle drag start
758
+ marker.on('dragstart', () => {
759
+ markerContainer.classList.add('dragging');
760
+ // Hide rotation handle during drag
761
+ rotationHandle.style.opacity = '0.2';
762
+ });
763
+
764
+ // Handle drag (real-time coordinate updates)
765
+ marker.on('drag', () => {
766
+ const { lng, lat } = marker.getLngLat();
767
+ // Update coordinates in real-time (optional - can be removed if performance is an issue)
768
+ updateCamera(camera.id, {
769
+ lng: parseFloat(lng.toFixed(6)),
770
+ lat: parseFloat(lat.toFixed(6)),
771
+ });
772
+ });
773
+
774
+ // Handle drag end to finalize camera position
775
+ marker.on('dragend', () => {
776
+ markerContainer.classList.remove('dragging');
777
+ rotationHandle.style.opacity = '0.9';
778
+
779
+ const { lng, lat } = marker.getLngLat();
780
+ updateCamera(camera.id, {
781
+ lng: parseFloat(lng.toFixed(6)),
782
+ lat: parseFloat(lat.toFixed(6)),
783
+ });
784
+ });
785
+
786
+ // Prevent rotation handle from triggering drag
787
+ rotationHandle.addEventListener('mousedown', (e) => {
788
+ e.stopPropagation();
789
+ marker.setDraggable(false);
790
+ });
791
+
792
+ // Re-enable dragging after rotation ends
793
+ const enableDragAfterRotation = () => {
794
+ setTimeout(() => {
795
+ marker.setDraggable(true);
796
+ }, 100);
797
+ };
798
+
799
+ document.addEventListener('mouseup', enableDragAfterRotation);
800
+
801
+ // Add FOV visualization if camera model is selected
802
+ if (verkadaCamera) {
803
+ const fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov; // Use 90 degrees for PTZ as default view
804
+ const fovRad = (fov * Math.PI) / 180;
805
+ const distance = verkadaCamera.ptz ? 0.001 : 0.0005; // Larger range for PTZ
806
+ const directionRad = (camera.direction * Math.PI) / 180;
807
+
808
+ // Calculate FOV triangle points
809
+ const point1Lng =
810
+ camera.lng + distance * Math.cos(directionRad - fovRad / 2);
811
+ const point1Lat =
812
+ camera.lat + distance * Math.sin(directionRad - fovRad / 2);
813
+ const point2Lng =
814
+ camera.lng + distance * Math.cos(directionRad + fovRad / 2);
815
+ const point2Lat =
816
+ camera.lat + distance * Math.sin(directionRad + fovRad / 2);
817
+
818
+ const fovPolygon = {
819
+ type: 'Feature',
820
+ geometry: {
821
+ type: 'Polygon',
822
+ coordinates: [
823
+ [
824
+ [camera.lng, camera.lat],
825
+ [point1Lng, point1Lat],
826
+ [point2Lng, point2Lat],
827
+ [camera.lng, camera.lat],
828
+ ],
829
+ ],
830
+ },
831
+ };
832
+
833
+ // Add FOV polygon to map
834
+ const sourceId = `fov-${camera.id}`;
835
+ const layerId = `fov-layer-${camera.id}`;
836
+
837
+ map.addSource(sourceId, {
838
+ type: 'geojson',
839
+ data: fovPolygon,
840
+ });
841
+
842
+ // Color based on camera type and environment
843
+ const fovColor =
844
+ verkadaCamera.environment === 'indoor'
845
+ ? '#10b981'
846
+ : '#f59e0b';
847
+
848
+ map.addLayer({
849
+ id: layerId,
850
+ type: 'fill',
851
+ source: sourceId,
852
+ paint: {
853
+ 'fill-color': fovColor,
854
+ 'fill-opacity': 0.25,
855
+ },
856
+ });
857
+ }
858
+ });
859
+ }, [map, cameras, mode]);
860
+
861
+
862
+ return (
863
+ <div className={styles.cameraPlacement}>
864
+ <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
869
+ onClick={() => fileInputRef.current?.click()}
870
+ 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
885
+ onClick={saveAsFile}
886
+ className={styles.saveFileBtn}
887
+ disabled={!cameras.length && !uploadedImage}
888
+ title="Save camera placement as JSON file"
889
+ >
890
+ Save File
891
+ </button>
892
+ <button
893
+ onClick={loadFromFile}
894
+ className={styles.loadFileBtn}
895
+ title="Load camera placement from JSON file"
896
+ >
897
+ Load File
898
+ </button>
899
+ <button
900
+ onClick={() =>
901
+ setSidebarCollapsed(!sidebarCollapsed)
902
+ }
903
+ >
904
+ {sidebarCollapsed ? 'Show' : 'Hide'} Cameras
905
+ </button>
906
+ <button
907
+ className={styles.exportBtn}
908
+ onClick={exportMap}
909
+ disabled={mode === 'image' && !uploadedImage}
910
+ >
911
+ Export {mode === 'map' ? 'Map' : 'Image'}
912
+ </button>
913
+ </div>
914
+ </div>
915
+ </div>
916
+
917
+ <div className={styles.mainContent}>
918
+ <div
919
+ className={`${styles.imageContainer} ${addCameraMode ? styles.addMode : ''} ${sidebarCollapsed ? styles.sidebarCollapsed : ''}`}
920
+ ref={imageContainer}
921
+ onClick={handleImageClick}
922
+ >
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(() => {
1014
+ // Check if element still exists and is in DOM
1015
+ if (!targetElement || !targetElement.parentNode) {
1016
+ return;
1017
+ }
1018
+
1019
+
1020
+ const tooltip = document.createElement('div');
1021
+ tooltip.className = styles.cameraTooltip;
1022
+ tooltip.innerHTML = `
1023
+ <div class="${styles.tooltipImage}">
1024
+ <img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
1025
+ </div>
1026
+ <div class="${styles.tooltipContent}">
1027
+ <div class="${styles.tooltipTitle}">${verkadaCamera.name}</div>
1028
+ <div class="${styles.tooltipSpecs}">
1029
+ <div>Resolution: ${verkadaCamera.resolution}</div>
1030
+ <div>FOV: ${verkadaCamera.fov}°</div>
1031
+ <div>Environment: ${verkadaCamera.environment}</div>
1032
+ </div>
1033
+ </div>
1034
+ `;
1035
+
1036
+ // Position tooltip away from rotation handle
1037
+ tooltip.style.left = '50px';
1038
+ tooltip.style.top = '-10px';
1039
+
1040
+ targetElement.appendChild(tooltip);
1041
+ targetElement._tooltip = tooltip;
1042
+
1043
+ }, 300); // 300ms delay
1044
+ }
1045
+ }}
1046
+ onMouseLeave={(e) => {
1047
+ // Clear timeout if leaving before tooltip shows
1048
+ if (e.currentTarget._tooltipTimeout) {
1049
+ clearTimeout(e.currentTarget._tooltipTimeout);
1050
+ e.currentTarget._tooltipTimeout = null;
1051
+ }
1052
+ // Remove tooltip if it exists
1053
+ if (e.currentTarget._tooltip) {
1054
+ e.currentTarget.removeChild(e.currentTarget._tooltip);
1055
+ e.currentTarget._tooltip = null;
1056
+ }
1057
+ }}
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 });
1081
+ }
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);
1091
+ }
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;
1114
+ }
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');
1155
+ }
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');
1180
+ }
1181
+
1182
+ // Update camera direction
1183
+ updateCamera(camera.id, { direction: newRotation });
1184
+ }
1185
+ };
1186
+
1187
+ const handleRotationEnd = () => {
1188
+
1189
+ if (isRotating) {
1190
+ markerContainer.classList.remove('rotating');
1191
+ }
1192
+ document.removeEventListener('mousemove', handleRotationMove);
1193
+ document.removeEventListener('mouseup', handleRotationEnd);
1194
+ };
1195
+
1196
+ document.addEventListener('mousemove', handleRotationMove);
1197
+ document.addEventListener('mouseup', handleRotationEnd);
1198
+ }}
1199
+ />
1200
+ </div>
1201
+ </div>
1202
+ );
1203
+ })}
1204
+ </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>
1216
+ </div>
1217
+ )}
1218
+ </div>
1219
+ </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
+ />
1229
+
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>
1242
+
1243
+ <div className={styles.camerasList}>
1244
+ {cameras.length === 0 ? (
1245
+ <div
1246
+ style={{
1247
+ textAlign: 'center',
1248
+ color: '#6c757d',
1249
+ padding: '40px 20px',
1250
+ fontStyle: 'italic',
1251
+ }}
1252
+ >
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>
1264
+ </div>
1265
+ ) : (
1266
+ cameras.map((camera) => {
1267
+ const verkadaCamera = camera.verkadaModel
1268
+ ? getCameraById(camera.verkadaModel)
1269
+ : null;
1270
+
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'}}
1293
+ />
1294
+ <div className={styles.sidebarCameraIcon} style={{display: 'none'}}>
1295
+ <CameraIcon
1296
+ icon={verkadaCamera.icon}
1297
+ size={16}
1298
+ />
1299
+ </div>
1300
+ </div>
1301
+ )}
1302
+ <div className={styles.sidebarCameraText}>
1303
+ <h4 className={styles.cameraName}>
1304
+ {camera.customName || camera.name}
1305
+ </h4>
1306
+ {verkadaCamera && (
1307
+ <span
1308
+ className={`${styles.cameraType} ${styles[verkadaCamera.environment]}`}
1309
+ >
1310
+ {verkadaCamera.id}
1311
+ </span>
1312
+ )}
1313
+ </div>
1314
+ </div>
1315
+
1316
+ {/* Compact action buttons */}
1317
+ <div className={styles.cameraActions}>
1318
+ <button
1319
+ className={styles.iconBtn}
1320
+ onClick={() => {
1321
+ setSelectedCamera(camera);
1322
+ setShowModal(true);
1323
+ }}
1324
+ title="Edit camera"
1325
+ >
1326
+ <Edit3 size={16} />
1327
+ </button>
1328
+ <button
1329
+ className={styles.iconBtn}
1330
+ onClick={() => removeCamera(camera.id)}
1331
+ title="Remove camera"
1332
+ >
1333
+ <Trash2 size={16} />
1334
+ </button>
1335
+ </div>
1336
+ </div>
1337
+
1338
+ {verkadaCamera ? (
1339
+ <div
1340
+ className={styles.cameraDetails}
1341
+ >
1342
+ <div
1343
+ className={
1344
+ modalStyles.cameraModelName
1345
+ }
1346
+ >
1347
+ <strong>
1348
+ {verkadaCamera.name}
1349
+ </strong>
1350
+ </div>
1351
+
1352
+ <div
1353
+ className={
1354
+ modalStyles.cameraSpecGrid
1355
+ }
1356
+ >
1357
+ <div
1358
+ className={
1359
+ modalStyles.specItem
1360
+ }
1361
+ >
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
1380
+ className={
1381
+ modalStyles.specItem
1382
+ }
1383
+ >
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
1400
+ className={
1401
+ modalStyles.specItem
1402
+ }
1403
+ >
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
+ }
1430
+ >
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
1450
+ className={
1451
+ modalStyles.specItem
1452
+ }
1453
+ >
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
1470
+ className={
1471
+ modalStyles.specItem
1472
+ }
1473
+ >
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
+ )}
1539
+ </div>
1540
+
1541
+ <div
1542
+ className={
1543
+ modalStyles.cameraFeatures
1544
+ }
1545
+ >
1546
+ <span
1547
+ className={
1548
+ modalStyles.specLabel
1549
+ }
1550
+ >
1551
+ Features:
1552
+ </span>
1553
+ <div
1554
+ className={
1555
+ modalStyles.featuresList
1556
+ }
1557
+ >
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>
1574
+ </div>
1575
+
1576
+ <div
1577
+ className={
1578
+ modalStyles.directionControl
1579
+ }
1580
+ >
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
+ }
1603
+ className={
1604
+ modalStyles.directionSlider
1605
+ }
1606
+ />
1607
+ <div
1608
+ className={
1609
+ modalStyles.directionHelper
1610
+ }
1611
+ >
1612
+ 0° = North, 90° = East,
1613
+ 180° = South, 270° =
1614
+ West
1615
+ </div>
1616
+ </div>
1617
+ </div>
1618
+ ) : (
1619
+ <div
1620
+ className={styles.cameraDetails}
1621
+ >
1622
+ <div
1623
+ className={
1624
+ modalStyles.noModel
1625
+ }
1626
+ >
1627
+ No Verkada model selected -
1628
+ Click "Edit" to choose a
1629
+ camera
1630
+ </div>
1631
+ <div
1632
+ className={
1633
+ modalStyles.directionControl
1634
+ }
1635
+ >
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
+ ),
1655
+ }
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>
1671
+ </div>
1672
+ </div>
1673
+ )}
1674
+
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
+ )}
1689
+ </div>
1690
+
1691
+ </div>
1692
+ );
1693
+ })
1694
+ )}
1695
+ </div>
1696
+ </div>
1697
+
1698
+ {showModal && selectedCamera && (
1699
+ <CameraSelectionModal
1700
+ camera={selectedCamera}
1701
+ onSave={(updates) => {
1702
+ updateCamera(selectedCamera.id, updates);
1703
+ setShowModal(false);
1704
+ }}
1705
+ onClose={() => {
1706
+ setShowModal(false);
1707
+ }}
1708
+ />
1709
+ )}
1710
+ </div>
1711
+ );
1712
+ };
1713
+
1714
+ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1715
+ // Determine if this is editing an existing camera or adding a new one
1716
+ const isUpdating = Boolean(camera.verkadaModel);
1717
+
1718
+ const [selectedCategory, setSelectedCategory] = useState('all');
1719
+ const [selectedCamera, setSelectedCamera] = useState(camera.verkadaModel ? getCameraById(camera.verkadaModel) : null);
1720
+
1721
+ // Filter cameras based on selected category
1722
+ const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
1723
+ if (selectedCategory === 'all') return true;
1724
+ return cam.type === selectedCategory;
1725
+ });
1726
+
1727
+
1728
+ const handleCameraSelect = (cameraModel) => {
1729
+ setSelectedCamera(cameraModel);
1730
+ };
1731
+
1732
+ const handleSave = () => {
1733
+ if (!selectedCamera) {
1734
+ alert('Please select a camera model before saving.');
1735
+ return;
1736
+ }
1737
+
1738
+ onSave({
1739
+ verkadaModel: selectedCamera.id,
1740
+ });
1741
+ };
1742
+
1743
+ return (
1744
+ <div className={styles.modalOverlay}>
1745
+ <div className={styles.cameraSelectionModal}>
1746
+ <div className={styles.modalHeader}>
1747
+ <h3>Select Verkada Camera</h3>
1748
+ <button className={styles.closeBtn} onClick={onClose}>×</button>
1749
+ </div>
1750
+
1751
+ <div className={styles.modalContent}>
1752
+ <div className={styles.topSection}>
1753
+ <div className={styles.categoryFilter}>
1754
+ <label>Filter by Category:</label>
1755
+ <div className={styles.categoryButtons}>
1756
+ <button
1757
+ className={selectedCategory === 'all' ? styles.active : ''}
1758
+ onClick={() => setSelectedCategory('all')}
1759
+ >
1760
+ All Cameras
1761
+ </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
+ ))}
1771
+ </div>
1772
+ </div>
1773
+ </div>
1774
+
1775
+ <div className={styles.cameraGrid}>
1776
+ {filteredCameras.map((cam) => (
1777
+ <div
1778
+ key={cam.id}
1779
+ className={`${styles.cameraCard} ${selectedCamera?.id === cam.id ? styles.selectedCard : ''}`}
1780
+ onClick={() => handleCameraSelect(cam)}
1781
+ >
1782
+ <div className={styles.cardHeader}>
1783
+ <div className={styles.cameraImageContainer}>
1784
+ <img
1785
+ src={cam.image}
1786
+ alt={cam.name}
1787
+ className={styles.cameraImage}
1788
+ onLoad={(e) => {
1789
+ e.target.style.opacity = '1';
1790
+ }}
1791
+ onError={(e) => {
1792
+ // Fallback to icon if image fails to load
1793
+ e.target.style.display = 'none';
1794
+ e.target.nextSibling.style.display = 'flex';
1795
+ }}
1796
+ style={{opacity: '0.3'}}
1797
+ />
1798
+ <div className={styles.cameraIcon} style={{display: 'none'}}>
1799
+ <CameraIcon icon={cam.icon} size={24} />
1800
+ </div>
1801
+ </div>
1802
+ <div className={styles.cameraTitle}>
1803
+ <h4>{cam.name}</h4>
1804
+ <span className={`${styles.environmentBadge} ${styles[cam.environment]}`}>
1805
+ {cam.environment}
1806
+ </span>
1807
+ </div>
1808
+ </div>
1809
+
1810
+ <div className={styles.cardBody}>
1811
+ <div className={styles.specRow}>
1812
+ <span className={styles.specLabel}>Resolution:</span>
1813
+ <span className={styles.specValue}>{cam.resolution}</span>
1814
+ </div>
1815
+ <div className={styles.specRow}>
1816
+ <span className={styles.specLabel}>FOV:</span>
1817
+ <span className={styles.specValue}>{cam.fov}°</span>
1818
+ </div>
1819
+ <div className={styles.specRow}>
1820
+ <span className={styles.specLabel}>Range:</span>
1821
+ <span className={styles.specValue}>{cam.irRange}m IR</span>
1822
+ </div>
1823
+ {cam.ptz && (
1824
+ <div className={styles.ptzBadge}>PTZ</div>
1825
+ )}
1826
+ </div>
1827
+
1828
+ <div className={styles.cardFooter}>
1829
+ <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
+ ))}
1835
+ {cam.features?.length > 2 && (
1836
+ <span className={styles.moreFeatures}>
1837
+ +{cam.features.length - 2} more
1838
+ </span>
1839
+ )}
1840
+ </div>
1841
+ </div>
1842
+ </div>
1843
+ ))}
1844
+ </div>
1845
+ </div>
1846
+
1847
+ <div className={styles.modalActions}>
1848
+ <div className={styles.selectedInfo}>
1849
+ {selectedCamera && (
1850
+ <span>Selected: {selectedCamera.name}</span>
1851
+ )}
1852
+ </div>
1853
+ <div className={styles.actionButtons}>
1854
+ <button onClick={onClose} className={styles.cancelBtn}>
1855
+ Cancel
1856
+ </button>
1857
+ <button
1858
+ onClick={handleSave}
1859
+ disabled={!selectedCamera}
1860
+ className={styles.saveBtn}
1861
+ >
1862
+ {isUpdating ? 'Update Camera' : 'Add Camera'}
1863
+ </button>
1864
+ </div>
1865
+ </div>
1866
+ </div>
1867
+ </div>
1868
+ );
1869
+ };
1870
+
1871
+ export default CameraPlacement;