@visns-studio/visns-components 5.15.8 → 5.15.10
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.
|
@@ -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,22 +31,28 @@ 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);
|
|
47
49
|
const imageContainer = useRef(null);
|
|
48
50
|
const fileInputRef = useRef(null);
|
|
51
|
+
const jsonFileInputRef = useRef(null);
|
|
49
52
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
50
53
|
const [isRotating, setIsRotating] = useState(false);
|
|
51
54
|
|
|
55
|
+
|
|
52
56
|
// Mapbox configuration
|
|
53
57
|
const MAPBOX_ACCESS_TOKEN =
|
|
54
58
|
'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
|
|
@@ -69,7 +73,6 @@ const CameraPlacement = () => {
|
|
|
69
73
|
try {
|
|
70
74
|
map.remove();
|
|
71
75
|
} catch (error) {
|
|
72
|
-
console.warn('Error removing map:', error);
|
|
73
76
|
}
|
|
74
77
|
setMap(null);
|
|
75
78
|
}
|
|
@@ -166,7 +169,6 @@ const CameraPlacement = () => {
|
|
|
166
169
|
try {
|
|
167
170
|
map.remove();
|
|
168
171
|
} catch (error) {
|
|
169
|
-
console.warn('Error removing map:', error);
|
|
170
172
|
}
|
|
171
173
|
setMap(null);
|
|
172
174
|
}
|
|
@@ -185,6 +187,282 @@ const CameraPlacement = () => {
|
|
|
185
187
|
[map]
|
|
186
188
|
);
|
|
187
189
|
|
|
190
|
+
// Handle legacy JSON file upload
|
|
191
|
+
const handleJsonFileUpload = useCallback(async (event) => {
|
|
192
|
+
const file = event.target.files[0];
|
|
193
|
+
if (!file) return;
|
|
194
|
+
|
|
195
|
+
if (!file.name.toLowerCase().endsWith('.json')) {
|
|
196
|
+
Swal.fire({
|
|
197
|
+
icon: 'error',
|
|
198
|
+
title: 'Invalid File Type',
|
|
199
|
+
text: 'Please select a JSON file (.json extension)',
|
|
200
|
+
confirmButtonColor: '#ef4444',
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const text = await file.text();
|
|
207
|
+
const jsonData = JSON.parse(text);
|
|
208
|
+
|
|
209
|
+
// Show loading indicator
|
|
210
|
+
Swal.fire({
|
|
211
|
+
title: 'Processing Legacy Data...',
|
|
212
|
+
text: 'Converting and validating JSON data',
|
|
213
|
+
allowOutsideClick: false,
|
|
214
|
+
showConfirmButton: false,
|
|
215
|
+
didOpen: () => {
|
|
216
|
+
Swal.showLoading();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Process and validate the JSON data
|
|
221
|
+
const result = await processLegacyJsonData(jsonData);
|
|
222
|
+
|
|
223
|
+
Swal.close();
|
|
224
|
+
|
|
225
|
+
if (result.success) {
|
|
226
|
+
// Load the processed data
|
|
227
|
+
setProjectName(result.data.projectName);
|
|
228
|
+
setMode(result.data.mode);
|
|
229
|
+
setCameras(result.data.cameras);
|
|
230
|
+
|
|
231
|
+
if (result.data.uploadedImage) {
|
|
232
|
+
setUploadedImage(result.data.uploadedImage);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Clear the selected project since this is new legacy data
|
|
236
|
+
setSelectedProject(null);
|
|
237
|
+
setSidebarTab('cameras');
|
|
238
|
+
|
|
239
|
+
Swal.fire({
|
|
240
|
+
icon: 'success',
|
|
241
|
+
title: 'Legacy Data Loaded!',
|
|
242
|
+
html: `
|
|
243
|
+
<div style="text-align: left;">
|
|
244
|
+
<p><strong>Successfully imported:</strong></p>
|
|
245
|
+
<ul style="margin: 10px 0; padding-left: 20px;">
|
|
246
|
+
<li>Project: "${result.data.projectName}"</li>
|
|
247
|
+
<li>Cameras: ${result.data.cameras.length}</li>
|
|
248
|
+
<li>Mode: ${result.data.mode}</li>
|
|
249
|
+
${result.data.uploadedImage ? '<li>Image: Yes</li>' : '<li>Image: No</li>'}
|
|
250
|
+
</ul>
|
|
251
|
+
<p style="color: #6b7280; font-size: 14px; margin-top: 15px;">
|
|
252
|
+
<strong>Next steps:</strong> Review the imported data and save it to the database using the Save button.
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
`,
|
|
256
|
+
confirmButtonColor: '#3b82f6',
|
|
257
|
+
confirmButtonText: 'Continue',
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
throw new Error(result.error);
|
|
261
|
+
}
|
|
262
|
+
} catch (error) {
|
|
263
|
+
Swal.fire({
|
|
264
|
+
icon: 'error',
|
|
265
|
+
title: 'Import Failed',
|
|
266
|
+
html: `
|
|
267
|
+
<div style="text-align: left;">
|
|
268
|
+
<p>Failed to import legacy JSON file:</p>
|
|
269
|
+
<div style="background: #f3f4f6; padding: 10px; border-radius: 4px; margin: 10px 0; font-family: monospace; font-size: 12px; max-height: 150px; overflow-y: auto;">
|
|
270
|
+
${error.message}
|
|
271
|
+
</div>
|
|
272
|
+
<p style="color: #6b7280; font-size: 14px;">
|
|
273
|
+
Please check that the JSON file contains valid camera placement data.
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
`,
|
|
277
|
+
confirmButtonColor: '#ef4444',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Clear the file input
|
|
282
|
+
event.target.value = '';
|
|
283
|
+
}, []);
|
|
284
|
+
|
|
285
|
+
// Process legacy JSON data and convert to current format
|
|
286
|
+
const processLegacyJsonData = useCallback(async (jsonData) => {
|
|
287
|
+
try {
|
|
288
|
+
// Validate basic structure
|
|
289
|
+
if (!jsonData || typeof jsonData !== 'object') {
|
|
290
|
+
throw new Error('Invalid JSON structure - expected an object');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let cameras = [];
|
|
294
|
+
let projectName = '';
|
|
295
|
+
let mode = 'image';
|
|
296
|
+
let uploadedImage = null;
|
|
297
|
+
|
|
298
|
+
// Handle different legacy JSON formats
|
|
299
|
+
|
|
300
|
+
// Format 1: Direct camera placement export with cameras array
|
|
301
|
+
if (jsonData.cameras && Array.isArray(jsonData.cameras)) {
|
|
302
|
+
cameras = jsonData.cameras.map((camera, index) => {
|
|
303
|
+
// Ensure required fields exist with fallbacks
|
|
304
|
+
const id = camera.id || Date.now() + index;
|
|
305
|
+
const name = camera.name || camera.customName || `Camera ${index + 1}`;
|
|
306
|
+
const x = typeof camera.x === 'number' ? camera.x : (camera.position?.x || 0);
|
|
307
|
+
const y = typeof camera.y === 'number' ? camera.y : (camera.position?.y || 0);
|
|
308
|
+
const direction = typeof camera.direction === 'number' ? camera.direction : (camera.rotation || 0);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
id: id,
|
|
312
|
+
name: name,
|
|
313
|
+
customName: camera.customName || '',
|
|
314
|
+
x: x,
|
|
315
|
+
y: y,
|
|
316
|
+
direction: direction,
|
|
317
|
+
verkadaModel: camera.verkadaModel || camera.model || null,
|
|
318
|
+
timestamp: camera.timestamp || new Date().toISOString(),
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
projectName = jsonData.projectName || jsonData.name || jsonData.title || 'Imported Legacy Project';
|
|
323
|
+
mode = jsonData.mode || 'image';
|
|
324
|
+
|
|
325
|
+
// Handle different image data formats - extract src string directly
|
|
326
|
+
if (jsonData.image && jsonData.image.src) {
|
|
327
|
+
// Format: { "image": { "src": "data:image/png;base64,..." } }
|
|
328
|
+
uploadedImage = jsonData.image.src;
|
|
329
|
+
} else if (jsonData.uploadedImage && jsonData.uploadedImage.src) {
|
|
330
|
+
// Format: { "uploadedImage": { "src": "...", "name": "..." } }
|
|
331
|
+
uploadedImage = jsonData.uploadedImage.src;
|
|
332
|
+
} else if (jsonData.uploadedImage && typeof jsonData.uploadedImage === 'string') {
|
|
333
|
+
// Format: { "uploadedImage": "data:image/..." }
|
|
334
|
+
uploadedImage = jsonData.uploadedImage;
|
|
335
|
+
} else if (jsonData.backgroundImage) {
|
|
336
|
+
// Format: { "backgroundImage": "data:image/..." }
|
|
337
|
+
uploadedImage = jsonData.backgroundImage;
|
|
338
|
+
} else {
|
|
339
|
+
uploadedImage = null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Format 2: Legacy format with different structure
|
|
344
|
+
else if (jsonData.placement || jsonData.cameraPlacement) {
|
|
345
|
+
const placement = jsonData.placement || jsonData.cameraPlacement;
|
|
346
|
+
|
|
347
|
+
if (placement.cameras && Array.isArray(placement.cameras)) {
|
|
348
|
+
cameras = placement.cameras.map((camera, index) => ({
|
|
349
|
+
id: camera.id || Date.now() + index,
|
|
350
|
+
name: camera.name || `Camera ${index + 1}`,
|
|
351
|
+
customName: camera.customName || '',
|
|
352
|
+
x: camera.x || 0,
|
|
353
|
+
y: camera.y || 0,
|
|
354
|
+
direction: camera.direction || 0,
|
|
355
|
+
verkadaModel: camera.verkadaModel || null,
|
|
356
|
+
timestamp: new Date().toISOString(),
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
projectName = placement.projectName || placement.name || 'Imported Legacy Project';
|
|
361
|
+
mode = placement.mode || 'image';
|
|
362
|
+
|
|
363
|
+
// Handle different image data formats - extract src string directly
|
|
364
|
+
if (placement.image && placement.image.src) {
|
|
365
|
+
uploadedImage = placement.image.src;
|
|
366
|
+
} else if (placement.image && typeof placement.image === 'string') {
|
|
367
|
+
uploadedImage = placement.image;
|
|
368
|
+
} else if (placement.backgroundImage) {
|
|
369
|
+
uploadedImage = placement.backgroundImage;
|
|
370
|
+
} else {
|
|
371
|
+
uploadedImage = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Format 3: Simple array of cameras
|
|
376
|
+
else if (Array.isArray(jsonData)) {
|
|
377
|
+
cameras = jsonData.map((camera, index) => ({
|
|
378
|
+
id: camera.id || Date.now() + index,
|
|
379
|
+
name: camera.name || `Camera ${index + 1}`,
|
|
380
|
+
customName: camera.customName || '',
|
|
381
|
+
x: camera.x || 0,
|
|
382
|
+
y: camera.y || 0,
|
|
383
|
+
direction: camera.direction || 0,
|
|
384
|
+
verkadaModel: camera.verkadaModel || null,
|
|
385
|
+
timestamp: new Date().toISOString(),
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
projectName = 'Imported Legacy Project';
|
|
389
|
+
mode = 'image';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Format 4: Check for other common legacy formats
|
|
393
|
+
else {
|
|
394
|
+
// Look for any array that might contain camera data
|
|
395
|
+
const possibleCameraArrays = Object.values(jsonData).filter(value =>
|
|
396
|
+
Array.isArray(value) && value.length > 0 &&
|
|
397
|
+
value[0] && typeof value[0] === 'object'
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (possibleCameraArrays.length > 0) {
|
|
401
|
+
const cameraArray = possibleCameraArrays[0];
|
|
402
|
+
cameras = cameraArray.map((camera, index) => ({
|
|
403
|
+
id: camera.id || Date.now() + index,
|
|
404
|
+
name: camera.name || `Camera ${index + 1}`,
|
|
405
|
+
customName: camera.customName || '',
|
|
406
|
+
x: camera.x || 0,
|
|
407
|
+
y: camera.y || 0,
|
|
408
|
+
direction: camera.direction || 0,
|
|
409
|
+
verkadaModel: camera.verkadaModel || null,
|
|
410
|
+
timestamp: new Date().toISOString(),
|
|
411
|
+
}));
|
|
412
|
+
|
|
413
|
+
projectName = jsonData.name || jsonData.title || 'Imported Legacy Project';
|
|
414
|
+
mode = jsonData.mode || 'image';
|
|
415
|
+
|
|
416
|
+
// Handle different image data formats - extract src string directly
|
|
417
|
+
if (jsonData.image && jsonData.image.src) {
|
|
418
|
+
uploadedImage = jsonData.image.src;
|
|
419
|
+
} else if (jsonData.image && typeof jsonData.image === 'string') {
|
|
420
|
+
uploadedImage = jsonData.image;
|
|
421
|
+
} else if (jsonData.backgroundImage) {
|
|
422
|
+
uploadedImage = jsonData.backgroundImage;
|
|
423
|
+
} else {
|
|
424
|
+
uploadedImage = null;
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
throw new Error('No valid camera data found in JSON file. Expected arrays of camera objects.');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Validate that we have at least some cameras
|
|
432
|
+
if (!cameras || cameras.length === 0) {
|
|
433
|
+
throw new Error('No cameras found in the JSON file');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Validate camera data
|
|
437
|
+
const invalidCameras = cameras.filter(camera =>
|
|
438
|
+
typeof camera.x !== 'number' ||
|
|
439
|
+
typeof camera.y !== 'number' ||
|
|
440
|
+
isNaN(camera.x) ||
|
|
441
|
+
isNaN(camera.y)
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
if (invalidCameras.length > 0) {
|
|
445
|
+
throw new Error(`${invalidCameras.length} cameras have invalid position data (x, y coordinates must be numbers)`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
success: true,
|
|
450
|
+
data: {
|
|
451
|
+
projectName: projectName,
|
|
452
|
+
mode: mode,
|
|
453
|
+
cameras: cameras,
|
|
454
|
+
uploadedImage: uploadedImage
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
} catch (error) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
error: error.message
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}, []);
|
|
465
|
+
|
|
188
466
|
// Initialize Mapbox GL map
|
|
189
467
|
useEffect(() => {
|
|
190
468
|
if (!mapContainer.current || mode !== 'map') return;
|
|
@@ -335,8 +613,43 @@ const CameraPlacement = () => {
|
|
|
335
613
|
[map, cameras]
|
|
336
614
|
);
|
|
337
615
|
|
|
338
|
-
//
|
|
339
|
-
const
|
|
616
|
+
// Fetch saved projects (lightweight - no heavy JSON fields)
|
|
617
|
+
const fetchSavedProjects = useCallback(async () => {
|
|
618
|
+
if (!userProfile?.id) return;
|
|
619
|
+
|
|
620
|
+
setLoadingProjects(true);
|
|
621
|
+
try {
|
|
622
|
+
const response = await fetch('/ajax/cameraPlacement/table', {
|
|
623
|
+
method: 'POST',
|
|
624
|
+
headers: {
|
|
625
|
+
'Content-Type': 'application/json',
|
|
626
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
627
|
+
},
|
|
628
|
+
body: JSON.stringify({
|
|
629
|
+
page: 1,
|
|
630
|
+
per_page: 100,
|
|
631
|
+
order_by: 'created_at',
|
|
632
|
+
order: 'desc'
|
|
633
|
+
}),
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const responseText = await response.text();
|
|
637
|
+
const result = JSON.parse(responseText);
|
|
638
|
+
|
|
639
|
+
if (result.data && Array.isArray(result.data)) {
|
|
640
|
+
setSavedProjects(result.data);
|
|
641
|
+
} else if (result.data && Array.isArray(result.data.data)) {
|
|
642
|
+
setSavedProjects(result.data.data);
|
|
643
|
+
}
|
|
644
|
+
} catch (error) {
|
|
645
|
+
} finally {
|
|
646
|
+
setLoadingProjects(false);
|
|
647
|
+
}
|
|
648
|
+
}, [userProfile]);
|
|
649
|
+
|
|
650
|
+
// Save placement to database
|
|
651
|
+
const saveToDatabase = useCallback(async () => {
|
|
652
|
+
|
|
340
653
|
if (!cameras.length && !uploadedImage) {
|
|
341
654
|
Swal.fire({
|
|
342
655
|
icon: 'warning',
|
|
@@ -347,115 +660,367 @@ const CameraPlacement = () => {
|
|
|
347
660
|
return;
|
|
348
661
|
}
|
|
349
662
|
|
|
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
|
-
};
|
|
663
|
+
// Enhanced camera data validation
|
|
664
|
+
if (cameras.length === 0) {
|
|
665
|
+
Swal.fire({
|
|
666
|
+
icon: 'warning',
|
|
667
|
+
title: 'No Cameras to Save',
|
|
668
|
+
text: 'Please place at least one camera before saving. Click on the image to add cameras.',
|
|
669
|
+
confirmButtonColor: '#f59e0b',
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
371
673
|
|
|
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',
|
|
674
|
+
// Validate each camera has required data
|
|
675
|
+
const invalidCameras = cameras.filter(camera => {
|
|
676
|
+
const issues = [];
|
|
677
|
+
|
|
678
|
+
if (!camera.id) issues.push('missing ID');
|
|
679
|
+
if (typeof camera.x !== 'number' || isNaN(camera.x)) issues.push('invalid X coordinate');
|
|
680
|
+
if (typeof camera.y !== 'number' || isNaN(camera.y)) issues.push('invalid Y coordinate');
|
|
681
|
+
if (!camera.name || camera.name.trim() === '') issues.push('missing name');
|
|
682
|
+
if (typeof camera.direction !== 'number' || isNaN(camera.direction)) issues.push('invalid direction');
|
|
683
|
+
|
|
684
|
+
return issues.length > 0;
|
|
395
685
|
});
|
|
396
|
-
}, [cameras, uploadedImage, mode]);
|
|
397
686
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
687
|
+
if (invalidCameras.length > 0) {
|
|
688
|
+
Swal.fire({
|
|
689
|
+
icon: 'error',
|
|
690
|
+
title: 'Invalid Camera Data',
|
|
691
|
+
text: `${invalidCameras.length} camera(s) have invalid data. Please check all cameras have proper positioning, names, and direction values.`,
|
|
692
|
+
confirmButtonColor: '#ef4444',
|
|
693
|
+
});
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
403
696
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
697
|
+
// Validate cameras_data array will not be empty
|
|
698
|
+
const validCamerasData = cameras.map((camera) => ({
|
|
699
|
+
id: camera.id,
|
|
700
|
+
name: camera.name,
|
|
701
|
+
customName: camera.customName,
|
|
702
|
+
x: camera.x,
|
|
703
|
+
y: camera.y,
|
|
704
|
+
direction: camera.direction,
|
|
705
|
+
verkadaModel: camera.verkadaModel,
|
|
706
|
+
timestamp: camera.timestamp,
|
|
707
|
+
})).filter(camera => camera.id && camera.name);
|
|
708
|
+
|
|
709
|
+
if (validCamerasData.length === 0) {
|
|
710
|
+
Swal.fire({
|
|
711
|
+
icon: 'error',
|
|
712
|
+
title: 'No Valid Camera Data',
|
|
713
|
+
text: 'All cameras seem to have missing or invalid data. Please recreate your cameras.',
|
|
714
|
+
confirmButtonColor: '#ef4444',
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
407
718
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
719
|
+
if (!projectName.trim()) {
|
|
720
|
+
Swal.fire({
|
|
721
|
+
icon: 'warning',
|
|
722
|
+
title: 'Project Name Required',
|
|
723
|
+
text: 'Please enter a project name before saving.',
|
|
724
|
+
confirmButtonColor: '#8b5cf6',
|
|
725
|
+
});
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
412
728
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
729
|
+
// Validate user profile
|
|
730
|
+
if (!userProfile || !userProfile.id) {
|
|
731
|
+
Swal.fire({
|
|
732
|
+
icon: 'error',
|
|
733
|
+
title: 'Authentication Error',
|
|
734
|
+
text: 'User profile is required to save camera placements.',
|
|
735
|
+
confirmButtonColor: '#ef4444',
|
|
736
|
+
});
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
423
739
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
740
|
+
// Validate mode
|
|
741
|
+
if (!mode || !['image', 'map'].includes(mode)) {
|
|
742
|
+
Swal.fire({
|
|
743
|
+
icon: 'error',
|
|
744
|
+
title: 'Invalid Mode',
|
|
745
|
+
text: 'Invalid placement mode. Please refresh the page and try again.',
|
|
746
|
+
confirmButtonColor: '#ef4444',
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
428
750
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
751
|
+
try {
|
|
752
|
+
// Check if we're updating an existing project or creating a new one
|
|
753
|
+
const isUpdating = selectedProject && selectedProject.id;
|
|
754
|
+
const method = isUpdating ? 'PUT' : 'POST';
|
|
755
|
+
const url = isUpdating ? `/ajax/cameraPlacement/${selectedProject.id}` : '/ajax/cameraPlacement';
|
|
756
|
+
|
|
757
|
+
const saveData = {
|
|
758
|
+
name: projectName.trim(),
|
|
759
|
+
project_name: projectName.trim(),
|
|
760
|
+
version: '1.0',
|
|
761
|
+
mode: mode,
|
|
762
|
+
user_id: userProfile.id,
|
|
763
|
+
image_data: uploadedImage
|
|
764
|
+
? {
|
|
765
|
+
src: uploadedImage,
|
|
766
|
+
name: 'uploaded-image',
|
|
767
|
+
}
|
|
768
|
+
: null,
|
|
769
|
+
image_name: uploadedImage ? 'uploaded-image' : null,
|
|
770
|
+
cameras_data: validCamerasData,
|
|
771
|
+
};
|
|
433
772
|
|
|
434
|
-
|
|
435
|
-
|
|
773
|
+
const response = await fetch(url, {
|
|
774
|
+
method: method,
|
|
775
|
+
headers: {
|
|
776
|
+
'Content-Type': 'application/json',
|
|
777
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
778
|
+
},
|
|
779
|
+
body: JSON.stringify(saveData),
|
|
780
|
+
});
|
|
436
781
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
confirmButtonColor: '#f59e0b',
|
|
452
|
-
});
|
|
782
|
+
const responseText = await response.text();
|
|
783
|
+
|
|
784
|
+
let result;
|
|
785
|
+
try {
|
|
786
|
+
result = JSON.parse(responseText);
|
|
787
|
+
} catch (parseError) {
|
|
788
|
+
throw new Error(`Server returned invalid JSON. Status: ${response.status}, Response: ${responseText.substring(0, 200)}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// DynamicController returns { data: {...}, error: "" } format
|
|
792
|
+
if (result.data && !result.error) {
|
|
793
|
+
// Update the selectedProject with the latest data if we were updating
|
|
794
|
+
if (isUpdating) {
|
|
795
|
+
setSelectedProject(result.data);
|
|
453
796
|
}
|
|
454
|
-
};
|
|
455
|
-
reader.readAsText(file);
|
|
456
|
-
};
|
|
457
797
|
|
|
458
|
-
|
|
798
|
+
Swal.fire({
|
|
799
|
+
icon: 'success',
|
|
800
|
+
title: isUpdating ? 'Updated!' : 'Saved!',
|
|
801
|
+
text: `Camera placement "${projectName}" ${isUpdating ? 'updated' : 'saved'} successfully`,
|
|
802
|
+
timer: 3000,
|
|
803
|
+
showConfirmButton: false,
|
|
804
|
+
toast: true,
|
|
805
|
+
position: 'top-end',
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// Refresh the projects list to show updated data
|
|
809
|
+
fetchSavedProjects();
|
|
810
|
+
} else {
|
|
811
|
+
const errorMessage = result.error || result.message || 'Save failed';
|
|
812
|
+
const errorDetails = result.errors ? JSON.stringify(result.errors) : '';
|
|
813
|
+
throw new Error(`${errorMessage}${errorDetails ? ` - Details: ${errorDetails}` : ''}`);
|
|
814
|
+
}
|
|
815
|
+
} catch (error) {
|
|
816
|
+
Swal.fire({
|
|
817
|
+
icon: 'error',
|
|
818
|
+
title: 'Save Failed',
|
|
819
|
+
text: error.message || 'Failed to save camera placement. Please try again.',
|
|
820
|
+
confirmButtonColor: '#ef4444',
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}, [cameras, uploadedImage, mode, projectName, userProfile, selectedProject, fetchSavedProjects]);
|
|
824
|
+
|
|
825
|
+
// Fetch full project data with heavy JSON fields
|
|
826
|
+
const fetchFullProject = useCallback(async (projectId) => {
|
|
827
|
+
try {
|
|
828
|
+
const response = await fetch(`/ajax/cameraPlacement/${projectId}`, {
|
|
829
|
+
headers: {
|
|
830
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const responseText = await response.text();
|
|
835
|
+
const result = JSON.parse(responseText);
|
|
836
|
+
|
|
837
|
+
if (result.id) {
|
|
838
|
+
// Direct object response (not wrapped in data)
|
|
839
|
+
return result;
|
|
840
|
+
} else if (result.data) {
|
|
841
|
+
// Wrapped in data object
|
|
842
|
+
return result.data;
|
|
843
|
+
} else {
|
|
844
|
+
throw new Error('Failed to fetch full project data');
|
|
845
|
+
}
|
|
846
|
+
} catch (error) {
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}, []);
|
|
850
|
+
|
|
851
|
+
// Load a specific project
|
|
852
|
+
const loadProject = useCallback(async (lightweightProject) => {
|
|
853
|
+
try {
|
|
854
|
+
// Show loading indicator
|
|
855
|
+
Swal.fire({
|
|
856
|
+
title: 'Loading Project...',
|
|
857
|
+
text: 'Fetching project data',
|
|
858
|
+
allowOutsideClick: false,
|
|
859
|
+
showConfirmButton: false,
|
|
860
|
+
didOpen: () => {
|
|
861
|
+
Swal.showLoading();
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// Fetch full project data including heavy JSON fields
|
|
866
|
+
const fullProject = await fetchFullProject(lightweightProject.id);
|
|
867
|
+
|
|
868
|
+
// Close loading indicator
|
|
869
|
+
Swal.close();
|
|
870
|
+
|
|
871
|
+
setSelectedProject(fullProject);
|
|
872
|
+
setProjectName(fullProject.project_name);
|
|
873
|
+
setMode(fullProject.mode);
|
|
874
|
+
|
|
875
|
+
if (fullProject.image_data && fullProject.image_data.src) {
|
|
876
|
+
setUploadedImage(fullProject.image_data.src);
|
|
877
|
+
} else {
|
|
878
|
+
setUploadedImage(null);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
setCameras(fullProject.cameras_data || []);
|
|
882
|
+
setSidebarTab('cameras'); // Switch to cameras tab
|
|
883
|
+
|
|
884
|
+
Swal.fire({
|
|
885
|
+
icon: 'success',
|
|
886
|
+
title: 'Project Loaded!',
|
|
887
|
+
text: `Loaded "${fullProject.project_name}" with ${fullProject.cameras_data?.length || 0} cameras`,
|
|
888
|
+
timer: 2000,
|
|
889
|
+
showConfirmButton: false,
|
|
890
|
+
toast: true,
|
|
891
|
+
position: 'top-end',
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
Swal.fire({
|
|
895
|
+
icon: 'error',
|
|
896
|
+
title: 'Load Failed',
|
|
897
|
+
text: 'Failed to load project. Please try again.',
|
|
898
|
+
confirmButtonColor: '#ef4444',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}, [fetchFullProject]);
|
|
902
|
+
|
|
903
|
+
// Fetch projects when user profile changes or sidebar switches to projects
|
|
904
|
+
useEffect(() => {
|
|
905
|
+
if (sidebarTab === 'projects' && userProfile?.id) {
|
|
906
|
+
fetchSavedProjects();
|
|
907
|
+
}
|
|
908
|
+
}, [sidebarTab, userProfile, fetchSavedProjects]);
|
|
909
|
+
|
|
910
|
+
// Initial fetch of projects when component mounts to get correct count
|
|
911
|
+
useEffect(() => {
|
|
912
|
+
if (userProfile?.id) {
|
|
913
|
+
fetchSavedProjects();
|
|
914
|
+
}
|
|
915
|
+
}, [userProfile, fetchSavedProjects]);
|
|
916
|
+
|
|
917
|
+
// Load placement from database (legacy - keeping for compatibility)
|
|
918
|
+
const loadFromDatabase = useCallback(async () => {
|
|
919
|
+
try {
|
|
920
|
+
const response = await fetch('/ajax/cameraPlacement/table', {
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: {
|
|
923
|
+
'Content-Type': 'application/json',
|
|
924
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
925
|
+
},
|
|
926
|
+
body: JSON.stringify({
|
|
927
|
+
page: 1,
|
|
928
|
+
per_page: 100,
|
|
929
|
+
order_by: 'created_at',
|
|
930
|
+
order: 'desc'
|
|
931
|
+
}),
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
const result = await response.json();
|
|
935
|
+
|
|
936
|
+
if (result.data && result.data.length > 0) {
|
|
937
|
+
const placements = result.data;
|
|
938
|
+
|
|
939
|
+
// Show placement selection modal
|
|
940
|
+
const { value: selectedPlacement } = await Swal.fire({
|
|
941
|
+
title: 'Load Camera Placement',
|
|
942
|
+
html: `
|
|
943
|
+
<div style="text-align: left; max-height: 400px; overflow-y: auto;">
|
|
944
|
+
${placements.map(placement => `
|
|
945
|
+
<div style="padding: 12px; margin: 8px 0; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;"
|
|
946
|
+
onmouseover="this.style.backgroundColor='#f9fafb'"
|
|
947
|
+
onmouseout="this.style.backgroundColor='white'"
|
|
948
|
+
onclick="this.classList.toggle('selected'); document.querySelectorAll('.placement-item').forEach(el => el !== this && el.classList.remove('selected'));"
|
|
949
|
+
class="placement-item"
|
|
950
|
+
data-placement-id="${placement.id}">
|
|
951
|
+
<div style="font-weight: 600; color: #1f2937;">${placement.project_name}</div>
|
|
952
|
+
<div style="font-size: 14px; color: #6b7280; margin-top: 4px;">
|
|
953
|
+
${placement.cameras_count} cameras • ${placement.mode} mode
|
|
954
|
+
</div>
|
|
955
|
+
<div style="font-size: 12px; color: #9ca3af; margin-top: 4px;">
|
|
956
|
+
Created: ${new Date(placement.created_at).toLocaleDateString()}
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
`).join('')}
|
|
960
|
+
</div>
|
|
961
|
+
<style>
|
|
962
|
+
.placement-item.selected {
|
|
963
|
+
background-color: #dbeafe !important;
|
|
964
|
+
border-color: #3b82f6 !important;
|
|
965
|
+
}
|
|
966
|
+
</style>
|
|
967
|
+
`,
|
|
968
|
+
showCancelButton: true,
|
|
969
|
+
confirmButtonText: 'Load Selected',
|
|
970
|
+
cancelButtonText: 'Cancel',
|
|
971
|
+
confirmButtonColor: '#3b82f6',
|
|
972
|
+
preConfirm: () => {
|
|
973
|
+
const selectedElement = document.querySelector('.placement-item.selected');
|
|
974
|
+
if (!selectedElement) {
|
|
975
|
+
Swal.showValidationMessage('Please select a placement to load');
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
return selectedElement.getAttribute('data-placement-id');
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
if (selectedPlacement) {
|
|
983
|
+
const placement = placements.find(p => p.id == selectedPlacement);
|
|
984
|
+
if (placement) {
|
|
985
|
+
// Load the placement data
|
|
986
|
+
setProjectName(placement.project_name);
|
|
987
|
+
setMode(placement.mode);
|
|
988
|
+
|
|
989
|
+
if (placement.image_data && placement.image_data.src) {
|
|
990
|
+
setUploadedImage(placement.image_data.src);
|
|
991
|
+
} else {
|
|
992
|
+
setUploadedImage(null);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
setCameras(placement.cameras_data || []);
|
|
996
|
+
|
|
997
|
+
Swal.fire({
|
|
998
|
+
icon: 'success',
|
|
999
|
+
title: 'Loaded!',
|
|
1000
|
+
text: `Successfully loaded "${placement.project_name}" with ${placement.cameras_count} cameras`,
|
|
1001
|
+
timer: 3000,
|
|
1002
|
+
showConfirmButton: false,
|
|
1003
|
+
toast: true,
|
|
1004
|
+
position: 'top-end',
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
Swal.fire({
|
|
1010
|
+
icon: 'info',
|
|
1011
|
+
title: 'No Saved Placements',
|
|
1012
|
+
text: 'You have no saved camera placements yet.',
|
|
1013
|
+
confirmButtonColor: '#3b82f6',
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
Swal.fire({
|
|
1018
|
+
icon: 'error',
|
|
1019
|
+
title: 'Load Failed',
|
|
1020
|
+
text: 'Failed to load camera placements. Please try again.',
|
|
1021
|
+
confirmButtonColor: '#ef4444',
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
459
1024
|
}, []);
|
|
460
1025
|
|
|
461
1026
|
const exportToPDF = useCallback(async () => {
|
|
@@ -501,7 +1066,6 @@ const CameraPlacement = () => {
|
|
|
501
1066
|
});
|
|
502
1067
|
mapImageData = canvas.toDataURL('image/png', 0.9);
|
|
503
1068
|
} catch (error) {
|
|
504
|
-
console.warn('Could not capture site map image:', error);
|
|
505
1069
|
// Continue without image if capture fails
|
|
506
1070
|
}
|
|
507
1071
|
}
|
|
@@ -520,7 +1084,6 @@ const CameraPlacement = () => {
|
|
|
520
1084
|
})
|
|
521
1085
|
).toBlob();
|
|
522
1086
|
} catch (pdfError) {
|
|
523
|
-
console.warn('Enhanced PDF failed, trying simple version:', pdfError);
|
|
524
1087
|
|
|
525
1088
|
// Fallback to simple PDF
|
|
526
1089
|
pdfBlob = await pdf(
|
|
@@ -586,7 +1149,6 @@ const CameraPlacement = () => {
|
|
|
586
1149
|
confirmButtonText: 'Great!',
|
|
587
1150
|
});
|
|
588
1151
|
} catch (error) {
|
|
589
|
-
console.error('Error generating PDF:', error);
|
|
590
1152
|
|
|
591
1153
|
// More specific error handling
|
|
592
1154
|
let errorMessage = 'Unable to generate PDF report. Please try again.';
|
|
@@ -631,10 +1193,6 @@ const CameraPlacement = () => {
|
|
|
631
1193
|
);
|
|
632
1194
|
}
|
|
633
1195
|
} catch (error) {
|
|
634
|
-
console.warn(
|
|
635
|
-
'Could not access map style, skipping FOV layer cleanup:',
|
|
636
|
-
error
|
|
637
|
-
);
|
|
638
1196
|
}
|
|
639
1197
|
existingSources.forEach((sourceId) => {
|
|
640
1198
|
try {
|
|
@@ -646,10 +1204,6 @@ const CameraPlacement = () => {
|
|
|
646
1204
|
map.removeSource(sourceId);
|
|
647
1205
|
}
|
|
648
1206
|
} catch (error) {
|
|
649
|
-
console.warn(
|
|
650
|
-
`Could not remove layer/source ${sourceId}:`,
|
|
651
|
-
error
|
|
652
|
-
);
|
|
653
1207
|
}
|
|
654
1208
|
});
|
|
655
1209
|
|
|
@@ -899,35 +1453,36 @@ const CameraPlacement = () => {
|
|
|
899
1453
|
<div className={styles.cameraPlacement}>
|
|
900
1454
|
<div className={styles.header}>
|
|
901
1455
|
<div className={styles.headerContent}>
|
|
902
|
-
<
|
|
1456
|
+
<div className={styles.titleSection}>
|
|
1457
|
+
<h1 className={styles.title}>Site Map Planner</h1>
|
|
1458
|
+
</div>
|
|
903
1459
|
<div className={styles.controls}>
|
|
1460
|
+
<input
|
|
1461
|
+
type="text"
|
|
1462
|
+
placeholder="Project name..."
|
|
1463
|
+
value={projectName}
|
|
1464
|
+
onChange={(e) => setProjectName(e.target.value)}
|
|
1465
|
+
className={styles.projectInput}
|
|
1466
|
+
maxLength={100}
|
|
1467
|
+
/>
|
|
904
1468
|
<IconButton
|
|
905
1469
|
icon={Upload}
|
|
906
1470
|
onClick={() => fileInputRef.current?.click()}
|
|
907
1471
|
tooltip="Upload Image"
|
|
908
1472
|
className={styles.uploadBtn}
|
|
909
1473
|
/>
|
|
910
|
-
<IconButton
|
|
911
|
-
icon={Save}
|
|
912
|
-
onClick={saveAsFile}
|
|
913
|
-
disabled={!cameras.length && !uploadedImage}
|
|
914
|
-
tooltip="Save camera placement as JSON file"
|
|
915
|
-
className={styles.saveFileBtn}
|
|
916
|
-
/>
|
|
917
1474
|
<IconButton
|
|
918
1475
|
icon={FolderOpen}
|
|
919
|
-
onClick={
|
|
920
|
-
tooltip="
|
|
921
|
-
className={styles.
|
|
1476
|
+
onClick={() => jsonFileInputRef.current?.click()}
|
|
1477
|
+
tooltip="Import Legacy JSON File"
|
|
1478
|
+
className={styles.importBtn}
|
|
922
1479
|
/>
|
|
923
1480
|
<IconButton
|
|
924
|
-
icon={
|
|
925
|
-
onClick={
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
sidebarCollapsed ? 'Show' : 'Hide'
|
|
930
|
-
} Cameras`}
|
|
1481
|
+
icon={Save}
|
|
1482
|
+
onClick={saveToDatabase}
|
|
1483
|
+
disabled={!cameras.length && !uploadedImage}
|
|
1484
|
+
tooltip="Save camera placement to database"
|
|
1485
|
+
className={styles.saveFileBtn}
|
|
931
1486
|
/>
|
|
932
1487
|
<IconButton
|
|
933
1488
|
icon={Download}
|
|
@@ -944,9 +1499,7 @@ const CameraPlacement = () => {
|
|
|
944
1499
|
<div
|
|
945
1500
|
className={`${styles.imageContainer} ${
|
|
946
1501
|
uploadedImage ? styles.addMode : ''
|
|
947
|
-
} ${
|
|
948
|
-
isDragOver ? styles.dragOver : ''
|
|
949
|
-
}`}
|
|
1502
|
+
} ${isDragOver ? styles.dragOver : ''}`}
|
|
950
1503
|
ref={imageContainer}
|
|
951
1504
|
onClick={handleImageClick}
|
|
952
1505
|
onDragOver={handleDragOver}
|
|
@@ -1386,10 +1939,6 @@ const CameraPlacement = () => {
|
|
|
1386
1939
|
rotationHandle.style
|
|
1387
1940
|
) {
|
|
1388
1941
|
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
1389
|
-
} else {
|
|
1390
|
-
console.error(
|
|
1391
|
-
'Rotation handle is null or has no style property'
|
|
1392
|
-
);
|
|
1393
1942
|
}
|
|
1394
1943
|
|
|
1395
1944
|
// Update camera direction
|
|
@@ -1468,56 +2017,51 @@ const CameraPlacement = () => {
|
|
|
1468
2017
|
</div>
|
|
1469
2018
|
)}
|
|
1470
2019
|
</div>
|
|
1471
|
-
</div>
|
|
1472
|
-
|
|
1473
|
-
{/* Hidden file input */}
|
|
1474
|
-
<input
|
|
1475
|
-
type="file"
|
|
1476
|
-
ref={fileInputRef}
|
|
1477
|
-
onChange={handleImageUpload}
|
|
1478
|
-
accept="image/*"
|
|
1479
|
-
style={{ display: 'none' }}
|
|
1480
|
-
/>
|
|
1481
2020
|
|
|
1482
|
-
|
|
1483
|
-
className={`${styles.sidebar} ${
|
|
1484
|
-
sidebarCollapsed ? styles.collapsed : ''
|
|
1485
|
-
}`}
|
|
1486
|
-
>
|
|
2021
|
+
<div className={styles.sidebar}>
|
|
1487
2022
|
<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
|
-
}}
|
|
2023
|
+
<div className={styles.sidebarTabs}>
|
|
2024
|
+
<button
|
|
2025
|
+
className={`${styles.tabButton} ${sidebarTab === 'cameras' ? styles.active : ''}`}
|
|
2026
|
+
onClick={() => setSidebarTab('cameras')}
|
|
1504
2027
|
>
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
2028
|
+
Cameras
|
|
2029
|
+
<span className={styles.tabCount}>({cameras.length})</span>
|
|
2030
|
+
</button>
|
|
2031
|
+
<button
|
|
2032
|
+
className={`${styles.tabButton} ${sidebarTab === 'projects' ? styles.active : ''}`}
|
|
2033
|
+
onClick={() => setSidebarTab('projects')}
|
|
2034
|
+
>
|
|
2035
|
+
Projects
|
|
2036
|
+
<span className={styles.tabCount}>({savedProjects.length})</span>
|
|
2037
|
+
</button>
|
|
2038
|
+
</div>
|
|
2039
|
+
{selectedProject && sidebarTab === 'cameras' && (
|
|
2040
|
+
<div className={styles.currentProject}>
|
|
2041
|
+
<span className={styles.projectLabel}>Current:</span>
|
|
2042
|
+
<span className={styles.projectName}>{selectedProject.project_name}</span>
|
|
1519
2043
|
</div>
|
|
1520
|
-
)
|
|
2044
|
+
)}
|
|
2045
|
+
</div>
|
|
2046
|
+
|
|
2047
|
+
<div className={styles.sidebarContent}>
|
|
2048
|
+
{sidebarTab === 'cameras' ? (
|
|
2049
|
+
<div className={styles.camerasList}>
|
|
2050
|
+
{cameras.length === 0 ? (
|
|
2051
|
+
<div className={styles.emptyState}>
|
|
2052
|
+
<div>
|
|
2053
|
+
{selectedProject
|
|
2054
|
+
? `No cameras in "${selectedProject.project_name}"`
|
|
2055
|
+
: "Upload an image first, then click anywhere on it to place cameras"
|
|
2056
|
+
}
|
|
2057
|
+
</div>
|
|
2058
|
+
<div className={styles.emptyStateSubtext}>
|
|
2059
|
+
Once placed, you can drag cameras to reposition
|
|
2060
|
+
them and use the rotation handle to adjust
|
|
2061
|
+
direction
|
|
2062
|
+
</div>
|
|
2063
|
+
</div>
|
|
2064
|
+
) : (
|
|
1521
2065
|
cameras.map((camera) => {
|
|
1522
2066
|
const verkadaCamera = camera.verkadaModel
|
|
1523
2067
|
? getCameraById(camera.verkadaModel)
|
|
@@ -1969,9 +2513,80 @@ const CameraPlacement = () => {
|
|
|
1969
2513
|
</div>
|
|
1970
2514
|
);
|
|
1971
2515
|
})
|
|
2516
|
+
)}
|
|
2517
|
+
</div>
|
|
2518
|
+
) : (
|
|
2519
|
+
<div className={styles.projectsList}>
|
|
2520
|
+
{loadingProjects ? (
|
|
2521
|
+
<div className={styles.loadingState}>
|
|
2522
|
+
<div>Loading projects...</div>
|
|
2523
|
+
</div>
|
|
2524
|
+
) : savedProjects.length === 0 ? (
|
|
2525
|
+
<div className={styles.emptyState}>
|
|
2526
|
+
<div>No saved projects found</div>
|
|
2527
|
+
<div className={styles.emptyStateSubtext}>
|
|
2528
|
+
Create and save your first camera placement project
|
|
2529
|
+
</div>
|
|
2530
|
+
</div>
|
|
2531
|
+
) : (
|
|
2532
|
+
savedProjects.map((project) => (
|
|
2533
|
+
<div
|
|
2534
|
+
key={project.id}
|
|
2535
|
+
className={`${styles.projectItem} ${selectedProject?.id === project.id ? styles.selected : ''}`}
|
|
2536
|
+
onClick={() => loadProject(project)}
|
|
2537
|
+
>
|
|
2538
|
+
<div className={styles.projectHeader}>
|
|
2539
|
+
<h4 className={styles.projectTitle}>
|
|
2540
|
+
{project.project_name}
|
|
2541
|
+
</h4>
|
|
2542
|
+
<div className={styles.projectMeta}>
|
|
2543
|
+
<span className={styles.projectMode}>
|
|
2544
|
+
{project.mode}
|
|
2545
|
+
</span>
|
|
2546
|
+
<span className={styles.projectDate}>
|
|
2547
|
+
{new Date(project.created_at).toLocaleDateString()}
|
|
2548
|
+
</span>
|
|
2549
|
+
</div>
|
|
2550
|
+
</div>
|
|
2551
|
+
<div className={styles.projectInfo}>
|
|
2552
|
+
<span className={styles.cameraCount}>
|
|
2553
|
+
{project.cameras_count || 0} cameras
|
|
2554
|
+
</span>
|
|
2555
|
+
{project.image_data && (
|
|
2556
|
+
<span className={styles.hasImage}>
|
|
2557
|
+
📷 Image
|
|
2558
|
+
</span>
|
|
2559
|
+
)}
|
|
2560
|
+
</div>
|
|
2561
|
+
{project.description && (
|
|
2562
|
+
<div className={styles.projectDescription}>
|
|
2563
|
+
{project.description}
|
|
2564
|
+
</div>
|
|
2565
|
+
)}
|
|
2566
|
+
</div>
|
|
2567
|
+
))
|
|
2568
|
+
)}
|
|
2569
|
+
</div>
|
|
1972
2570
|
)}
|
|
1973
2571
|
</div>
|
|
1974
2572
|
</div>
|
|
2573
|
+
</div>
|
|
2574
|
+
|
|
2575
|
+
{/* Hidden file inputs */}
|
|
2576
|
+
<input
|
|
2577
|
+
type="file"
|
|
2578
|
+
ref={fileInputRef}
|
|
2579
|
+
onChange={handleImageUpload}
|
|
2580
|
+
accept="image/*"
|
|
2581
|
+
style={{ display: 'none' }}
|
|
2582
|
+
/>
|
|
2583
|
+
<input
|
|
2584
|
+
type="file"
|
|
2585
|
+
ref={jsonFileInputRef}
|
|
2586
|
+
onChange={handleJsonFileUpload}
|
|
2587
|
+
accept=".json,application/json"
|
|
2588
|
+
style={{ display: 'none' }}
|
|
2589
|
+
/>
|
|
1975
2590
|
|
|
1976
2591
|
{showModal && selectedCamera && (
|
|
1977
2592
|
<CameraSelectionModal
|