@7365admin1/layer-common 4.1.3-staging.254 → 4.1.3-staging.256
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/components/CameraForm.vue +189 -6
- package/components/CameraWall.vue +129 -1
- package/composables/useSiteSettings.ts +47 -2
- package/package.json +1 -1
- package/utils/camera-wall.ts +72 -0
|
@@ -15,7 +15,67 @@
|
|
|
15
15
|
placeholder="Enter CCTV camera name"
|
|
16
16
|
/>
|
|
17
17
|
</v-col>
|
|
18
|
-
<v-col cols="12">
|
|
18
|
+
<v-col v-if="isCCTV" cols="12" class="mb-2">
|
|
19
|
+
<InputLabel
|
|
20
|
+
class="text-capitalize"
|
|
21
|
+
title="How do you have this camera's address?"
|
|
22
|
+
/>
|
|
23
|
+
<v-radio-group
|
|
24
|
+
v-model="addressMode"
|
|
25
|
+
inline
|
|
26
|
+
hide-details
|
|
27
|
+
density="comfortable"
|
|
28
|
+
>
|
|
29
|
+
<v-radio label="From the recorder" value="recorder" />
|
|
30
|
+
<v-radio label="I already have a link" value="url" />
|
|
31
|
+
</v-radio-group>
|
|
32
|
+
</v-col>
|
|
33
|
+
|
|
34
|
+
<template v-if="showRecorderFields">
|
|
35
|
+
<v-col cols="12">
|
|
36
|
+
<InputLabel
|
|
37
|
+
class="text-capitalize"
|
|
38
|
+
title="Recorder address"
|
|
39
|
+
required
|
|
40
|
+
/>
|
|
41
|
+
<v-text-field
|
|
42
|
+
v-model.trim="recorder.host"
|
|
43
|
+
density="comfortable"
|
|
44
|
+
:rules="[requiredRule]"
|
|
45
|
+
placeholder="Enter the recorder's address"
|
|
46
|
+
hint="The address of the recorder (NVR) this camera is plugged into, on the site network."
|
|
47
|
+
persistent-hint
|
|
48
|
+
/>
|
|
49
|
+
</v-col>
|
|
50
|
+
|
|
51
|
+
<v-col cols="5" class="pr-2">
|
|
52
|
+
<InputLabel class="text-capitalize" title="Port" />
|
|
53
|
+
<v-text-field
|
|
54
|
+
v-model.trim="recorder.port"
|
|
55
|
+
density="comfortable"
|
|
56
|
+
:rules="[portRule]"
|
|
57
|
+
placeholder="Leave blank if unsure"
|
|
58
|
+
/>
|
|
59
|
+
</v-col>
|
|
60
|
+
|
|
61
|
+
<v-col cols="7">
|
|
62
|
+
<InputLabel
|
|
63
|
+
class="text-capitalize"
|
|
64
|
+
title="Channel number"
|
|
65
|
+
required
|
|
66
|
+
/>
|
|
67
|
+
<v-text-field
|
|
68
|
+
v-model.trim="recorder.channel"
|
|
69
|
+
density="comfortable"
|
|
70
|
+
:rules="[requiredRule, channelRule]"
|
|
71
|
+
placeholder="e.g. 6"
|
|
72
|
+
hint="The channel this camera sits on in the recorder's own list."
|
|
73
|
+
persistent-hint
|
|
74
|
+
/>
|
|
75
|
+
</v-col>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<v-col v-if="showUrlField" cols="12">
|
|
19
79
|
<InputLabel class="text-capitalize" title="URL" required />
|
|
20
80
|
<v-text-field
|
|
21
81
|
v-model.trim="camera.host"
|
|
@@ -26,6 +86,26 @@
|
|
|
26
86
|
/>
|
|
27
87
|
</v-col>
|
|
28
88
|
|
|
89
|
+
<v-col v-if="isCCTV" cols="12" class="pt-2">
|
|
90
|
+
<v-btn
|
|
91
|
+
variant="outlined"
|
|
92
|
+
class="text-none"
|
|
93
|
+
:disabled="!canTest || disable"
|
|
94
|
+
:loading="testing"
|
|
95
|
+
@click="testCamera"
|
|
96
|
+
text="Test this camera"
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
<v-alert
|
|
100
|
+
v-if="testResult"
|
|
101
|
+
class="mt-3"
|
|
102
|
+
variant="tonal"
|
|
103
|
+
density="compact"
|
|
104
|
+
:type="testResult.ok ? 'success' : 'warning'"
|
|
105
|
+
:text="testResult.message"
|
|
106
|
+
/>
|
|
107
|
+
</v-col>
|
|
108
|
+
|
|
29
109
|
<!-- <v-col v-if="isANPR" cols="12">
|
|
30
110
|
<InputLabel class="text-capitalize" title="Category" required />
|
|
31
111
|
<v-select
|
|
@@ -232,6 +312,108 @@ const message = ref("");
|
|
|
232
312
|
const isANPR = computed(() => prop.type === "anpr");
|
|
233
313
|
const isCCTV = computed(() => prop.type === "ip");
|
|
234
314
|
|
|
315
|
+
const { addCamera, updateSiteCamera, testSiteCamera } = useSiteSettings();
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Two ways to say where a camera is, and the guided one is the default.
|
|
319
|
+
*
|
|
320
|
+
* A CCTV camera's stored address is a relay player page. Nothing printed on a
|
|
321
|
+
* camera, and nothing in a recorder's own screens, will ever tell somebody what
|
|
322
|
+
* it is — until now it had to be requested from whoever runs the video service
|
|
323
|
+
* and pasted in. `recorder` collects what a person can genuinely find out and
|
|
324
|
+
* the server derives the address; `url` is the old field, kept because cameras
|
|
325
|
+
* configured that way must keep editing exactly as they do today and because
|
|
326
|
+
* some people do legitimately have a ready-made link.
|
|
327
|
+
*
|
|
328
|
+
* Editing an existing camera opens on `url`, so nothing about an already-working
|
|
329
|
+
* record changes shape under the person looking at it.
|
|
330
|
+
*/
|
|
331
|
+
const addressMode = ref<"recorder" | "url">(
|
|
332
|
+
prop.type === "ip" && prop.mode !== "edit" ? "recorder" : "url"
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const recorder = ref({ host: "", port: "", channel: "" });
|
|
336
|
+
|
|
337
|
+
const showRecorderFields = computed(
|
|
338
|
+
() => isCCTV.value && addressMode.value === "recorder"
|
|
339
|
+
);
|
|
340
|
+
const showUrlField = computed(
|
|
341
|
+
() => !isCCTV.value || addressMode.value === "url"
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const portRule = (v: string | any) => {
|
|
345
|
+
if (!v) return true;
|
|
346
|
+
const port = Number(v);
|
|
347
|
+
return (Number.isInteger(port) && port >= 1 && port <= 65535) || "Enter a port number between 1 and 65535, or leave it blank.";
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const channelRule = (v: string | any) => {
|
|
351
|
+
if (!v) return true;
|
|
352
|
+
const channel = Number(v);
|
|
353
|
+
return (
|
|
354
|
+
(Number.isInteger(channel) && channel >= 1 && channel <= 256) ||
|
|
355
|
+
"Enter the channel number, from 1 to 256."
|
|
356
|
+
);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/* ---------------------------- test before saving --------------------------- */
|
|
360
|
+
|
|
361
|
+
const testing = ref(false);
|
|
362
|
+
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* What the address fields currently say, in the shape the API takes.
|
|
366
|
+
*
|
|
367
|
+
* The same object is used for the test and for the save, so a green test is a
|
|
368
|
+
* test of the thing that gets saved and not of something near it.
|
|
369
|
+
*/
|
|
370
|
+
function addressPayload() {
|
|
371
|
+
if (showRecorderFields.value) {
|
|
372
|
+
return {
|
|
373
|
+
host: "",
|
|
374
|
+
recorderHost: recorder.value.host,
|
|
375
|
+
recorderPort: recorder.value.port ? Number(recorder.value.port) : undefined,
|
|
376
|
+
channel: recorder.value.channel ? Number(recorder.value.channel) : undefined,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { host: camera.value.host };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const canTest = computed(() => {
|
|
384
|
+
if (testing.value) return false;
|
|
385
|
+
if (showRecorderFields.value) {
|
|
386
|
+
return Boolean(recorder.value.host && recorder.value.channel);
|
|
387
|
+
}
|
|
388
|
+
return Boolean(camera.value.host);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// A verdict belongs to the address that produced it. Change the address and the
|
|
392
|
+
// old verdict is no longer about anything.
|
|
393
|
+
watch(
|
|
394
|
+
() => JSON.stringify(addressPayload()),
|
|
395
|
+
() => {
|
|
396
|
+
testResult.value = null;
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
async function testCamera() {
|
|
401
|
+
testing.value = true;
|
|
402
|
+
testResult.value = null;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const result = await testSiteCamera(prop.site, addressPayload());
|
|
406
|
+
testResult.value = { ok: result.ok, message: result.message };
|
|
407
|
+
} catch (error: any) {
|
|
408
|
+
testResult.value = {
|
|
409
|
+
ok: false,
|
|
410
|
+
message: cameraErrorConverter(error, prop.type),
|
|
411
|
+
};
|
|
412
|
+
} finally {
|
|
413
|
+
testing.value = false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
235
417
|
const anprCategoryOptions = computed(() => [
|
|
236
418
|
{ title: "Resident", value: "resident" },
|
|
237
419
|
{ title: "Visitor", value: "visitor" },
|
|
@@ -265,7 +447,10 @@ const hasChanges = computed(() => {
|
|
|
265
447
|
camera.value.category !== prop.camera.category ||
|
|
266
448
|
camera.value.guardPost !== prop.camera.guardPost ||
|
|
267
449
|
camera.value.direction !== prop.camera.direction ||
|
|
268
|
-
camera.value.name !== prop.camera.name
|
|
450
|
+
camera.value.name !== prop.camera.name ||
|
|
451
|
+
// Switching an existing camera over to the recorder fields is a change
|
|
452
|
+
// even though its stored URL has not been retyped.
|
|
453
|
+
showRecorderFields.value
|
|
269
454
|
);
|
|
270
455
|
}
|
|
271
456
|
|
|
@@ -273,20 +458,18 @@ const hasChanges = computed(() => {
|
|
|
273
458
|
return true;
|
|
274
459
|
});
|
|
275
460
|
|
|
276
|
-
const { addCamera, updateSiteCamera } = useSiteSettings();
|
|
277
|
-
|
|
278
461
|
async function submit() {
|
|
279
462
|
disable.value = true;
|
|
280
463
|
camera.value.guardPost = camera.value.guardPost ?? 0;
|
|
281
464
|
|
|
282
465
|
try {
|
|
283
466
|
if (prop.mode === "add") {
|
|
284
|
-
await addCamera(camera.value);
|
|
467
|
+
await addCamera({ ...camera.value, ...addressPayload() });
|
|
285
468
|
}
|
|
286
469
|
|
|
287
470
|
if (prop.mode === "edit") {
|
|
288
471
|
await updateSiteCamera(camera.value._id ?? "", {
|
|
289
|
-
|
|
472
|
+
...addressPayload(),
|
|
290
473
|
username: camera.value.username,
|
|
291
474
|
password: camera.value.password,
|
|
292
475
|
category: camera.value.category,
|
|
@@ -72,7 +72,32 @@
|
|
|
72
72
|
<p v-if="!cameras.length && !loading" class="vms__picker-empty">
|
|
73
73
|
No cameras yet.
|
|
74
74
|
</p>
|
|
75
|
-
|
|
75
|
+
|
|
76
|
+
<!--
|
|
77
|
+
SEARCH, then a pager. A site with 150 cameras used to render 150
|
|
78
|
+
checkboxes in one 220 px column, so finding "Basement Ramp 2" meant
|
|
79
|
+
scrolling past everything else. Typing filters; the pager keeps the
|
|
80
|
+
column a fixed height whatever the site's size.
|
|
81
|
+
|
|
82
|
+
A plain input rather than a Vuetify field: this component is the wall
|
|
83
|
+
chrome, styled from its own CSS variables, and a `v-text-field` here
|
|
84
|
+
brings the app theme into a black surround that deliberately is not
|
|
85
|
+
themed.
|
|
86
|
+
-->
|
|
87
|
+
<input
|
|
88
|
+
v-if="cameras.length > PICKER_PAGE_SIZE"
|
|
89
|
+
v-model.trim="pickerSearch"
|
|
90
|
+
class="vms__picker-search"
|
|
91
|
+
type="search"
|
|
92
|
+
placeholder="Search cameras"
|
|
93
|
+
aria-label="Search cameras on this site"
|
|
94
|
+
>
|
|
95
|
+
|
|
96
|
+
<p v-if="cameras.length && !picker.matched" class="vms__picker-empty">
|
|
97
|
+
No camera matches that search.
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
<label v-for="c in picker.items" :key="c._id" class="vms__pick">
|
|
76
101
|
<input
|
|
77
102
|
type="checkbox"
|
|
78
103
|
:checked="isPicked(c._id)"
|
|
@@ -85,6 +110,32 @@
|
|
|
85
110
|
:class="c.status === 'active' ? 'vms__pick-dot--on' : 'vms__pick-dot--off'"
|
|
86
111
|
/>
|
|
87
112
|
</label>
|
|
113
|
+
|
|
114
|
+
<!--
|
|
115
|
+
Shown whenever the matching list is longer than one page, so the reader
|
|
116
|
+
is never left believing the column is the whole list. The count is of
|
|
117
|
+
MATCHES, not of the site, because that is the list being paged.
|
|
118
|
+
-->
|
|
119
|
+
<div v-if="picker.pages > 1" class="vms__picker-pager">
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
class="vms__link"
|
|
123
|
+
:disabled="pickerPage === 0"
|
|
124
|
+
@click="pickerPage -= 1"
|
|
125
|
+
>
|
|
126
|
+
Prev
|
|
127
|
+
</button>
|
|
128
|
+
<span class="vms__picker-range">{{ picker.range }}</span>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
class="vms__link"
|
|
132
|
+
:disabled="pickerPage >= picker.pages - 1"
|
|
133
|
+
@click="pickerPage += 1"
|
|
134
|
+
>
|
|
135
|
+
Next
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
88
139
|
<p v-if="selectedIds.length >= layout.tiles" class="vms__picker-note">
|
|
89
140
|
This layout holds {{ layout.tiles }}. Choose a bigger layout for more.
|
|
90
141
|
</p>
|
|
@@ -293,6 +344,8 @@ import {
|
|
|
293
344
|
focusedCamera,
|
|
294
345
|
healthView,
|
|
295
346
|
isDenseLayout,
|
|
347
|
+
PICKER_PAGE_SIZE,
|
|
348
|
+
pickerView,
|
|
296
349
|
sharedCapabilityNote,
|
|
297
350
|
wallCameras,
|
|
298
351
|
wallLayout,
|
|
@@ -392,6 +445,8 @@ onMounted(load);
|
|
|
392
445
|
watch(() => props.site, () => {
|
|
393
446
|
selectedIds.value = [];
|
|
394
447
|
page.value = 0;
|
|
448
|
+
pickerSearch.value = "";
|
|
449
|
+
pickerPage.value = 0;
|
|
395
450
|
healthById.value = {};
|
|
396
451
|
load();
|
|
397
452
|
});
|
|
@@ -437,6 +492,46 @@ watch([layoutKey, pages], () => {
|
|
|
437
492
|
page.value = clampPage(page.value, pages.value);
|
|
438
493
|
});
|
|
439
494
|
|
|
495
|
+
/* ------------------------------------------------- browsing a large estate */
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The picker is searched and paged, because at 150 cameras it was neither.
|
|
499
|
+
*
|
|
500
|
+
* It rendered one checkbox per camera in a 220 px column with no filter, so the
|
|
501
|
+
* only way to reach the last camera at a large site was to scroll past the other
|
|
502
|
+
* 149 - and nothing on screen said how many there were. This is the same
|
|
503
|
+
* treatment `CameraMain.vue` already gives the settings list.
|
|
504
|
+
*
|
|
505
|
+
* Searched and paged in the BROWSER on purpose: the wall endpoint answers with
|
|
506
|
+
* the whole site in one read (`getSiteWall` applies no limit, and the response is
|
|
507
|
+
* an id/name/status projection, not pictures), it has no search parameter, and a
|
|
508
|
+
* site's camera list is small enough to hold. If a site ever grows past what one
|
|
509
|
+
* request should carry, this wants a server-side search - and so does the
|
|
510
|
+
* endpoint.
|
|
511
|
+
*
|
|
512
|
+
* ponytail: a plain filter + slice, no virtual scroller. 150 rows of which 25
|
|
513
|
+
* are mounted is not a performance problem.
|
|
514
|
+
*/
|
|
515
|
+
const pickerSearch = ref("");
|
|
516
|
+
const pickerPage = ref(0);
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The whole picker column in one computed: the matching cameras, the page being
|
|
520
|
+
* shown, how many pages there are and the "26-50 of 150" label. The rule lives
|
|
521
|
+
* in `pickerView` so it can be tested; this only holds the two inputs.
|
|
522
|
+
*/
|
|
523
|
+
const picker = computed(() =>
|
|
524
|
+
pickerView(cameras.value, pickerSearch.value, pickerPage.value, PICKER_PAGE_SIZE)
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* `pickerView` clamps the page for display, but the ref has to follow or the
|
|
529
|
+
* Prev/Next buttons act on a number the column is not on.
|
|
530
|
+
*/
|
|
531
|
+
watch(picker, (view) => {
|
|
532
|
+
if (pickerPage.value !== view.page) pickerPage.value = view.page;
|
|
533
|
+
});
|
|
534
|
+
|
|
440
535
|
function isPicked(id: string) {
|
|
441
536
|
return selectedIds.value.includes(id);
|
|
442
537
|
}
|
|
@@ -728,6 +823,39 @@ async function toggleFullscreen() {
|
|
|
728
823
|
border-right: 1px solid var(--vms-line);
|
|
729
824
|
}
|
|
730
825
|
|
|
826
|
+
.vms__picker-search {
|
|
827
|
+
width: 100%;
|
|
828
|
+
box-sizing: border-box;
|
|
829
|
+
margin: 0 0 6px;
|
|
830
|
+
/* 32 px so a thumb on a control-room touchscreen hits it. */
|
|
831
|
+
height: 32px;
|
|
832
|
+
padding: 0 8px;
|
|
833
|
+
font-size: 12px;
|
|
834
|
+
color: var(--vms-text);
|
|
835
|
+
background: rgba(255, 255, 255, 0.06);
|
|
836
|
+
border: 1px solid var(--vms-line);
|
|
837
|
+
border-radius: 4px;
|
|
838
|
+
}
|
|
839
|
+
.vms__picker-search::placeholder {
|
|
840
|
+
color: var(--vms-text-dim);
|
|
841
|
+
}
|
|
842
|
+
.vms__picker-pager {
|
|
843
|
+
display: flex;
|
|
844
|
+
align-items: center;
|
|
845
|
+
justify-content: space-between;
|
|
846
|
+
gap: 6px;
|
|
847
|
+
margin: 6px 0 0;
|
|
848
|
+
font-size: 11px;
|
|
849
|
+
}
|
|
850
|
+
.vms__picker-range {
|
|
851
|
+
color: var(--vms-text-dim);
|
|
852
|
+
}
|
|
853
|
+
.vms__picker-pager .vms__link[disabled] {
|
|
854
|
+
/* Dimmed, not hidden: a pager that loses its Prev button on page 1 moves Next
|
|
855
|
+
under the cursor and gets clicked by accident. */
|
|
856
|
+
opacity: 0.45;
|
|
857
|
+
cursor: default;
|
|
858
|
+
}
|
|
731
859
|
.vms__picker-head {
|
|
732
860
|
display: flex;
|
|
733
861
|
align-items: center;
|
|
@@ -48,13 +48,56 @@ export default function () {
|
|
|
48
48
|
}
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
|
-
async function addCamera(camera: TSiteCamera) {
|
|
51
|
+
async function addCamera(camera: TSiteCamera & TCameraByRecorder) {
|
|
52
52
|
return await useNuxtApp().$api<Record<string, any>>(`/api/site-cameras`, {
|
|
53
53
|
method: "POST",
|
|
54
54
|
body: camera,
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Describe a camera by the recorder it is on instead of by a finished URL.
|
|
60
|
+
*
|
|
61
|
+
* The stored address is a relay player page, which nobody outside the video
|
|
62
|
+
* service can work out. These three fields are what a person can actually
|
|
63
|
+
* read off a recorder, and the server turns them into that address. Optional
|
|
64
|
+
* everywhere: sending `host` still works exactly as it always has.
|
|
65
|
+
*/
|
|
66
|
+
type TCameraByRecorder = {
|
|
67
|
+
recorderHost?: string;
|
|
68
|
+
recorderPort?: number;
|
|
69
|
+
channel?: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What a "Test this camera" press comes back with. Never an address. */
|
|
73
|
+
type TCameraTestResult = {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
status:
|
|
76
|
+
| "working"
|
|
77
|
+
| "no-video"
|
|
78
|
+
| "credential-refused"
|
|
79
|
+
| "unreachable"
|
|
80
|
+
| "cannot-test";
|
|
81
|
+
message: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Try an address before saving it. Read-only, and it runs on the server —
|
|
86
|
+
* the browser never touches a recorder and never sees a credential.
|
|
87
|
+
*/
|
|
88
|
+
async function testSiteCamera(
|
|
89
|
+
siteId: string,
|
|
90
|
+
payload: { host?: string } & TCameraByRecorder
|
|
91
|
+
) {
|
|
92
|
+
return await useNuxtApp().$api<TCameraTestResult>(
|
|
93
|
+
`/api/site-cameras/site/${siteId}/test`,
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
body: payload,
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
async function updateSiteCamera(
|
|
59
102
|
id: string,
|
|
60
103
|
value: Partial<
|
|
@@ -68,7 +111,8 @@ export default function () {
|
|
|
68
111
|
| "direction"
|
|
69
112
|
| "status"
|
|
70
113
|
>
|
|
71
|
-
>
|
|
114
|
+
> &
|
|
115
|
+
TCameraByRecorder
|
|
72
116
|
) {
|
|
73
117
|
return await useNuxtApp().$api<Record<string, any>>(
|
|
74
118
|
`/api/site-cameras/id/${id}`,
|
|
@@ -258,6 +302,7 @@ export default function () {
|
|
|
258
302
|
getSiteUnits,
|
|
259
303
|
updateSite,
|
|
260
304
|
addCamera,
|
|
305
|
+
testSiteCamera,
|
|
261
306
|
getAllSiteCameras,
|
|
262
307
|
bulkImportSiteCameras,
|
|
263
308
|
getSiteWall,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.1.3-staging.
|
|
5
|
+
"version": "4.1.3-staging.256",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/utils/camera-wall.ts
CHANGED
|
@@ -169,6 +169,78 @@ export function clampPage(page: number, pages: number): number {
|
|
|
169
169
|
return Math.min(Math.max(0, Math.floor(page)), Math.max(0, pages - 1));
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** How many cameras the picker's side column shows at a time. */
|
|
173
|
+
export const PICKER_PAGE_SIZE = 25;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The camera browser beside the wall, searched and paged.
|
|
177
|
+
*
|
|
178
|
+
* **This exists because the picker was neither.** It rendered one checkbox per
|
|
179
|
+
* camera in a 220 px column with no filter and no count, so at a 150-camera site
|
|
180
|
+
* the only way to reach the last camera was to scroll past the other 149, and
|
|
181
|
+
* nothing on screen said how many there were. A supervisor who could not find a
|
|
182
|
+
* camera had no way to tell whether it was missing or merely further down.
|
|
183
|
+
*
|
|
184
|
+
* Searched and paged in the browser on purpose: the wall endpoint answers with
|
|
185
|
+
* the whole site in one read (`getSiteWall` applies no limit, and the response is
|
|
186
|
+
* an id/name/status projection rather than pictures) and has no search parameter.
|
|
187
|
+
*
|
|
188
|
+
* Extracted from the component so the paging is a tested rule rather than a
|
|
189
|
+
* template nobody would think to check - the playground build does not compile
|
|
190
|
+
* the wall's screens at all.
|
|
191
|
+
*
|
|
192
|
+
* `range` counts MATCHES, not the site, because that is the list being paged -
|
|
193
|
+
* but a search that hides cameras says so through `total` vs `matched`.
|
|
194
|
+
*/
|
|
195
|
+
export function pickerView(
|
|
196
|
+
cameras: TWallCamera[],
|
|
197
|
+
query: string | undefined,
|
|
198
|
+
page: number,
|
|
199
|
+
pageSize: number = PICKER_PAGE_SIZE
|
|
200
|
+
): {
|
|
201
|
+
matches: TWallCamera[];
|
|
202
|
+
items: TWallCamera[];
|
|
203
|
+
pages: number;
|
|
204
|
+
page: number;
|
|
205
|
+
range: string;
|
|
206
|
+
total: number;
|
|
207
|
+
matched: number;
|
|
208
|
+
} {
|
|
209
|
+
const all = Array.isArray(cameras) ? cameras : [];
|
|
210
|
+
const size = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : PICKER_PAGE_SIZE;
|
|
211
|
+
const needle = String(query ?? "").trim().toLowerCase();
|
|
212
|
+
|
|
213
|
+
// Name first, id as the fallback label - the same thing the row displays, so
|
|
214
|
+
// searching for what you can read always finds it.
|
|
215
|
+
const matches = needle
|
|
216
|
+
? all.filter((c: any) =>
|
|
217
|
+
String(c?.name || c?._id || "").toLowerCase().includes(needle)
|
|
218
|
+
)
|
|
219
|
+
: all;
|
|
220
|
+
|
|
221
|
+
const pages = Math.max(1, Math.ceil(matches.length / size));
|
|
222
|
+
// Clamped rather than trusted: typing can shrink the list under a page number
|
|
223
|
+
// that was valid a keystroke ago, and an out-of-range page renders as "no
|
|
224
|
+
// cameras", which reads as a broken picker rather than a stale page.
|
|
225
|
+
const current = clampPage(page, pages);
|
|
226
|
+
const start = current * size;
|
|
227
|
+
const items = matches.slice(start, start + size);
|
|
228
|
+
|
|
229
|
+
const range = matches.length
|
|
230
|
+
? `${start + 1}-${Math.min(matches.length, start + size)} of ${matches.length}`
|
|
231
|
+
: "0 of 0";
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
matches,
|
|
235
|
+
items,
|
|
236
|
+
pages,
|
|
237
|
+
page: current,
|
|
238
|
+
range,
|
|
239
|
+
total: all.length,
|
|
240
|
+
matched: matches.length,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
172
244
|
/**
|
|
173
245
|
* The cameras on one page, in order. **Never padded.**
|
|
174
246
|
*
|