@7365admin1/layer-common 3.2.0 → 3.2.1-staging.60
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/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/components/CameraWall.vue +625 -0
- package/components/CameraWallTile.vue +333 -0
- package/components/EntryPassInformation.vue +4 -4
- package/components/HidAccessLogDashboard.vue +53 -8
- package/components/HidIntercomManagement.vue +842 -201
- package/components/HidQrCodeConfiguration.vue +907 -95
- package/components/HidServiceSettingsPanel.vue +20 -1
- package/components/HidUserEnrollment.vue +96 -2
- package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
- package/components/OnlineFormConfigurationForm.vue +620 -224
- package/components/OnlineFormFill.vue +36 -8
- package/components/OnlineFormsConfiguration.vue +6 -2
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/SiteSettings.vue +4 -2
- package/components/VisitorForm.vue +201 -78
- package/components/VisitorManagement.vue +0 -1
- package/composables/useHidAmico.ts +127 -0
- package/composables/useHidNavigation.ts +0 -7
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useSipWebPhone.ts +332 -0
- package/composables/useSiteSettings.ts +28 -0
- package/composables/useWebUsb.ts +127 -37
- package/package.json +2 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/plugins/secure-member.client.ts +21 -56
- package/types/site.d.ts +71 -0
- package/utils/camera-wall.test.ts +149 -0
- package/utils/camera-wall.ts +299 -0
- package/components/HidIdentityMapping.vue +0 -975
- package/middleware/member.ts +0 -4
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/layer-common": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Draw the camera wall from the gated wall endpoint, and show the server's own
|
|
6
|
+
reason on a tile.
|
|
7
|
+
|
|
8
|
+
`CameraWall` fetched its cameras from `GET /api/site-cameras` - the Settings
|
|
9
|
+
panel's paginated CRUD list, which took its site from an optional query
|
|
10
|
+
parameter and checked nothing about the caller. Any signed-in user from any
|
|
11
|
+
organisation could read every camera in the estate through it, `host` and
|
|
12
|
+
`username` included.
|
|
13
|
+
|
|
14
|
+
It now calls `GET /api/site-cameras/site/:siteId/wall`, which already exists,
|
|
15
|
+
which the React Native monitoring app was built against, and which is
|
|
16
|
+
authorised server-side: the caller must be a member of the site, of its owning
|
|
17
|
+
organisation, or work for an organisation actively engaged to serve it. That
|
|
18
|
+
endpoint also returns `type: "ip"` cameras only and each camera's capability
|
|
19
|
+
descriptor, so a wall cannot be handed an ANPR unit and a tile can explain
|
|
20
|
+
itself.
|
|
21
|
+
|
|
22
|
+
Two consequences worth stating:
|
|
23
|
+
|
|
24
|
+
- **The pager is gone.** The wall endpoint returns the site's cameras in one
|
|
25
|
+
answer. Paging a video wall was an artefact of borrowing the CRUD list.
|
|
26
|
+
- **A tile now prefers the server's `unavailableReason`** over the reason it
|
|
27
|
+
used to work out itself. The server knows things the browser cannot - chiefly
|
|
28
|
+
"No recorder is configured for this camera's relay", which is the true answer
|
|
29
|
+
for every camera in the estate until the API host is configured, and which the
|
|
30
|
+
wall used to replace with its own guess. Offline still beats everything, since
|
|
31
|
+
it explains every tile at once.
|
|
32
|
+
|
|
33
|
+
No permission gate was invented here. Each application still gates its own menu
|
|
34
|
+
entry; this component draws what it is given, and the server decides.
|
|
35
|
+
|
|
36
|
+
Two smaller things in the same area:
|
|
37
|
+
|
|
38
|
+
- **A refused wall now says so.** The catch-all message told every failure to
|
|
39
|
+
"check your connection", which sends somebody who simply may not see that
|
|
40
|
+
site off to debug their wifi. A 401/403/404 now reads "You do not have access
|
|
41
|
+
to this site's cameras." The server answers "not yours" and "does not exist"
|
|
42
|
+
identically, so this wording does not distinguish them either.
|
|
43
|
+
- **`middleware/member.ts` is removed.** It read a cookie into an unused
|
|
44
|
+
variable and did nothing else - a file named like a membership gate that was
|
|
45
|
+
not one. No page in any of the eleven web apps referenced it. The real check
|
|
46
|
+
is `plugins/secure-member.client.ts`, driven by `memberOnly` page meta, which
|
|
47
|
+
is untouched.
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="root" class="vms">
|
|
3
|
+
<!-- ------------------------------------------------------------ toolbar -->
|
|
4
|
+
<div class="vms__bar">
|
|
5
|
+
<button
|
|
6
|
+
type="button"
|
|
7
|
+
class="vms__tool"
|
|
8
|
+
:class="{ 'vms__tool--on': showPicker }"
|
|
9
|
+
:aria-pressed="showPicker"
|
|
10
|
+
@click="showPicker = !showPicker"
|
|
11
|
+
>
|
|
12
|
+
Cameras<span v-if="cameras.length" class="vms__count">{{ cameras.length }}</span>
|
|
13
|
+
</button>
|
|
14
|
+
|
|
15
|
+
<div class="vms__layouts" role="group" aria-label="Wall layout">
|
|
16
|
+
<button
|
|
17
|
+
v-for="l in WALL_LAYOUTS"
|
|
18
|
+
:key="l.key"
|
|
19
|
+
type="button"
|
|
20
|
+
class="vms__tool vms__tool--icon"
|
|
21
|
+
:class="{ 'vms__tool--on': layoutKey === l.key }"
|
|
22
|
+
:aria-pressed="layoutKey === l.key"
|
|
23
|
+
:title="`${l.label} (${l.key})`"
|
|
24
|
+
:aria-label="`${l.label} camera layout`"
|
|
25
|
+
@click="layoutKey = l.key"
|
|
26
|
+
>
|
|
27
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
|
28
|
+
<rect
|
|
29
|
+
v-for="cell in l.tiles"
|
|
30
|
+
:key="cell"
|
|
31
|
+
:x="((cell - 1) % l.columns) * (16 / l.columns) + 1"
|
|
32
|
+
:y="Math.floor((cell - 1) / l.columns) * (16 / l.columns) + 1"
|
|
33
|
+
:width="16 / l.columns - 2"
|
|
34
|
+
:height="16 / l.columns - 2"
|
|
35
|
+
fill="currentColor"
|
|
36
|
+
/>
|
|
37
|
+
</svg>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="vms__spacer" />
|
|
42
|
+
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="vms__tool"
|
|
46
|
+
title="Fill the screen with the wall"
|
|
47
|
+
@click="toggleFullscreen"
|
|
48
|
+
>
|
|
49
|
+
Full screen
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<!-- -------------------------------------------------------------- body -->
|
|
54
|
+
<div class="vms__body">
|
|
55
|
+
<!--
|
|
56
|
+
THE CAMERA BROWSER. Beside the wall, not on top of it: a supervisor
|
|
57
|
+
changing what they watch should still be able to see what they are
|
|
58
|
+
watching.
|
|
59
|
+
-->
|
|
60
|
+
<aside v-if="showPicker" class="vms__picker" aria-label="Cameras on this site">
|
|
61
|
+
<p class="vms__picker-head">
|
|
62
|
+
On the wall
|
|
63
|
+
<button
|
|
64
|
+
v-if="selectedIds.length"
|
|
65
|
+
type="button"
|
|
66
|
+
class="vms__link"
|
|
67
|
+
@click="selectedIds = []"
|
|
68
|
+
>
|
|
69
|
+
Reset
|
|
70
|
+
</button>
|
|
71
|
+
</p>
|
|
72
|
+
<p v-if="!cameras.length && !loading" class="vms__picker-empty">
|
|
73
|
+
No cameras yet.
|
|
74
|
+
</p>
|
|
75
|
+
<label v-for="c in cameras" :key="c._id" class="vms__pick">
|
|
76
|
+
<input
|
|
77
|
+
type="checkbox"
|
|
78
|
+
:checked="isPicked(c._id)"
|
|
79
|
+
:disabled="!isPicked(c._id) && selectedIds.length >= layout.tiles"
|
|
80
|
+
@change="togglePick(c._id)"
|
|
81
|
+
>
|
|
82
|
+
<span class="vms__pick-name">{{ c.name || c._id }}</span>
|
|
83
|
+
<span
|
|
84
|
+
class="vms__pick-dot"
|
|
85
|
+
:class="c.status === 'active' ? 'vms__pick-dot--on' : 'vms__pick-dot--off'"
|
|
86
|
+
/>
|
|
87
|
+
</label>
|
|
88
|
+
<p v-if="selectedIds.length >= layout.tiles" class="vms__picker-note">
|
|
89
|
+
This layout holds {{ layout.tiles }}. Choose a bigger layout for more.
|
|
90
|
+
</p>
|
|
91
|
+
</aside>
|
|
92
|
+
|
|
93
|
+
<div class="vms__stage">
|
|
94
|
+
<!-- Loading, empty and failed are real states, not a blank rectangle. -->
|
|
95
|
+
<div v-if="loading" class="vms__state">
|
|
96
|
+
<p class="vms__state-head">Loading cameras…</p>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-else-if="failed" class="vms__state">
|
|
100
|
+
<p class="vms__state-head">Cameras could not be loaded</p>
|
|
101
|
+
<p class="vms__state-body">{{ failed }}</p>
|
|
102
|
+
<button type="button" class="vms__tool" @click="load()">Try again</button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div v-else-if="!cameras.length" class="vms__state">
|
|
106
|
+
<p class="vms__state-head">No cameras set up yet</p>
|
|
107
|
+
<p class="vms__state-body">
|
|
108
|
+
CCTV cameras are added in Site Settings, under CCTV. Ask whoever manages
|
|
109
|
+
this site's settings to add one.
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div
|
|
114
|
+
v-else
|
|
115
|
+
class="vms__grid"
|
|
116
|
+
:style="{
|
|
117
|
+
gridTemplateColumns: `repeat(${layout.columns}, minmax(0, 1fr))`,
|
|
118
|
+
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
|
119
|
+
}"
|
|
120
|
+
>
|
|
121
|
+
<CameraWallTile
|
|
122
|
+
v-for="(camera, i) in tiles"
|
|
123
|
+
:key="camera ? camera._id : `empty-${i}`"
|
|
124
|
+
:camera="camera"
|
|
125
|
+
:dense="dense"
|
|
126
|
+
:online="online"
|
|
127
|
+
:focused="!!camera && camera._id === focusedId"
|
|
128
|
+
@focus="focusedId = $event._id"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<!-- ------------------------------------------------------- capabilities -->
|
|
135
|
+
<!--
|
|
136
|
+
THE CONTROLS THAT ARE NOT SWITCHED ON YET.
|
|
137
|
+
|
|
138
|
+
Drawn, dimmed, and carrying the server's own reason — never hidden. A
|
|
139
|
+
supervisor who cannot see that recorded footage exists as an idea cannot
|
|
140
|
+
ask for it, and hiding it is what stops anyone asking.
|
|
141
|
+
-->
|
|
142
|
+
<div v-if="focused" class="vms__caps">
|
|
143
|
+
<p class="vms__caps-head">{{ focused.name || focused._id }}</p>
|
|
144
|
+
<div class="vms__caps-row">
|
|
145
|
+
<div
|
|
146
|
+
v-for="c in WALL_CAPABILITY_CONTROLS"
|
|
147
|
+
:key="c.key"
|
|
148
|
+
class="vms__cap"
|
|
149
|
+
:class="{ 'vms__cap--off': !capability(c.key).available }"
|
|
150
|
+
>
|
|
151
|
+
<p class="vms__cap-title">
|
|
152
|
+
{{ c.title }}
|
|
153
|
+
<span v-if="capability(c.key).tag" class="vms__cap-tag">
|
|
154
|
+
{{ capability(c.key).tag }}
|
|
155
|
+
</span>
|
|
156
|
+
</p>
|
|
157
|
+
<p class="vms__cap-detail">{{ capability(c.key).detail }}</p>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<script setup lang="ts">
|
|
165
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
166
|
+
|
|
167
|
+
import {
|
|
168
|
+
capabilityView,
|
|
169
|
+
DEFAULT_WALL_LAYOUT,
|
|
170
|
+
isDenseLayout,
|
|
171
|
+
wallCameras,
|
|
172
|
+
wallLayout,
|
|
173
|
+
wallTiles,
|
|
174
|
+
WALL_CAPABILITY_CONTROLS,
|
|
175
|
+
WALL_LAYOUTS,
|
|
176
|
+
type TWallCamera,
|
|
177
|
+
type TWallLayoutKey,
|
|
178
|
+
} from "../utils/camera-wall";
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* THE CAMERA WALL — one component, both applications.
|
|
182
|
+
*
|
|
183
|
+
* Security has had a wall for a while and Property Management has had none, so
|
|
184
|
+
* the same product answered the same question two different ways depending on
|
|
185
|
+
* which tab you were in. This is that wall, once, in the shared layer: the
|
|
186
|
+
* apps supply the site and nothing else.
|
|
187
|
+
*
|
|
188
|
+
* ## What it is, in one paragraph
|
|
189
|
+
*
|
|
190
|
+
* A dark video wall: a layout switcher, a camera browser beside the grid, tiles
|
|
191
|
+
* that carry a name and a live status, any tile expandable to the whole screen,
|
|
192
|
+
* and — under it — the camera controls that exist but are not switched on in
|
|
193
|
+
* this deployment yet, drawn and dimmed with the server's own explanation. It
|
|
194
|
+
* is the same shape as the monitoring surfaces in the iSecure365 mobile app,
|
|
195
|
+
* on purpose.
|
|
196
|
+
*
|
|
197
|
+
* ## Why the wall is dark in a light application
|
|
198
|
+
*
|
|
199
|
+
* A supervisor scanning tiles is judging PICTURES, and every bright surface
|
|
200
|
+
* beside a picture steals the contrast the picture needs. So the wall is dark
|
|
201
|
+
* in both themes, deliberately — the rest of the application is untouched, and
|
|
202
|
+
* a light-theme user getting a dark wall is the intended answer, not a missed
|
|
203
|
+
* token.
|
|
204
|
+
*
|
|
205
|
+
* ## What it deliberately does NOT do
|
|
206
|
+
*
|
|
207
|
+
* It does not decide who may see cameras. There is no camera-VIEWING permission
|
|
208
|
+
* in the product today — only the two `site-settings:manage-*-camera` strings,
|
|
209
|
+
* which govern SETUP — so each application gates its own menu entry with a
|
|
210
|
+
* permission that actually resolves, and this component draws what it is given.
|
|
211
|
+
* Inventing a gate here would silently deny people access they have today.
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
const props = defineProps<{ site: string }>();
|
|
215
|
+
|
|
216
|
+
/* ---------------------------------------------------------------- the data */
|
|
217
|
+
|
|
218
|
+
const { getSiteWall } = useSiteSettings();
|
|
219
|
+
|
|
220
|
+
const cameras = ref<TWallCamera[]>([]);
|
|
221
|
+
const loading = ref(true);
|
|
222
|
+
const failed = ref("");
|
|
223
|
+
|
|
224
|
+
async function load() {
|
|
225
|
+
loading.value = true;
|
|
226
|
+
failed.value = "";
|
|
227
|
+
try {
|
|
228
|
+
// The site's wall, from the endpoint built for it. `wallCameras` still
|
|
229
|
+
// filters on the way in — the server already returns `type: "ip"` only, and
|
|
230
|
+
// an ANPR unit must never reach a monitoring wall even if that ever changes.
|
|
231
|
+
const res = await getSiteWall(props.site);
|
|
232
|
+
cameras.value = wallCameras(res?.cameras);
|
|
233
|
+
} catch (error: any) {
|
|
234
|
+
// The raw message is often a stack trace and rarely something an operator
|
|
235
|
+
// can act on — but "you may not see this site" and "your connection
|
|
236
|
+
// dropped" are genuinely different problems with different next steps, and
|
|
237
|
+
// telling a refused caller to check their connection sends them off to
|
|
238
|
+
// debug their wifi. The server answers a site that is not the caller's the
|
|
239
|
+
// same way as a site that does not exist, on purpose, so this wording does
|
|
240
|
+
// not distinguish them either.
|
|
241
|
+
cameras.value = [];
|
|
242
|
+
const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
|
|
243
|
+
failed.value =
|
|
244
|
+
status === 401 || status === 403 || status === 404
|
|
245
|
+
? "You do not have access to this site's cameras."
|
|
246
|
+
: "The list of cameras did not load. Check your connection and try again.";
|
|
247
|
+
} finally {
|
|
248
|
+
loading.value = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
onMounted(load);
|
|
253
|
+
watch(() => props.site, () => {
|
|
254
|
+
selectedIds.value = [];
|
|
255
|
+
load();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/* -------------------------------------------------------------- the layout */
|
|
259
|
+
|
|
260
|
+
const layoutKey = ref<TWallLayoutKey>(DEFAULT_WALL_LAYOUT);
|
|
261
|
+
const layout = computed(() => wallLayout(layoutKey.value));
|
|
262
|
+
const rows = computed(() => Math.ceil(layout.value.tiles / layout.value.columns));
|
|
263
|
+
const dense = computed(() => isDenseLayout(layoutKey.value));
|
|
264
|
+
|
|
265
|
+
const selectedIds = ref<string[]>([]);
|
|
266
|
+
const showPicker = ref(false);
|
|
267
|
+
|
|
268
|
+
const tiles = computed(() => wallTiles(cameras.value, selectedIds.value, layoutKey.value));
|
|
269
|
+
|
|
270
|
+
function isPicked(id: string) {
|
|
271
|
+
return selectedIds.value.includes(id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function togglePick(id: string) {
|
|
275
|
+
if (isPicked(id)) selectedIds.value = selectedIds.value.filter((x) => x !== id);
|
|
276
|
+
else if (selectedIds.value.length < layout.value.tiles)
|
|
277
|
+
selectedIds.value = [...selectedIds.value, id];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* --------------------------------------------------------- what is focused */
|
|
281
|
+
|
|
282
|
+
const focusedId = ref("");
|
|
283
|
+
const focused = computed(
|
|
284
|
+
() => tiles.value.find((c) => c && c._id === focusedId.value) || tiles.value.find(Boolean) || null
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
function capability(key: string) {
|
|
288
|
+
return capabilityView(focused.value, key);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ------------------------------------------------------- browser condition */
|
|
292
|
+
|
|
293
|
+
const online = ref(true);
|
|
294
|
+
const setOnline = () => {
|
|
295
|
+
online.value = typeof navigator === "undefined" ? true : navigator.onLine !== false;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
onMounted(() => {
|
|
299
|
+
setOnline();
|
|
300
|
+
window.addEventListener("online", setOnline);
|
|
301
|
+
window.addEventListener("offline", setOnline);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
onBeforeUnmount(() => {
|
|
305
|
+
window.removeEventListener("online", setOnline);
|
|
306
|
+
window.removeEventListener("offline", setOnline);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
/* ------------------------------------------------------------- fullscreen */
|
|
310
|
+
|
|
311
|
+
const root = ref<HTMLElement | null>(null);
|
|
312
|
+
|
|
313
|
+
async function toggleFullscreen() {
|
|
314
|
+
const el = root.value;
|
|
315
|
+
if (!el) return;
|
|
316
|
+
try {
|
|
317
|
+
if (document.fullscreenElement) await document.exitFullscreen();
|
|
318
|
+
else await el.requestFullscreen();
|
|
319
|
+
} catch {
|
|
320
|
+
// A browser that refuses fullscreen is not worth interrupting anyone over.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
</script>
|
|
324
|
+
|
|
325
|
+
<style scoped>
|
|
326
|
+
/*
|
|
327
|
+
The near-black ramp, the gutter and the radius are the same values the mobile
|
|
328
|
+
monitoring build uses, so the two surfaces read as one product. They are
|
|
329
|
+
literals rather than theme tokens because this surface is dark in BOTH themes
|
|
330
|
+
and therefore cannot read one.
|
|
331
|
+
*/
|
|
332
|
+
.vms {
|
|
333
|
+
--vms-screen: #07090c;
|
|
334
|
+
--vms-tile: #101418;
|
|
335
|
+
--vms-chrome: #161b22;
|
|
336
|
+
--vms-chrome-active: #232b36;
|
|
337
|
+
--vms-line: #232b36;
|
|
338
|
+
--vms-scrim: rgba(7, 9, 12, 0.62);
|
|
339
|
+
--vms-scrim-solid: rgba(7, 9, 12, 0.82);
|
|
340
|
+
--vms-text: #e8ecf1;
|
|
341
|
+
--vms-text-dim: #94a0b0;
|
|
342
|
+
--vms-text-faint: #5c6675;
|
|
343
|
+
--vms-accent: #5b9be0;
|
|
344
|
+
--vms-radius: 3px;
|
|
345
|
+
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-direction: column;
|
|
348
|
+
background: var(--vms-screen);
|
|
349
|
+
border-radius: var(--vms-radius);
|
|
350
|
+
overflow: hidden;
|
|
351
|
+
color: var(--vms-text);
|
|
352
|
+
font-family: inherit;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.vms__bar {
|
|
356
|
+
display: flex;
|
|
357
|
+
align-items: center;
|
|
358
|
+
gap: 6px;
|
|
359
|
+
padding: 6px;
|
|
360
|
+
background: var(--vms-chrome);
|
|
361
|
+
border-bottom: 1px solid var(--vms-line);
|
|
362
|
+
flex-wrap: wrap;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.vms__spacer {
|
|
366
|
+
flex: 1 1 auto;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.vms__layouts {
|
|
370
|
+
display: flex;
|
|
371
|
+
gap: 2px;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.vms__tool {
|
|
375
|
+
display: inline-flex;
|
|
376
|
+
align-items: center;
|
|
377
|
+
gap: 6px;
|
|
378
|
+
min-height: 28px;
|
|
379
|
+
padding: 4px 10px;
|
|
380
|
+
border: 1px solid var(--vms-line);
|
|
381
|
+
border-radius: var(--vms-radius);
|
|
382
|
+
background: transparent;
|
|
383
|
+
color: var(--vms-text);
|
|
384
|
+
font-size: 12px;
|
|
385
|
+
line-height: 16px;
|
|
386
|
+
cursor: pointer;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.vms__tool:hover:not(:disabled) {
|
|
390
|
+
background: var(--vms-chrome-active);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.vms__tool:disabled {
|
|
394
|
+
/* Legible, not ghosted: see the note on the capability panel below. */
|
|
395
|
+
opacity: 0.55;
|
|
396
|
+
cursor: default;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.vms__tool--icon {
|
|
400
|
+
padding: 4px 8px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/*
|
|
404
|
+
A SELECTED TOOL IS NOT PAINTED IN THE BRAND RED. On this screen red already
|
|
405
|
+
means "this camera is down"; a permanent red block in the toolbar of a
|
|
406
|
+
monitoring wall competes with the alarms the wall exists to show. Selection is
|
|
407
|
+
a raised neutral fill and a 2 px accent underline instead.
|
|
408
|
+
*/
|
|
409
|
+
.vms__tool--on {
|
|
410
|
+
background: var(--vms-chrome-active);
|
|
411
|
+
box-shadow: inset 0 -2px 0 var(--vms-accent);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.vms__tool:focus-visible {
|
|
415
|
+
outline: 2px solid var(--vms-accent);
|
|
416
|
+
outline-offset: 1px;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.vms__count {
|
|
420
|
+
color: var(--vms-text-dim);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.vms__body {
|
|
424
|
+
display: flex;
|
|
425
|
+
min-height: 0;
|
|
426
|
+
height: 68vh;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.vms__picker {
|
|
430
|
+
width: 220px;
|
|
431
|
+
flex: none;
|
|
432
|
+
overflow-y: auto;
|
|
433
|
+
padding: 8px;
|
|
434
|
+
background: var(--vms-chrome);
|
|
435
|
+
border-right: 1px solid var(--vms-line);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.vms__picker-head {
|
|
439
|
+
display: flex;
|
|
440
|
+
align-items: center;
|
|
441
|
+
justify-content: space-between;
|
|
442
|
+
margin: 0 0 6px;
|
|
443
|
+
font-size: 11px;
|
|
444
|
+
letter-spacing: 0.06em;
|
|
445
|
+
text-transform: uppercase;
|
|
446
|
+
color: var(--vms-text-dim);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.vms__picker-empty,
|
|
450
|
+
.vms__picker-note {
|
|
451
|
+
margin: 6px 0 0;
|
|
452
|
+
font-size: 11px;
|
|
453
|
+
line-height: 15px;
|
|
454
|
+
color: var(--vms-text-dim);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.vms__link {
|
|
458
|
+
background: none;
|
|
459
|
+
border: none;
|
|
460
|
+
color: var(--vms-accent);
|
|
461
|
+
font-size: 11px;
|
|
462
|
+
cursor: pointer;
|
|
463
|
+
padding: 0;
|
|
464
|
+
text-transform: none;
|
|
465
|
+
letter-spacing: 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.vms__pick {
|
|
469
|
+
display: flex;
|
|
470
|
+
align-items: center;
|
|
471
|
+
gap: 8px;
|
|
472
|
+
/* Dense chrome, but a 40 px row so a checkbox is not a lottery to hit. */
|
|
473
|
+
min-height: 40px;
|
|
474
|
+
padding: 0 4px;
|
|
475
|
+
font-size: 12px;
|
|
476
|
+
cursor: pointer;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.vms__pick-name {
|
|
480
|
+
flex: 1 1 auto;
|
|
481
|
+
overflow: hidden;
|
|
482
|
+
text-overflow: ellipsis;
|
|
483
|
+
white-space: nowrap;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.vms__pick-dot {
|
|
487
|
+
width: 7px;
|
|
488
|
+
height: 7px;
|
|
489
|
+
border-radius: 50%;
|
|
490
|
+
flex: none;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.vms__pick-dot--on {
|
|
494
|
+
background: #4caf50;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.vms__pick-dot--off {
|
|
498
|
+
background: var(--vms-text-faint);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.vms__stage {
|
|
502
|
+
flex: 1 1 auto;
|
|
503
|
+
min-width: 0;
|
|
504
|
+
display: flex;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.vms__grid {
|
|
508
|
+
flex: 1 1 auto;
|
|
509
|
+
display: grid;
|
|
510
|
+
/* 2 px: on a nine-tile wall the usual 8 px rhythm costs real picture. */
|
|
511
|
+
gap: 2px;
|
|
512
|
+
padding: 2px;
|
|
513
|
+
min-width: 0;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.vms__state {
|
|
517
|
+
flex: 1 1 auto;
|
|
518
|
+
display: flex;
|
|
519
|
+
flex-direction: column;
|
|
520
|
+
align-items: center;
|
|
521
|
+
justify-content: center;
|
|
522
|
+
gap: 8px;
|
|
523
|
+
padding: 24px;
|
|
524
|
+
text-align: center;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.vms__state-head {
|
|
528
|
+
margin: 0;
|
|
529
|
+
font-size: 14px;
|
|
530
|
+
font-weight: 600;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.vms__state-body {
|
|
534
|
+
margin: 0;
|
|
535
|
+
max-width: 46ch;
|
|
536
|
+
font-size: 12px;
|
|
537
|
+
line-height: 18px;
|
|
538
|
+
color: var(--vms-text-dim);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* ------------------------------------------------------------ capabilities */
|
|
542
|
+
|
|
543
|
+
.vms__caps {
|
|
544
|
+
padding: 10px;
|
|
545
|
+
background: var(--vms-chrome);
|
|
546
|
+
border-top: 1px solid var(--vms-line);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.vms__caps-head {
|
|
550
|
+
margin: 0 0 8px;
|
|
551
|
+
font-size: 11px;
|
|
552
|
+
letter-spacing: 0.06em;
|
|
553
|
+
text-transform: uppercase;
|
|
554
|
+
color: var(--vms-text-dim);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.vms__caps-row {
|
|
558
|
+
display: grid;
|
|
559
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
560
|
+
gap: 8px;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/*
|
|
564
|
+
A CONTROL THAT IS OFF IS OUTLINED, NOT FADED.
|
|
565
|
+
|
|
566
|
+
A faded fill is a live control with the lights down; an outline is the shape
|
|
567
|
+
of a control that is not there yet. The accent rail on the left says the same
|
|
568
|
+
thing again: a defined slot, waiting to be filled — which is literally true.
|
|
569
|
+
*/
|
|
570
|
+
.vms__cap {
|
|
571
|
+
padding: 8px 10px;
|
|
572
|
+
border: 1px solid var(--vms-line);
|
|
573
|
+
border-left: 2px solid var(--vms-accent);
|
|
574
|
+
border-radius: var(--vms-radius);
|
|
575
|
+
background: transparent;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.vms__cap-title {
|
|
579
|
+
margin: 0 0 2px;
|
|
580
|
+
display: flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
gap: 8px;
|
|
583
|
+
font-size: 12px;
|
|
584
|
+
font-weight: 600;
|
|
585
|
+
/* The TITLE stays legible even when the control is off: somebody who cannot
|
|
586
|
+
read "Recorded footage" cannot ask for it. */
|
|
587
|
+
color: var(--vms-text);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.vms__cap-tag {
|
|
591
|
+
font-size: 11px;
|
|
592
|
+
font-weight: 400;
|
|
593
|
+
padding: 1px 6px;
|
|
594
|
+
border-radius: var(--vms-radius);
|
|
595
|
+
background: var(--vms-chrome-active);
|
|
596
|
+
color: var(--vms-text-dim);
|
|
597
|
+
white-space: nowrap;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/*
|
|
601
|
+
0.55, not the Material 0.38. Every switched-off control here carries a
|
|
602
|
+
SENTENCE, and 0.38 measures about 3.3:1 against this surface — under the 4.5:1
|
|
603
|
+
a sentence needs. 0.55 measures about 5.2:1. A reason nobody can read is the
|
|
604
|
+
same as no reason at all.
|
|
605
|
+
*/
|
|
606
|
+
.vms__cap--off .vms__cap-detail {
|
|
607
|
+
opacity: 0.55;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.vms__cap-detail {
|
|
611
|
+
margin: 0;
|
|
612
|
+
font-size: 12px;
|
|
613
|
+
line-height: 17px;
|
|
614
|
+
color: var(--vms-text-dim);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.vms:fullscreen .vms__body {
|
|
618
|
+
height: auto;
|
|
619
|
+
flex: 1 1 auto;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.vms:fullscreen {
|
|
623
|
+
height: 100vh;
|
|
624
|
+
}
|
|
625
|
+
</style>
|