@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.
@@ -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
- <label v-for="c in cameras" :key="c._id" class="vms__pick">
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.255",
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.",
@@ -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
  *