@7365admin1/layer-common 4.1.3-staging.255 → 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/CameraWall.vue +129 -1
- package/package.json +1 -1
- package/utils/camera-wall.ts +72 -0
|
@@ -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;
|
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
|
*
|