@visns-studio/visns-components 5.15.8 → 5.15.9
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.
package/package.json
CHANGED
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
95
95
|
},
|
|
96
96
|
"name": "@visns-studio/visns-components",
|
|
97
|
-
"version": "5.15.
|
|
97
|
+
"version": "5.15.9",
|
|
98
98
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
99
99
|
"main": "src/index.js",
|
|
100
100
|
"files": [
|
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
Upload,
|
|
13
13
|
Save,
|
|
14
14
|
FolderOpen,
|
|
15
|
-
Eye,
|
|
16
|
-
EyeOff,
|
|
17
15
|
Download,
|
|
18
16
|
FileImage,
|
|
19
17
|
Lightbulb,
|
|
@@ -33,14 +31,18 @@ import {
|
|
|
33
31
|
} from './data/verkadaCameras';
|
|
34
32
|
import CameraIcon from './utils/CameraIcon';
|
|
35
33
|
|
|
36
|
-
const CameraPlacement = () => {
|
|
34
|
+
const CameraPlacement = ({ userProfile }) => {
|
|
37
35
|
const [cameras, setCameras] = useState([]);
|
|
38
36
|
const [selectedCamera, setSelectedCamera] = useState(null);
|
|
39
37
|
const [showModal, setShowModal] = useState(false);
|
|
40
38
|
const [map, setMap] = useState(null);
|
|
41
|
-
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
42
39
|
const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
|
|
43
40
|
const [uploadedImage, setUploadedImage] = useState(null);
|
|
41
|
+
const [projectName, setProjectName] = useState('');
|
|
42
|
+
const [sidebarTab, setSidebarTab] = useState('cameras'); // 'cameras' or 'projects'
|
|
43
|
+
const [savedProjects, setSavedProjects] = useState([]);
|
|
44
|
+
const [selectedProject, setSelectedProject] = useState(null);
|
|
45
|
+
const [loadingProjects, setLoadingProjects] = useState(false);
|
|
44
46
|
const mapContainer = useRef(null);
|
|
45
47
|
const mapRef = useRef(null);
|
|
46
48
|
const geocoderRef = useRef(null);
|
|
@@ -49,6 +51,7 @@ const CameraPlacement = () => {
|
|
|
49
51
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
50
52
|
const [isRotating, setIsRotating] = useState(false);
|
|
51
53
|
|
|
54
|
+
|
|
52
55
|
// Mapbox configuration
|
|
53
56
|
const MAPBOX_ACCESS_TOKEN =
|
|
54
57
|
'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
|
|
@@ -69,7 +72,6 @@ const CameraPlacement = () => {
|
|
|
69
72
|
try {
|
|
70
73
|
map.remove();
|
|
71
74
|
} catch (error) {
|
|
72
|
-
console.warn('Error removing map:', error);
|
|
73
75
|
}
|
|
74
76
|
setMap(null);
|
|
75
77
|
}
|
|
@@ -166,7 +168,6 @@ const CameraPlacement = () => {
|
|
|
166
168
|
try {
|
|
167
169
|
map.remove();
|
|
168
170
|
} catch (error) {
|
|
169
|
-
console.warn('Error removing map:', error);
|
|
170
171
|
}
|
|
171
172
|
setMap(null);
|
|
172
173
|
}
|
|
@@ -335,8 +336,43 @@ const CameraPlacement = () => {
|
|
|
335
336
|
[map, cameras]
|
|
336
337
|
);
|
|
337
338
|
|
|
338
|
-
//
|
|
339
|
-
const
|
|
339
|
+
// Fetch saved projects (lightweight - no heavy JSON fields)
|
|
340
|
+
const fetchSavedProjects = useCallback(async () => {
|
|
341
|
+
if (!userProfile?.id) return;
|
|
342
|
+
|
|
343
|
+
setLoadingProjects(true);
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch('/ajax/cameraPlacement/table', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: {
|
|
348
|
+
'Content-Type': 'application/json',
|
|
349
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
350
|
+
},
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
page: 1,
|
|
353
|
+
per_page: 100,
|
|
354
|
+
order_by: 'created_at',
|
|
355
|
+
order: 'desc'
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const responseText = await response.text();
|
|
360
|
+
const result = JSON.parse(responseText);
|
|
361
|
+
|
|
362
|
+
if (result.data && Array.isArray(result.data)) {
|
|
363
|
+
setSavedProjects(result.data);
|
|
364
|
+
} else if (result.data && Array.isArray(result.data.data)) {
|
|
365
|
+
setSavedProjects(result.data.data);
|
|
366
|
+
}
|
|
367
|
+
} catch (error) {
|
|
368
|
+
} finally {
|
|
369
|
+
setLoadingProjects(false);
|
|
370
|
+
}
|
|
371
|
+
}, [userProfile]);
|
|
372
|
+
|
|
373
|
+
// Save placement to database
|
|
374
|
+
const saveToDatabase = useCallback(async () => {
|
|
375
|
+
|
|
340
376
|
if (!cameras.length && !uploadedImage) {
|
|
341
377
|
Swal.fire({
|
|
342
378
|
icon: 'warning',
|
|
@@ -347,115 +383,367 @@ const CameraPlacement = () => {
|
|
|
347
383
|
return;
|
|
348
384
|
}
|
|
349
385
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
cameras: cameras.map((camera) => ({
|
|
361
|
-
id: camera.id,
|
|
362
|
-
name: camera.name,
|
|
363
|
-
customName: camera.customName,
|
|
364
|
-
x: camera.x,
|
|
365
|
-
y: camera.y,
|
|
366
|
-
direction: camera.direction,
|
|
367
|
-
verkadaModel: camera.verkadaModel,
|
|
368
|
-
timestamp: camera.timestamp,
|
|
369
|
-
})),
|
|
370
|
-
};
|
|
386
|
+
// Enhanced camera data validation
|
|
387
|
+
if (cameras.length === 0) {
|
|
388
|
+
Swal.fire({
|
|
389
|
+
icon: 'warning',
|
|
390
|
+
title: 'No Cameras to Save',
|
|
391
|
+
text: 'Please place at least one camera before saving. Click on the image to add cameras.',
|
|
392
|
+
confirmButtonColor: '#f59e0b',
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
371
396
|
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
linkElement.setAttribute('download', exportFileDefaultName);
|
|
384
|
-
linkElement.click();
|
|
385
|
-
|
|
386
|
-
// Show success message
|
|
387
|
-
Swal.fire({
|
|
388
|
-
icon: 'success',
|
|
389
|
-
title: 'File Saved!',
|
|
390
|
-
text: `Camera placement saved as ${exportFileDefaultName}`,
|
|
391
|
-
timer: 3000,
|
|
392
|
-
showConfirmButton: false,
|
|
393
|
-
toast: true,
|
|
394
|
-
position: 'top-end',
|
|
397
|
+
// Validate each camera has required data
|
|
398
|
+
const invalidCameras = cameras.filter(camera => {
|
|
399
|
+
const issues = [];
|
|
400
|
+
|
|
401
|
+
if (!camera.id) issues.push('missing ID');
|
|
402
|
+
if (typeof camera.x !== 'number' || isNaN(camera.x)) issues.push('invalid X coordinate');
|
|
403
|
+
if (typeof camera.y !== 'number' || isNaN(camera.y)) issues.push('invalid Y coordinate');
|
|
404
|
+
if (!camera.name || camera.name.trim() === '') issues.push('missing name');
|
|
405
|
+
if (typeof camera.direction !== 'number' || isNaN(camera.direction)) issues.push('invalid direction');
|
|
406
|
+
|
|
407
|
+
return issues.length > 0;
|
|
395
408
|
});
|
|
396
|
-
}, [cameras, uploadedImage, mode]);
|
|
397
409
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
410
|
+
if (invalidCameras.length > 0) {
|
|
411
|
+
Swal.fire({
|
|
412
|
+
icon: 'error',
|
|
413
|
+
title: 'Invalid Camera Data',
|
|
414
|
+
text: `${invalidCameras.length} camera(s) have invalid data. Please check all cameras have proper positioning, names, and direction values.`,
|
|
415
|
+
confirmButtonColor: '#ef4444',
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
403
419
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
420
|
+
// Validate cameras_data array will not be empty
|
|
421
|
+
const validCamerasData = cameras.map((camera) => ({
|
|
422
|
+
id: camera.id,
|
|
423
|
+
name: camera.name,
|
|
424
|
+
customName: camera.customName,
|
|
425
|
+
x: camera.x,
|
|
426
|
+
y: camera.y,
|
|
427
|
+
direction: camera.direction,
|
|
428
|
+
verkadaModel: camera.verkadaModel,
|
|
429
|
+
timestamp: camera.timestamp,
|
|
430
|
+
})).filter(camera => camera.id && camera.name);
|
|
431
|
+
|
|
432
|
+
if (validCamerasData.length === 0) {
|
|
433
|
+
Swal.fire({
|
|
434
|
+
icon: 'error',
|
|
435
|
+
title: 'No Valid Camera Data',
|
|
436
|
+
text: 'All cameras seem to have missing or invalid data. Please recreate your cameras.',
|
|
437
|
+
confirmButtonColor: '#ef4444',
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
407
441
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
442
|
+
if (!projectName.trim()) {
|
|
443
|
+
Swal.fire({
|
|
444
|
+
icon: 'warning',
|
|
445
|
+
title: 'Project Name Required',
|
|
446
|
+
text: 'Please enter a project name before saving.',
|
|
447
|
+
confirmButtonColor: '#8b5cf6',
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
412
451
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
452
|
+
// Validate user profile
|
|
453
|
+
if (!userProfile || !userProfile.id) {
|
|
454
|
+
Swal.fire({
|
|
455
|
+
icon: 'error',
|
|
456
|
+
title: 'Authentication Error',
|
|
457
|
+
text: 'User profile is required to save camera placements.',
|
|
458
|
+
confirmButtonColor: '#ef4444',
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
423
462
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
463
|
+
// Validate mode
|
|
464
|
+
if (!mode || !['image', 'map'].includes(mode)) {
|
|
465
|
+
Swal.fire({
|
|
466
|
+
icon: 'error',
|
|
467
|
+
title: 'Invalid Mode',
|
|
468
|
+
text: 'Invalid placement mode. Please refresh the page and try again.',
|
|
469
|
+
confirmButtonColor: '#ef4444',
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
428
473
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
474
|
+
try {
|
|
475
|
+
// Check if we're updating an existing project or creating a new one
|
|
476
|
+
const isUpdating = selectedProject && selectedProject.id;
|
|
477
|
+
const method = isUpdating ? 'PUT' : 'POST';
|
|
478
|
+
const url = isUpdating ? `/ajax/cameraPlacement/${selectedProject.id}` : '/ajax/cameraPlacement';
|
|
479
|
+
|
|
480
|
+
const saveData = {
|
|
481
|
+
name: projectName.trim(),
|
|
482
|
+
project_name: projectName.trim(),
|
|
483
|
+
version: '1.0',
|
|
484
|
+
mode: mode,
|
|
485
|
+
user_id: userProfile.id,
|
|
486
|
+
image_data: uploadedImage
|
|
487
|
+
? {
|
|
488
|
+
src: uploadedImage,
|
|
489
|
+
name: 'uploaded-image',
|
|
490
|
+
}
|
|
491
|
+
: null,
|
|
492
|
+
image_name: uploadedImage ? 'uploaded-image' : null,
|
|
493
|
+
cameras_data: validCamerasData,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const response = await fetch(url, {
|
|
497
|
+
method: method,
|
|
498
|
+
headers: {
|
|
499
|
+
'Content-Type': 'application/json',
|
|
500
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
501
|
+
},
|
|
502
|
+
body: JSON.stringify(saveData),
|
|
503
|
+
});
|
|
433
504
|
|
|
434
|
-
|
|
435
|
-
setCameras(saveData.cameras || []);
|
|
505
|
+
const responseText = await response.text();
|
|
436
506
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
title: 'File Error',
|
|
450
|
-
text: "Error reading file. Please ensure it's a valid JSON file.",
|
|
451
|
-
confirmButtonColor: '#f59e0b',
|
|
452
|
-
});
|
|
507
|
+
let result;
|
|
508
|
+
try {
|
|
509
|
+
result = JSON.parse(responseText);
|
|
510
|
+
} catch (parseError) {
|
|
511
|
+
throw new Error(`Server returned invalid JSON. Status: ${response.status}, Response: ${responseText.substring(0, 200)}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// DynamicController returns { data: {...}, error: "" } format
|
|
515
|
+
if (result.data && !result.error) {
|
|
516
|
+
// Update the selectedProject with the latest data if we were updating
|
|
517
|
+
if (isUpdating) {
|
|
518
|
+
setSelectedProject(result.data);
|
|
453
519
|
}
|
|
454
|
-
};
|
|
455
|
-
reader.readAsText(file);
|
|
456
|
-
};
|
|
457
520
|
|
|
458
|
-
|
|
521
|
+
Swal.fire({
|
|
522
|
+
icon: 'success',
|
|
523
|
+
title: isUpdating ? 'Updated!' : 'Saved!',
|
|
524
|
+
text: `Camera placement "${projectName}" ${isUpdating ? 'updated' : 'saved'} successfully`,
|
|
525
|
+
timer: 3000,
|
|
526
|
+
showConfirmButton: false,
|
|
527
|
+
toast: true,
|
|
528
|
+
position: 'top-end',
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Refresh the projects list to show updated data
|
|
532
|
+
fetchSavedProjects();
|
|
533
|
+
} else {
|
|
534
|
+
const errorMessage = result.error || result.message || 'Save failed';
|
|
535
|
+
const errorDetails = result.errors ? JSON.stringify(result.errors) : '';
|
|
536
|
+
throw new Error(`${errorMessage}${errorDetails ? ` - Details: ${errorDetails}` : ''}`);
|
|
537
|
+
}
|
|
538
|
+
} catch (error) {
|
|
539
|
+
Swal.fire({
|
|
540
|
+
icon: 'error',
|
|
541
|
+
title: 'Save Failed',
|
|
542
|
+
text: error.message || 'Failed to save camera placement. Please try again.',
|
|
543
|
+
confirmButtonColor: '#ef4444',
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}, [cameras, uploadedImage, mode, projectName, userProfile, selectedProject, fetchSavedProjects]);
|
|
547
|
+
|
|
548
|
+
// Fetch full project data with heavy JSON fields
|
|
549
|
+
const fetchFullProject = useCallback(async (projectId) => {
|
|
550
|
+
try {
|
|
551
|
+
const response = await fetch(`/ajax/cameraPlacement/${projectId}`, {
|
|
552
|
+
headers: {
|
|
553
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const responseText = await response.text();
|
|
558
|
+
const result = JSON.parse(responseText);
|
|
559
|
+
|
|
560
|
+
if (result.id) {
|
|
561
|
+
// Direct object response (not wrapped in data)
|
|
562
|
+
return result;
|
|
563
|
+
} else if (result.data) {
|
|
564
|
+
// Wrapped in data object
|
|
565
|
+
return result.data;
|
|
566
|
+
} else {
|
|
567
|
+
throw new Error('Failed to fetch full project data');
|
|
568
|
+
}
|
|
569
|
+
} catch (error) {
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}, []);
|
|
573
|
+
|
|
574
|
+
// Load a specific project
|
|
575
|
+
const loadProject = useCallback(async (lightweightProject) => {
|
|
576
|
+
try {
|
|
577
|
+
// Show loading indicator
|
|
578
|
+
const loadingToast = Swal.fire({
|
|
579
|
+
title: 'Loading Project...',
|
|
580
|
+
text: 'Fetching project data',
|
|
581
|
+
allowOutsideClick: false,
|
|
582
|
+
showConfirmButton: false,
|
|
583
|
+
didOpen: () => {
|
|
584
|
+
Swal.showLoading();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Fetch full project data including heavy JSON fields
|
|
589
|
+
const fullProject = await fetchFullProject(lightweightProject.id);
|
|
590
|
+
|
|
591
|
+
// Close loading indicator
|
|
592
|
+
Swal.close();
|
|
593
|
+
|
|
594
|
+
setSelectedProject(fullProject);
|
|
595
|
+
setProjectName(fullProject.project_name);
|
|
596
|
+
setMode(fullProject.mode);
|
|
597
|
+
|
|
598
|
+
if (fullProject.image_data && fullProject.image_data.src) {
|
|
599
|
+
setUploadedImage(fullProject.image_data.src);
|
|
600
|
+
} else {
|
|
601
|
+
setUploadedImage(null);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
setCameras(fullProject.cameras_data || []);
|
|
605
|
+
setSidebarTab('cameras'); // Switch to cameras tab
|
|
606
|
+
|
|
607
|
+
Swal.fire({
|
|
608
|
+
icon: 'success',
|
|
609
|
+
title: 'Project Loaded!',
|
|
610
|
+
text: `Loaded "${fullProject.project_name}" with ${fullProject.cameras_data?.length || 0} cameras`,
|
|
611
|
+
timer: 2000,
|
|
612
|
+
showConfirmButton: false,
|
|
613
|
+
toast: true,
|
|
614
|
+
position: 'top-end',
|
|
615
|
+
});
|
|
616
|
+
} catch (error) {
|
|
617
|
+
Swal.fire({
|
|
618
|
+
icon: 'error',
|
|
619
|
+
title: 'Load Failed',
|
|
620
|
+
text: 'Failed to load project. Please try again.',
|
|
621
|
+
confirmButtonColor: '#ef4444',
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}, [fetchFullProject]);
|
|
625
|
+
|
|
626
|
+
// Fetch projects when user profile changes or sidebar switches to projects
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
if (sidebarTab === 'projects' && userProfile?.id) {
|
|
629
|
+
fetchSavedProjects();
|
|
630
|
+
}
|
|
631
|
+
}, [sidebarTab, userProfile, fetchSavedProjects]);
|
|
632
|
+
|
|
633
|
+
// Initial fetch of projects when component mounts to get correct count
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
if (userProfile?.id) {
|
|
636
|
+
fetchSavedProjects();
|
|
637
|
+
}
|
|
638
|
+
}, [userProfile, fetchSavedProjects]);
|
|
639
|
+
|
|
640
|
+
// Load placement from database (legacy - keeping for compatibility)
|
|
641
|
+
const loadFromDatabase = useCallback(async () => {
|
|
642
|
+
try {
|
|
643
|
+
const response = await fetch('/ajax/cameraPlacement/table', {
|
|
644
|
+
method: 'POST',
|
|
645
|
+
headers: {
|
|
646
|
+
'Content-Type': 'application/json',
|
|
647
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
648
|
+
},
|
|
649
|
+
body: JSON.stringify({
|
|
650
|
+
page: 1,
|
|
651
|
+
per_page: 100,
|
|
652
|
+
order_by: 'created_at',
|
|
653
|
+
order: 'desc'
|
|
654
|
+
}),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const result = await response.json();
|
|
658
|
+
|
|
659
|
+
if (result.data && result.data.length > 0) {
|
|
660
|
+
const placements = result.data;
|
|
661
|
+
|
|
662
|
+
// Show placement selection modal
|
|
663
|
+
const { value: selectedPlacement } = await Swal.fire({
|
|
664
|
+
title: 'Load Camera Placement',
|
|
665
|
+
html: `
|
|
666
|
+
<div style="text-align: left; max-height: 400px; overflow-y: auto;">
|
|
667
|
+
${placements.map(placement => `
|
|
668
|
+
<div style="padding: 12px; margin: 8px 0; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;"
|
|
669
|
+
onmouseover="this.style.backgroundColor='#f9fafb'"
|
|
670
|
+
onmouseout="this.style.backgroundColor='white'"
|
|
671
|
+
onclick="this.classList.toggle('selected'); document.querySelectorAll('.placement-item').forEach(el => el !== this && el.classList.remove('selected'));"
|
|
672
|
+
class="placement-item"
|
|
673
|
+
data-placement-id="${placement.id}">
|
|
674
|
+
<div style="font-weight: 600; color: #1f2937;">${placement.project_name}</div>
|
|
675
|
+
<div style="font-size: 14px; color: #6b7280; margin-top: 4px;">
|
|
676
|
+
${placement.cameras_count} cameras • ${placement.mode} mode
|
|
677
|
+
</div>
|
|
678
|
+
<div style="font-size: 12px; color: #9ca3af; margin-top: 4px;">
|
|
679
|
+
Created: ${new Date(placement.created_at).toLocaleDateString()}
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
`).join('')}
|
|
683
|
+
</div>
|
|
684
|
+
<style>
|
|
685
|
+
.placement-item.selected {
|
|
686
|
+
background-color: #dbeafe !important;
|
|
687
|
+
border-color: #3b82f6 !important;
|
|
688
|
+
}
|
|
689
|
+
</style>
|
|
690
|
+
`,
|
|
691
|
+
showCancelButton: true,
|
|
692
|
+
confirmButtonText: 'Load Selected',
|
|
693
|
+
cancelButtonText: 'Cancel',
|
|
694
|
+
confirmButtonColor: '#3b82f6',
|
|
695
|
+
preConfirm: () => {
|
|
696
|
+
const selectedElement = document.querySelector('.placement-item.selected');
|
|
697
|
+
if (!selectedElement) {
|
|
698
|
+
Swal.showValidationMessage('Please select a placement to load');
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
return selectedElement.getAttribute('data-placement-id');
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
if (selectedPlacement) {
|
|
706
|
+
const placement = placements.find(p => p.id == selectedPlacement);
|
|
707
|
+
if (placement) {
|
|
708
|
+
// Load the placement data
|
|
709
|
+
setProjectName(placement.project_name);
|
|
710
|
+
setMode(placement.mode);
|
|
711
|
+
|
|
712
|
+
if (placement.image_data && placement.image_data.src) {
|
|
713
|
+
setUploadedImage(placement.image_data.src);
|
|
714
|
+
} else {
|
|
715
|
+
setUploadedImage(null);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
setCameras(placement.cameras_data || []);
|
|
719
|
+
|
|
720
|
+
Swal.fire({
|
|
721
|
+
icon: 'success',
|
|
722
|
+
title: 'Loaded!',
|
|
723
|
+
text: `Successfully loaded "${placement.project_name}" with ${placement.cameras_count} cameras`,
|
|
724
|
+
timer: 3000,
|
|
725
|
+
showConfirmButton: false,
|
|
726
|
+
toast: true,
|
|
727
|
+
position: 'top-end',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
} else {
|
|
732
|
+
Swal.fire({
|
|
733
|
+
icon: 'info',
|
|
734
|
+
title: 'No Saved Placements',
|
|
735
|
+
text: 'You have no saved camera placements yet.',
|
|
736
|
+
confirmButtonColor: '#3b82f6',
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
} catch (error) {
|
|
740
|
+
Swal.fire({
|
|
741
|
+
icon: 'error',
|
|
742
|
+
title: 'Load Failed',
|
|
743
|
+
text: 'Failed to load camera placements. Please try again.',
|
|
744
|
+
confirmButtonColor: '#ef4444',
|
|
745
|
+
});
|
|
746
|
+
}
|
|
459
747
|
}, []);
|
|
460
748
|
|
|
461
749
|
const exportToPDF = useCallback(async () => {
|
|
@@ -501,7 +789,6 @@ const CameraPlacement = () => {
|
|
|
501
789
|
});
|
|
502
790
|
mapImageData = canvas.toDataURL('image/png', 0.9);
|
|
503
791
|
} catch (error) {
|
|
504
|
-
console.warn('Could not capture site map image:', error);
|
|
505
792
|
// Continue without image if capture fails
|
|
506
793
|
}
|
|
507
794
|
}
|
|
@@ -520,7 +807,6 @@ const CameraPlacement = () => {
|
|
|
520
807
|
})
|
|
521
808
|
).toBlob();
|
|
522
809
|
} catch (pdfError) {
|
|
523
|
-
console.warn('Enhanced PDF failed, trying simple version:', pdfError);
|
|
524
810
|
|
|
525
811
|
// Fallback to simple PDF
|
|
526
812
|
pdfBlob = await pdf(
|
|
@@ -586,7 +872,6 @@ const CameraPlacement = () => {
|
|
|
586
872
|
confirmButtonText: 'Great!',
|
|
587
873
|
});
|
|
588
874
|
} catch (error) {
|
|
589
|
-
console.error('Error generating PDF:', error);
|
|
590
875
|
|
|
591
876
|
// More specific error handling
|
|
592
877
|
let errorMessage = 'Unable to generate PDF report. Please try again.';
|
|
@@ -631,10 +916,6 @@ const CameraPlacement = () => {
|
|
|
631
916
|
);
|
|
632
917
|
}
|
|
633
918
|
} catch (error) {
|
|
634
|
-
console.warn(
|
|
635
|
-
'Could not access map style, skipping FOV layer cleanup:',
|
|
636
|
-
error
|
|
637
|
-
);
|
|
638
919
|
}
|
|
639
920
|
existingSources.forEach((sourceId) => {
|
|
640
921
|
try {
|
|
@@ -646,10 +927,6 @@ const CameraPlacement = () => {
|
|
|
646
927
|
map.removeSource(sourceId);
|
|
647
928
|
}
|
|
648
929
|
} catch (error) {
|
|
649
|
-
console.warn(
|
|
650
|
-
`Could not remove layer/source ${sourceId}:`,
|
|
651
|
-
error
|
|
652
|
-
);
|
|
653
930
|
}
|
|
654
931
|
});
|
|
655
932
|
|
|
@@ -899,8 +1176,18 @@ const CameraPlacement = () => {
|
|
|
899
1176
|
<div className={styles.cameraPlacement}>
|
|
900
1177
|
<div className={styles.header}>
|
|
901
1178
|
<div className={styles.headerContent}>
|
|
902
|
-
<
|
|
1179
|
+
<div className={styles.titleSection}>
|
|
1180
|
+
<h1 className={styles.title}>Site Map Planner</h1>
|
|
1181
|
+
</div>
|
|
903
1182
|
<div className={styles.controls}>
|
|
1183
|
+
<input
|
|
1184
|
+
type="text"
|
|
1185
|
+
placeholder="Project name..."
|
|
1186
|
+
value={projectName}
|
|
1187
|
+
onChange={(e) => setProjectName(e.target.value)}
|
|
1188
|
+
className={styles.projectInput}
|
|
1189
|
+
maxLength={100}
|
|
1190
|
+
/>
|
|
904
1191
|
<IconButton
|
|
905
1192
|
icon={Upload}
|
|
906
1193
|
onClick={() => fileInputRef.current?.click()}
|
|
@@ -909,26 +1196,11 @@ const CameraPlacement = () => {
|
|
|
909
1196
|
/>
|
|
910
1197
|
<IconButton
|
|
911
1198
|
icon={Save}
|
|
912
|
-
onClick={
|
|
1199
|
+
onClick={saveToDatabase}
|
|
913
1200
|
disabled={!cameras.length && !uploadedImage}
|
|
914
|
-
tooltip="Save camera placement
|
|
1201
|
+
tooltip="Save camera placement to database"
|
|
915
1202
|
className={styles.saveFileBtn}
|
|
916
1203
|
/>
|
|
917
|
-
<IconButton
|
|
918
|
-
icon={FolderOpen}
|
|
919
|
-
onClick={loadFromFile}
|
|
920
|
-
tooltip="Load camera placement from JSON file"
|
|
921
|
-
className={styles.loadFileBtn}
|
|
922
|
-
/>
|
|
923
|
-
<IconButton
|
|
924
|
-
icon={sidebarCollapsed ? Eye : EyeOff}
|
|
925
|
-
onClick={() =>
|
|
926
|
-
setSidebarCollapsed(!sidebarCollapsed)
|
|
927
|
-
}
|
|
928
|
-
tooltip={`${
|
|
929
|
-
sidebarCollapsed ? 'Show' : 'Hide'
|
|
930
|
-
} Cameras`}
|
|
931
|
-
/>
|
|
932
1204
|
<IconButton
|
|
933
1205
|
icon={Download}
|
|
934
1206
|
onClick={exportToPDF}
|
|
@@ -944,9 +1216,7 @@ const CameraPlacement = () => {
|
|
|
944
1216
|
<div
|
|
945
1217
|
className={`${styles.imageContainer} ${
|
|
946
1218
|
uploadedImage ? styles.addMode : ''
|
|
947
|
-
} ${
|
|
948
|
-
isDragOver ? styles.dragOver : ''
|
|
949
|
-
}`}
|
|
1219
|
+
} ${isDragOver ? styles.dragOver : ''}`}
|
|
950
1220
|
ref={imageContainer}
|
|
951
1221
|
onClick={handleImageClick}
|
|
952
1222
|
onDragOver={handleDragOver}
|
|
@@ -1386,10 +1656,6 @@ const CameraPlacement = () => {
|
|
|
1386
1656
|
rotationHandle.style
|
|
1387
1657
|
) {
|
|
1388
1658
|
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
1389
|
-
} else {
|
|
1390
|
-
console.error(
|
|
1391
|
-
'Rotation handle is null or has no style property'
|
|
1392
|
-
);
|
|
1393
1659
|
}
|
|
1394
1660
|
|
|
1395
1661
|
// Update camera direction
|
|
@@ -1468,56 +1734,51 @@ const CameraPlacement = () => {
|
|
|
1468
1734
|
</div>
|
|
1469
1735
|
)}
|
|
1470
1736
|
</div>
|
|
1471
|
-
</div>
|
|
1472
1737
|
|
|
1473
|
-
|
|
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
|
-
>
|
|
1738
|
+
<div className={styles.sidebar}>
|
|
1487
1739
|
<div className={styles.sidebarHeader}>
|
|
1488
|
-
<
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
</p>
|
|
1493
|
-
</div>
|
|
1494
|
-
|
|
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
|
-
}}
|
|
1740
|
+
<div className={styles.sidebarTabs}>
|
|
1741
|
+
<button
|
|
1742
|
+
className={`${styles.tabButton} ${sidebarTab === 'cameras' ? styles.active : ''}`}
|
|
1743
|
+
onClick={() => setSidebarTab('cameras')}
|
|
1504
1744
|
>
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1745
|
+
Cameras
|
|
1746
|
+
<span className={styles.tabCount}>({cameras.length})</span>
|
|
1747
|
+
</button>
|
|
1748
|
+
<button
|
|
1749
|
+
className={`${styles.tabButton} ${sidebarTab === 'projects' ? styles.active : ''}`}
|
|
1750
|
+
onClick={() => setSidebarTab('projects')}
|
|
1751
|
+
>
|
|
1752
|
+
Projects
|
|
1753
|
+
<span className={styles.tabCount}>({savedProjects.length})</span>
|
|
1754
|
+
</button>
|
|
1755
|
+
</div>
|
|
1756
|
+
{selectedProject && sidebarTab === 'cameras' && (
|
|
1757
|
+
<div className={styles.currentProject}>
|
|
1758
|
+
<span className={styles.projectLabel}>Current:</span>
|
|
1759
|
+
<span className={styles.projectName}>{selectedProject.project_name}</span>
|
|
1519
1760
|
</div>
|
|
1520
|
-
)
|
|
1761
|
+
)}
|
|
1762
|
+
</div>
|
|
1763
|
+
|
|
1764
|
+
<div className={styles.sidebarContent}>
|
|
1765
|
+
{sidebarTab === 'cameras' ? (
|
|
1766
|
+
<div className={styles.camerasList}>
|
|
1767
|
+
{cameras.length === 0 ? (
|
|
1768
|
+
<div className={styles.emptyState}>
|
|
1769
|
+
<div>
|
|
1770
|
+
{selectedProject
|
|
1771
|
+
? `No cameras in "${selectedProject.project_name}"`
|
|
1772
|
+
: "Upload an image first, then click anywhere on it to place cameras"
|
|
1773
|
+
}
|
|
1774
|
+
</div>
|
|
1775
|
+
<div className={styles.emptyStateSubtext}>
|
|
1776
|
+
Once placed, you can drag cameras to reposition
|
|
1777
|
+
them and use the rotation handle to adjust
|
|
1778
|
+
direction
|
|
1779
|
+
</div>
|
|
1780
|
+
</div>
|
|
1781
|
+
) : (
|
|
1521
1782
|
cameras.map((camera) => {
|
|
1522
1783
|
const verkadaCamera = camera.verkadaModel
|
|
1523
1784
|
? getCameraById(camera.verkadaModel)
|
|
@@ -1969,9 +2230,73 @@ const CameraPlacement = () => {
|
|
|
1969
2230
|
</div>
|
|
1970
2231
|
);
|
|
1971
2232
|
})
|
|
2233
|
+
)}
|
|
2234
|
+
</div>
|
|
2235
|
+
) : (
|
|
2236
|
+
<div className={styles.projectsList}>
|
|
2237
|
+
{loadingProjects ? (
|
|
2238
|
+
<div className={styles.loadingState}>
|
|
2239
|
+
<div>Loading projects...</div>
|
|
2240
|
+
</div>
|
|
2241
|
+
) : savedProjects.length === 0 ? (
|
|
2242
|
+
<div className={styles.emptyState}>
|
|
2243
|
+
<div>No saved projects found</div>
|
|
2244
|
+
<div className={styles.emptyStateSubtext}>
|
|
2245
|
+
Create and save your first camera placement project
|
|
2246
|
+
</div>
|
|
2247
|
+
</div>
|
|
2248
|
+
) : (
|
|
2249
|
+
savedProjects.map((project) => (
|
|
2250
|
+
<div
|
|
2251
|
+
key={project.id}
|
|
2252
|
+
className={`${styles.projectItem} ${selectedProject?.id === project.id ? styles.selected : ''}`}
|
|
2253
|
+
onClick={() => loadProject(project)}
|
|
2254
|
+
>
|
|
2255
|
+
<div className={styles.projectHeader}>
|
|
2256
|
+
<h4 className={styles.projectTitle}>
|
|
2257
|
+
{project.project_name}
|
|
2258
|
+
</h4>
|
|
2259
|
+
<div className={styles.projectMeta}>
|
|
2260
|
+
<span className={styles.projectMode}>
|
|
2261
|
+
{project.mode}
|
|
2262
|
+
</span>
|
|
2263
|
+
<span className={styles.projectDate}>
|
|
2264
|
+
{new Date(project.created_at).toLocaleDateString()}
|
|
2265
|
+
</span>
|
|
2266
|
+
</div>
|
|
2267
|
+
</div>
|
|
2268
|
+
<div className={styles.projectInfo}>
|
|
2269
|
+
<span className={styles.cameraCount}>
|
|
2270
|
+
{project.cameras_count || 0} cameras
|
|
2271
|
+
</span>
|
|
2272
|
+
{project.image_data && (
|
|
2273
|
+
<span className={styles.hasImage}>
|
|
2274
|
+
📷 Image
|
|
2275
|
+
</span>
|
|
2276
|
+
)}
|
|
2277
|
+
</div>
|
|
2278
|
+
{project.description && (
|
|
2279
|
+
<div className={styles.projectDescription}>
|
|
2280
|
+
{project.description}
|
|
2281
|
+
</div>
|
|
2282
|
+
)}
|
|
2283
|
+
</div>
|
|
2284
|
+
))
|
|
2285
|
+
)}
|
|
2286
|
+
</div>
|
|
1972
2287
|
)}
|
|
1973
2288
|
</div>
|
|
1974
2289
|
</div>
|
|
2290
|
+
</div>
|
|
2291
|
+
|
|
2292
|
+
{/* Hidden file input */}
|
|
2293
|
+
<input
|
|
2294
|
+
type="file"
|
|
2295
|
+
ref={fileInputRef}
|
|
2296
|
+
onChange={handleImageUpload}
|
|
2297
|
+
accept="image/*"
|
|
2298
|
+
style={{ display: 'none' }}
|
|
2299
|
+
/>
|
|
1975
2300
|
|
|
1976
2301
|
{showModal && selectedCamera && (
|
|
1977
2302
|
<CameraSelectionModal
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
border-bottom: 1px solid rgba(var(--primary-rgb), 0.7);
|
|
19
19
|
z-index: 100;
|
|
20
20
|
position: relative;
|
|
21
|
+
min-width: 1000px;
|
|
21
22
|
|
|
22
23
|
.headerContent {
|
|
23
24
|
display: flex;
|
|
24
25
|
justify-content: space-between;
|
|
25
26
|
align-items: center;
|
|
27
|
+
min-width: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.titleSection {
|
|
31
|
+
flex-shrink: 0;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
.title {
|
|
@@ -32,12 +38,44 @@
|
|
|
32
38
|
margin: 0;
|
|
33
39
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
34
40
|
letter-spacing: -0.5px;
|
|
41
|
+
white-space: nowrap;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
.controls {
|
|
38
45
|
display: flex;
|
|
39
46
|
gap: 12px;
|
|
40
47
|
align-items: center;
|
|
48
|
+
flex-shrink: 0;
|
|
49
|
+
min-width: 480px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.projectInput {
|
|
53
|
+
background: rgba(255, 255, 255, 0.15);
|
|
54
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
55
|
+
border-radius: var(--br, 8px);
|
|
56
|
+
padding: 8px 12px;
|
|
57
|
+
color: white;
|
|
58
|
+
font-size: 14px;
|
|
59
|
+
width: 300px;
|
|
60
|
+
max-width: 300px;
|
|
61
|
+
transition: all 0.2s ease;
|
|
62
|
+
margin-right: 12px;
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
|
|
65
|
+
&::placeholder {
|
|
66
|
+
color: rgba(255, 255, 255, 0.7);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&:focus {
|
|
70
|
+
outline: none;
|
|
71
|
+
background: rgba(255, 255, 255, 0.25);
|
|
72
|
+
border-color: rgba(255, 255, 255, 0.5);
|
|
73
|
+
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
&:hover {
|
|
77
|
+
background: rgba(255, 255, 255, 0.2);
|
|
78
|
+
}
|
|
41
79
|
}
|
|
42
80
|
|
|
43
81
|
.iconButton {
|
|
@@ -46,6 +84,7 @@
|
|
|
46
84
|
justify-content: center;
|
|
47
85
|
width: 44px;
|
|
48
86
|
height: 44px;
|
|
87
|
+
min-width: 44px;
|
|
49
88
|
padding: 0;
|
|
50
89
|
background: var(--secondary-color, #6b7280);
|
|
51
90
|
color: white;
|
|
@@ -56,6 +95,7 @@
|
|
|
56
95
|
border: 1px solid rgba(var(--secondary-rgb, 107, 114, 128), 1);
|
|
57
96
|
position: relative;
|
|
58
97
|
margin: 0 2px;
|
|
98
|
+
flex-shrink: 0;
|
|
59
99
|
|
|
60
100
|
&::after {
|
|
61
101
|
content: attr(title);
|
|
@@ -180,6 +220,7 @@
|
|
|
180
220
|
overflow: hidden;
|
|
181
221
|
gap: 0;
|
|
182
222
|
position: relative;
|
|
223
|
+
height: calc(100vh - 100px);
|
|
183
224
|
}
|
|
184
225
|
|
|
185
226
|
.mapContainer {
|
|
@@ -236,9 +277,6 @@
|
|
|
236
277
|
.imageContainer {
|
|
237
278
|
flex: 1;
|
|
238
279
|
position: relative;
|
|
239
|
-
min-height: calc(100vh - 100px);
|
|
240
|
-
height: calc(100vh - 100px);
|
|
241
|
-
width: calc(100% - 400px);
|
|
242
280
|
background: var(--bg);
|
|
243
281
|
overflow: hidden;
|
|
244
282
|
cursor: default;
|
|
@@ -247,9 +285,6 @@
|
|
|
247
285
|
cursor: crosshair;
|
|
248
286
|
}
|
|
249
287
|
|
|
250
|
-
&.sidebarCollapsed {
|
|
251
|
-
width: 100%;
|
|
252
|
-
}
|
|
253
288
|
|
|
254
289
|
&.dragOver {
|
|
255
290
|
background: rgba(var(--primary-rgb), 0.05);
|
|
@@ -359,23 +394,16 @@
|
|
|
359
394
|
}
|
|
360
395
|
|
|
361
396
|
.sidebar {
|
|
362
|
-
position: absolute;
|
|
363
|
-
top: 70px;
|
|
364
|
-
right: 0;
|
|
365
397
|
width: 400px;
|
|
366
|
-
|
|
398
|
+
min-width: 400px;
|
|
367
399
|
background: var(--item-color);
|
|
368
400
|
border-left: 1px solid var(--border-color, #e5e7eb);
|
|
369
401
|
display: flex;
|
|
370
402
|
flex-direction: column;
|
|
371
403
|
z-index: 20;
|
|
372
404
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
&.collapsed {
|
|
377
|
-
transform: translateX(100%);
|
|
378
|
-
}
|
|
405
|
+
flex-shrink: 0;
|
|
406
|
+
height: 100%;
|
|
379
407
|
|
|
380
408
|
.sidebarHeader {
|
|
381
409
|
padding: 20px 24px;
|
|
@@ -384,6 +412,75 @@
|
|
|
384
412
|
position: relative;
|
|
385
413
|
z-index: 1;
|
|
386
414
|
|
|
415
|
+
.sidebarTabs {
|
|
416
|
+
display: flex;
|
|
417
|
+
gap: 4px;
|
|
418
|
+
margin-bottom: 12px;
|
|
419
|
+
|
|
420
|
+
.tabButton {
|
|
421
|
+
flex: 1;
|
|
422
|
+
padding: 8px 12px;
|
|
423
|
+
background: transparent;
|
|
424
|
+
border: 1px solid var(--border-color, #e5e7eb);
|
|
425
|
+
border-radius: 6px;
|
|
426
|
+
font-size: 14px;
|
|
427
|
+
font-weight: 500;
|
|
428
|
+
color: var(--paragraph-color, #64748b);
|
|
429
|
+
cursor: pointer;
|
|
430
|
+
transition: all 0.2s ease;
|
|
431
|
+
display: flex;
|
|
432
|
+
align-items: center;
|
|
433
|
+
justify-content: center;
|
|
434
|
+
gap: 4px;
|
|
435
|
+
|
|
436
|
+
.tabCount {
|
|
437
|
+
font-size: 12px;
|
|
438
|
+
opacity: 0.7;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
&:hover {
|
|
442
|
+
background: var(--surface-color, #f1f5f9);
|
|
443
|
+
color: var(--heading-color, #1e293b);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
&.active {
|
|
447
|
+
background: var(--primary-color, #3b82f6);
|
|
448
|
+
color: white;
|
|
449
|
+
border-color: var(--primary-color, #3b82f6);
|
|
450
|
+
|
|
451
|
+
.tabCount {
|
|
452
|
+
opacity: 0.8;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.currentProject {
|
|
459
|
+
display: flex;
|
|
460
|
+
align-items: center;
|
|
461
|
+
gap: 8px;
|
|
462
|
+
padding: 8px 12px;
|
|
463
|
+
background: var(--success-color-light, #ecfdf5);
|
|
464
|
+
border: 1px solid var(--success-color, #10b981);
|
|
465
|
+
border-radius: 6px;
|
|
466
|
+
font-size: 12px;
|
|
467
|
+
|
|
468
|
+
.projectLabel {
|
|
469
|
+
color: var(--success-color, #10b981);
|
|
470
|
+
font-weight: 500;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.projectName {
|
|
474
|
+
color: var(--heading-color, #1e293b);
|
|
475
|
+
font-weight: 600;
|
|
476
|
+
flex: 1;
|
|
477
|
+
max-width: 200px;
|
|
478
|
+
overflow: hidden;
|
|
479
|
+
text-overflow: ellipsis;
|
|
480
|
+
white-space: nowrap;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
387
484
|
.sidebarTitle {
|
|
388
485
|
font-size: 18px;
|
|
389
486
|
font-weight: 700;
|
|
@@ -400,6 +497,140 @@
|
|
|
400
497
|
}
|
|
401
498
|
}
|
|
402
499
|
|
|
500
|
+
.sidebarContent {
|
|
501
|
+
flex: 1;
|
|
502
|
+
overflow: hidden;
|
|
503
|
+
display: flex;
|
|
504
|
+
flex-direction: column;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.emptyState {
|
|
508
|
+
text-align: center;
|
|
509
|
+
color: #6c757d;
|
|
510
|
+
padding: 40px 20px;
|
|
511
|
+
font-style: italic;
|
|
512
|
+
|
|
513
|
+
.emptyStateSubtext {
|
|
514
|
+
fontSize: 12px;
|
|
515
|
+
margin-top: 8px;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.loadingState {
|
|
520
|
+
text-align: center;
|
|
521
|
+
color: #6c757d;
|
|
522
|
+
padding: 40px 20px;
|
|
523
|
+
font-style: italic;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.projectsList {
|
|
527
|
+
flex: 1;
|
|
528
|
+
overflow-y: auto;
|
|
529
|
+
padding: 20px 24px;
|
|
530
|
+
|
|
531
|
+
&::-webkit-scrollbar {
|
|
532
|
+
width: 6px;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
&::-webkit-scrollbar-track {
|
|
536
|
+
background: rgba(0, 0, 0, 0.05);
|
|
537
|
+
border-radius: 3px;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
&::-webkit-scrollbar-thumb {
|
|
541
|
+
background: #d1d5db;
|
|
542
|
+
border-radius: 3px;
|
|
543
|
+
|
|
544
|
+
&:hover {
|
|
545
|
+
background: #9ca3af;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.projectItem {
|
|
550
|
+
padding: 16px;
|
|
551
|
+
background: var(--item-color);
|
|
552
|
+
border-radius: var(--br, 8px);
|
|
553
|
+
margin-bottom: 12px;
|
|
554
|
+
border: 1px solid var(--border-color, #e5e7eb);
|
|
555
|
+
transition: all 0.2s ease;
|
|
556
|
+
cursor: pointer;
|
|
557
|
+
position: relative;
|
|
558
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
559
|
+
|
|
560
|
+
&:hover {
|
|
561
|
+
background: var(--surface-color, #f8fafc);
|
|
562
|
+
border-color: var(--primary-color, #3b82f6);
|
|
563
|
+
transform: translateY(-1px);
|
|
564
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
&.selected {
|
|
568
|
+
border-color: var(--primary-color, #3b82f6);
|
|
569
|
+
background: var(--primary-color-light, #eff6ff);
|
|
570
|
+
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.projectHeader {
|
|
574
|
+
margin-bottom: 8px;
|
|
575
|
+
|
|
576
|
+
.projectTitle {
|
|
577
|
+
font-size: 16px;
|
|
578
|
+
font-weight: 600;
|
|
579
|
+
color: var(--heading-color, #1e293b);
|
|
580
|
+
margin: 0 0 4px 0;
|
|
581
|
+
line-height: 1.2;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.projectMeta {
|
|
585
|
+
display: flex;
|
|
586
|
+
gap: 8px;
|
|
587
|
+
align-items: center;
|
|
588
|
+
|
|
589
|
+
.projectMode {
|
|
590
|
+
background: var(--primary-color-light, #eff6ff);
|
|
591
|
+
color: var(--primary-color, #3b82f6);
|
|
592
|
+
padding: 2px 6px;
|
|
593
|
+
border-radius: 4px;
|
|
594
|
+
font-size: 11px;
|
|
595
|
+
font-weight: 500;
|
|
596
|
+
text-transform: uppercase;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.projectDate {
|
|
600
|
+
font-size: 12px;
|
|
601
|
+
color: var(--paragraph-color, #64748b);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.projectInfo {
|
|
607
|
+
display: flex;
|
|
608
|
+
gap: 12px;
|
|
609
|
+
align-items: center;
|
|
610
|
+
margin-bottom: 8px;
|
|
611
|
+
|
|
612
|
+
.cameraCount {
|
|
613
|
+
font-size: 13px;
|
|
614
|
+
color: var(--paragraph-color, #64748b);
|
|
615
|
+
font-weight: 500;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
.hasImage {
|
|
619
|
+
font-size: 12px;
|
|
620
|
+
color: var(--success-color, #10b981);
|
|
621
|
+
font-weight: 500;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.projectDescription {
|
|
626
|
+
font-size: 12px;
|
|
627
|
+
color: var(--paragraph-color, #64748b);
|
|
628
|
+
line-height: 1.4;
|
|
629
|
+
font-style: italic;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
403
634
|
.camerasList {
|
|
404
635
|
flex: 1;
|
|
405
636
|
overflow-y: auto;
|