@7365admin1/layer-common 3.2.0 → 3.2.1-staging.59

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.
Files changed (33) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/components/CameraWall.vue +625 -0
  3. package/components/CameraWallTile.vue +333 -0
  4. package/components/EntryPassInformation.vue +4 -4
  5. package/components/HidAccessLogDashboard.vue +53 -8
  6. package/components/HidIntercomManagement.vue +842 -201
  7. package/components/HidQrCodeConfiguration.vue +907 -95
  8. package/components/HidServiceSettingsPanel.vue +20 -1
  9. package/components/HidUserEnrollment.vue +96 -2
  10. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  11. package/components/OnlineFormConfigurationForm.vue +620 -224
  12. package/components/OnlineFormFill.vue +36 -8
  13. package/components/OnlineFormsConfiguration.vue +6 -2
  14. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  15. package/components/QrTemplate/PrintDialog.vue +209 -196
  16. package/components/SiteSettings.vue +4 -2
  17. package/components/VisitorForm.vue +201 -78
  18. package/components/VisitorManagement.vue +0 -1
  19. package/composables/useHidAmico.ts +127 -0
  20. package/composables/useHidNavigation.ts +0 -7
  21. package/composables/useOnlineFormPages.ts +2 -0
  22. package/composables/useSipWebPhone.ts +332 -0
  23. package/composables/useSiteSettings.ts +28 -0
  24. package/composables/useWebUsb.ts +127 -37
  25. package/package.json +2 -1
  26. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  27. package/plugins/secure-member.client.ts +21 -56
  28. package/types/site.d.ts +71 -0
  29. package/utils/camera-wall.test.ts +149 -0
  30. package/utils/camera-wall.ts +299 -0
  31. package/components/HidIdentityMapping.vue +0 -975
  32. package/middleware/member.ts +0 -4
  33. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -12,75 +12,40 @@ export default defineNuxtPlugin(() => {
12
12
 
13
13
  const { userAppRole, id, orgNature } = useLocalSetup();
14
14
 
15
- router.afterEach(async (to) => {
15
+ router.beforeEach(async (to) => {
16
16
  const isMember = to.meta?.memberOnly;
17
17
 
18
18
  if (!isMember) return;
19
19
 
20
20
  const APP = useRuntimeConfig().public.APP;
21
- const org = computed(
22
- () =>
23
- (to.params.org as string) || (to.params.organization as string) || ""
24
- );
21
+ const org =
22
+ (to.params.org as string) || (to.params.organization as string) || "";
25
23
 
26
- console.log('[secure-member-plugin', 'org', org.value)
27
-
28
- if (!hexSchema.safeParse(org.value).success) {
29
- return router.replace({ name: "require-organization-membership" });
24
+ if (!hexSchema.safeParse(org).success) {
25
+ return { name: "require-organization-membership" };
30
26
  }
31
27
 
32
- const userId = computed(() => useCookie("user").value ?? "");
33
-
34
- const { data: userMemberData, error: userMemberError } =
35
- await useLazyAsyncData(
36
- "plugin-get-member-by-id-" + userId.value + "-" + APP + "-" + org.value,
37
- () => getByUserType(userId.value, APP, org.value),
38
- { watch: [userId] }
39
- );
40
-
41
- watchEffect(() => {
42
- if (userMemberError.value) {
43
- console.log('running-secure-member-redirect-plugin')
44
- navigateTo(
45
- {
46
- name: "index",
47
- },
48
- { replace: true }
49
- );
50
- }
51
- });
52
-
53
- const roleId = ref("roleId");
28
+ const userId = useCookie("user").value ?? "";
29
+ if (!userId) return { name: "index" };
54
30
 
55
- watchEffect(() => {
56
- if (userMemberData.value) {
57
- id.value = userMemberData.value.org ?? "";
58
- roleId.value = userMemberData.value.role ?? "roleId";
59
- }
60
- });
31
+ try {
32
+ const userMemberData = await getByUserType(userId, APP, org);
33
+ id.value = userMemberData.org ?? "";
61
34
 
62
- const { data: getOrgByIdReq } = await useLazyAsyncData(
63
- "plugin-get-org-by-id-" + org.value,
64
- () => getById(org.value),
65
- { watch: [org] }
66
- );
35
+ const [orgResult, roleResult] = await Promise.allSettled([
36
+ getById(org),
37
+ userMemberData.role ? getRoleById(userMemberData.role) : null,
38
+ ]);
67
39
 
68
- watchEffect(() => {
69
- if (getOrgByIdReq.value) {
70
- orgNature.value = getOrgByIdReq.value.nature ?? "";
40
+ if (orgResult.status === "fulfilled" && orgResult.value) {
41
+ orgNature.value = orgResult.value.nature ?? "";
71
42
  }
72
- });
73
43
 
74
- const { data: getRoleByIdReq } = await useLazyAsyncData(
75
- "plugin-get-role-by-id-" + roleId.value,
76
- () => getRoleById(roleId.value),
77
- { watch: [roleId] }
78
- );
79
-
80
- watchEffect(() => {
81
- if (getRoleByIdReq.value) {
82
- userAppRole.value = getRoleByIdReq.value;
44
+ if (roleResult.status === "fulfilled" && roleResult.value) {
45
+ userAppRole.value = roleResult.value;
83
46
  }
84
- });
47
+ } catch (error) {
48
+ return { name: "index" };
49
+ }
85
50
  });
86
51
  });
package/types/site.d.ts CHANGED
@@ -1,5 +1,76 @@
1
1
  declare type TSiteCreate = Pick<TSite, "name" | "description" | "orgId">;
2
2
 
3
+ declare type THidQrCodeFormat = "0" | "1" | "2";
4
+
5
+ declare type THidPermissionCategory =
6
+ | "resident"
7
+ | "property_management"
8
+ | "service_provider";
9
+
10
+ declare type THidPermissionAssignment = {
11
+ subjectId: string;
12
+ category: THidPermissionCategory;
13
+ name: string;
14
+ subtitle?: string;
15
+ intercom: boolean;
16
+ readerId?: string;
17
+ location?: string;
18
+ locationKey?: string;
19
+ };
20
+
21
+ declare type THidSitePermissions = {
22
+ orgId: string;
23
+ site: string;
24
+ readerId: string;
25
+ location: string;
26
+ assignments: THidPermissionAssignment[];
27
+ counts: {
28
+ resident: number;
29
+ propertyManagement: number;
30
+ serviceProvider: number;
31
+ intercom: number;
32
+ };
33
+ };
34
+
35
+ declare type THidPermissionCandidate = THidPermissionAssignment & {
36
+ selected: boolean;
37
+ };
38
+
39
+ declare type THidQrCodePassConfig = {
40
+ enabled: boolean;
41
+ onlineMode?: boolean;
42
+ readerId: string;
43
+ qrFormat: THidQrCodeFormat;
44
+ identificationMethods?: {
45
+ facial: boolean;
46
+ card: boolean;
47
+ qrCode: boolean;
48
+ idPassword: boolean;
49
+ pin: boolean;
50
+ bluetooth: boolean;
51
+ };
52
+ printer: {
53
+ vendorId: string;
54
+ productId: string;
55
+ };
56
+ template: {
57
+ header: string;
58
+ subtext: string;
59
+ };
60
+ validityMinutes: number | null;
61
+ updatedAt?: string;
62
+ };
63
+
64
+ declare type TSiteMetadata = {
65
+ block?: number;
66
+ guardPosts?: number;
67
+ gracePeriod?: number;
68
+ incidentCounter?: number;
69
+ incidentLogo?: string;
70
+ services?: Record<string, unknown>[];
71
+ hidQrCodePass?: THidQrCodePassConfig;
72
+ };
73
+
3
74
 
4
75
  declare type TSite = {
5
76
  _id?: string;
@@ -0,0 +1,149 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ capabilityView,
6
+ isDenseLayout,
7
+ tileView,
8
+ wallCameras,
9
+ wallLayout,
10
+ wallTiles,
11
+ } from "./camera-wall.ts";
12
+
13
+ const ip = (over: Record<string, unknown> = {}) => ({
14
+ _id: "c1",
15
+ name: "GYM AREA",
16
+ type: "ip",
17
+ status: "active",
18
+ host: "https://example.invalid/4",
19
+ ...over,
20
+ });
21
+
22
+ /* ---------------------------------------------------------------- rule 1 */
23
+
24
+ test("an ANPR camera can never reach the wall", () => {
25
+ const items = [ip(), { _id: "c2", type: "anpr", host: "http://example.invalid:1234" }];
26
+ const kept = wallCameras(items);
27
+ assert.equal(kept.length, 1);
28
+ assert.equal(kept[0]._id, "c1");
29
+ });
30
+
31
+ test("a junk response is an empty wall, not a crash", () => {
32
+ assert.deepEqual(wallCameras(undefined), []);
33
+ assert.deepEqual(wallCameras([null, 3, "x"]), []);
34
+ });
35
+
36
+ /* ---------------------------------------------------------- layout + order */
37
+
38
+ test("an unknown layout key falls back to four tiles", () => {
39
+ assert.equal(wallLayout("7x7").tiles, 4);
40
+ assert.equal(isDenseLayout("3x3"), true);
41
+ assert.equal(isDenseLayout("2x2"), false);
42
+ });
43
+
44
+ test("no selection means the site's own order, not a blank wall", () => {
45
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
46
+ const tiles = wallTiles(cams, [], "2x2");
47
+ assert.equal(tiles.length, 4);
48
+ assert.equal(tiles[0]._id, "a");
49
+ assert.equal(tiles[1]._id, "b");
50
+ assert.equal(tiles[2], null);
51
+ });
52
+
53
+ test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
54
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
55
+ const tiles = wallTiles(cams, ["b", "gone", "a"], "1x1");
56
+ assert.equal(tiles.length, 1);
57
+ assert.equal(tiles[0]._id, "b");
58
+ });
59
+
60
+ /* ---------------------------------------------------------------- rule 2/3 */
61
+
62
+ test("no descriptor at all counts as NOT available", () => {
63
+ const v = capabilityView(ip(), "ptz");
64
+ assert.equal(v.available, false);
65
+ assert.equal(v.tag, "Not checked yet");
66
+ assert.match(v.detail, /has not been asked/);
67
+ });
68
+
69
+ test("the server's own sentence is shown, never rewritten", () => {
70
+ const cam = ip({
71
+ capabilities: {
72
+ playback: {
73
+ state: "unsupported",
74
+ detail: "No direct connection to this camera's recorder is configured on this server.",
75
+ },
76
+ },
77
+ });
78
+ const v = capabilityView(cam, "playback");
79
+ assert.equal(v.tag, "Not set up yet");
80
+ assert.equal(
81
+ v.detail,
82
+ "No direct connection to this camera's recorder is configured on this server."
83
+ );
84
+ });
85
+
86
+ test("a supported capability wears no tag", () => {
87
+ const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
88
+ assert.equal(capabilityView(cam, "playback").available, true);
89
+ assert.equal(capabilityView(cam, "playback").tag, null);
90
+ });
91
+
92
+ /* ------------------------------------------------------------------ rule 4 */
93
+
94
+ test("a page that has not loaded is Connecting, never Live", () => {
95
+ assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
96
+ assert.equal(tileView(ip(), { player: "ready" }).label, "Live");
97
+ assert.equal(tileView(ip(), { player: "timeout" }).state, "no-signal");
98
+ });
99
+
100
+ test("the browser being offline outranks everything and says so differently", () => {
101
+ assert.equal(tileView(ip(), { online: false, player: "ready" }).label, "No connection");
102
+ assert.equal(tileView(ip(), { player: "timeout" }).label, "No signal");
103
+ });
104
+
105
+ test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
106
+ assert.match(tileView(ip({ status: "inactive" })).detail, /not active/);
107
+ assert.match(tileView(ip({ host: "" })).detail, /no address/);
108
+ assert.match(tileView(null).detail, /No camera in this position/);
109
+ assert.equal(tileView(ip({ host: "" })).showPlayer, false);
110
+ });
111
+
112
+ test("a server that says live video is unsupported is believed", () => {
113
+ const cam = ip({
114
+ capabilities: {
115
+ liveVideo: { state: "unsupported", detail: "This camera is not active." },
116
+ },
117
+ });
118
+ const v = tileView(cam, { player: "ready" });
119
+ assert.equal(v.showPlayer, false);
120
+ assert.equal(v.detail, "This camera is not active.");
121
+ });
122
+
123
+ /* ------------------------------------------- the server's reason wins */
124
+
125
+ test("the server's own refusal is shown verbatim, not replaced with a guess", () => {
126
+ // The one that matters: the browser cannot know this, and until the API host
127
+ // is configured it is the true answer for every camera in the estate.
128
+ const noRecorder = "No recorder is configured for this camera's relay.";
129
+ const view = tileView(ip({ unavailableReason: noRecorder }));
130
+ assert.equal(view.detail, noRecorder);
131
+ assert.equal(view.state, "unavailable");
132
+ assert.equal(view.showPlayer, false);
133
+ });
134
+
135
+ test("the server's reason beats the reasons the wall could work out itself", () => {
136
+ const reason = "This camera has no address configured.";
137
+ assert.equal(tileView(ip({ status: "inactive", unavailableReason: reason })).detail, reason);
138
+ });
139
+
140
+ test("an absent or blank reason changes nothing", () => {
141
+ assert.equal(tileView(ip({ unavailableReason: null })).state, "connecting");
142
+ assert.equal(tileView(ip({ unavailableReason: " " })).state, "connecting");
143
+ assert.equal(tileView(ip()).state, "connecting");
144
+ });
145
+
146
+ test("being offline still explains every tile at once, ahead of the server's reason", () => {
147
+ const view = tileView(ip({ unavailableReason: "Something server-side." }), { online: false });
148
+ assert.equal(view.state, "offline");
149
+ });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * THE CAMERA WALL'S RULES, WITH NO MARKUP IN THEM.
3
+ *
4
+ * `CameraWall.vue` draws the wall; this file decides what it is allowed to draw.
5
+ * Keeping the two apart is not tidiness — every rule below is one somebody can
6
+ * get wrong in a way nobody notices until a supervisor is looking at the wrong
7
+ * picture, so each one is stated once, here, and pinned by `camera-wall.test.ts`.
8
+ *
9
+ * ## The four rules
10
+ *
11
+ * 1. **Only `type: "ip"` cameras go on a wall.** An ANPR unit belongs to visitor
12
+ * and vehicle management: it is pointed at a number plate, it is wired to a
13
+ * barrier, and its record carries a real device address and a real credential.
14
+ * Putting one on a monitoring wall is a defect, not a feature. The request
15
+ * already asks the server for `type=ip`; `wallCameras()` filters again on the
16
+ * way in, because "the caller passed the right query" is an assumption and
17
+ * this is the rule.
18
+ * 2. **A capability we have not been told about is NOT available.** An absent or
19
+ * malformed descriptor reads as `unknown`, and `unknown` counts as no — so a
20
+ * browser talking to an older API cannot draw a control that would do nothing.
21
+ * 3. **A control that is off is drawn, dimmed, and says why** — never hidden. The
22
+ * sentence shown is the SERVER'S own sentence, never rewritten here, so a
23
+ * camera cannot explain itself two different ways on two screens.
24
+ * 4. **"Live" means a picture is arriving**, not "we asked for one". The tile
25
+ * says `Connecting…` until the player page is actually up, and `No signal`
26
+ * when it never comes.
27
+ *
28
+ * These match the iSecure365 mobile monitoring build one for one, deliberately:
29
+ * the same product should not answer the same question differently on a phone
30
+ * and on a laptop.
31
+ */
32
+
33
+ /** One capability's answer, as the API returns it (core's `CameraCapabilityEntry`). */
34
+ export type TWallCapability = {
35
+ state?: "supported" | "unsupported" | "unknown";
36
+ transport?: string | null;
37
+ reason?: string | null;
38
+ /** The server's own sentence. Displayed verbatim; never rewritten here. */
39
+ detail?: string | null;
40
+ };
41
+
42
+ /** A camera as the wall needs it. Extra fields on the record are ignored. */
43
+ export type TWallCamera = {
44
+ _id: string;
45
+ name?: string;
46
+ /**
47
+ * The stored address. For a `type: "ip"` camera this is a complete, directly
48
+ * renderable live-video page — it is the picture, not a fragment to resolve.
49
+ */
50
+ host?: string;
51
+ type?: string;
52
+ status?: string;
53
+ capabilities?: Record<string, TWallCapability>;
54
+ /**
55
+ * The server's own sentence for why this camera cannot show a picture, or
56
+ * `null`/absent when it can. Answered from the record before anything is
57
+ * requested, so it costs no device traffic.
58
+ *
59
+ * **Preferred over anything worked out here.** The server knows things the
60
+ * browser cannot — that no recorder is configured for this camera's relay, for
61
+ * instance, which is the true answer for every camera until the API host is
62
+ * configured. Replacing it with our own wording hides that.
63
+ */
64
+ unavailableReason?: string | null;
65
+ };
66
+
67
+ /* -------------------------------------------------------------------------- */
68
+ /* Layouts */
69
+ /* -------------------------------------------------------------------------- */
70
+
71
+ /**
72
+ * The three wall sizes.
73
+ *
74
+ * Square only, and only three of them. A VMS layout picker exists so a
75
+ * supervisor can change what they are watching in one click; a list of eleven
76
+ * arrangements is a settings screen wearing a toolbar's clothes.
77
+ */
78
+ export const WALL_LAYOUTS = [
79
+ { key: "1x1", columns: 1, tiles: 1, label: "Single" },
80
+ { key: "2x2", columns: 2, tiles: 4, label: "Four" },
81
+ { key: "3x3", columns: 3, tiles: 9, label: "Nine" },
82
+ ] as const;
83
+
84
+ export type TWallLayoutKey = (typeof WALL_LAYOUTS)[number]["key"];
85
+
86
+ /** Four tiles: enough to be a wall, small enough to still be readable. */
87
+ export const DEFAULT_WALL_LAYOUT: TWallLayoutKey = "2x2";
88
+
89
+ export function wallLayout(key: string | undefined) {
90
+ return WALL_LAYOUTS.find((l) => l.key === key) ?? WALL_LAYOUTS[1];
91
+ }
92
+
93
+ /**
94
+ * At three columns a tile is small enough that the chrome starts eating the
95
+ * picture, so the tile drops its status WORD and keeps the dot.
96
+ */
97
+ export function isDenseLayout(key: string | undefined) {
98
+ return wallLayout(key).columns >= 3;
99
+ }
100
+
101
+ /* -------------------------------------------------------------------------- */
102
+ /* Which cameras, and in what order */
103
+ /* -------------------------------------------------------------------------- */
104
+
105
+ /** Rule 1. Only `type: "ip"`. See the note at the top of this file. */
106
+ export function wallCameras(items: unknown): TWallCamera[] {
107
+ if (!Array.isArray(items)) return [];
108
+ return items.filter(
109
+ (c): c is TWallCamera => !!c && typeof c === "object" && (c as TWallCamera).type === "ip"
110
+ );
111
+ }
112
+
113
+ /**
114
+ * The tiles, in order, padded with `null` to the layout's size.
115
+ *
116
+ * An empty selection is **not** "no cameras" — it is "nobody has chosen yet",
117
+ * and the wall falls back to the site's own order. A supervisor who clears the
118
+ * picker gets their site back, not a black screen.
119
+ */
120
+ export function wallTiles(
121
+ cameras: TWallCamera[],
122
+ selectedIds: string[] | undefined,
123
+ layoutKey: string | undefined
124
+ ): Array<TWallCamera | null> {
125
+ const { tiles } = wallLayout(layoutKey);
126
+ const byId = new Map(cameras.map((c) => [c._id, c]));
127
+ const chosen =
128
+ selectedIds && selectedIds.length
129
+ ? selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c)
130
+ : cameras;
131
+
132
+ const out: Array<TWallCamera | null> = chosen.slice(0, tiles);
133
+ while (out.length < tiles) out.push(null);
134
+ return out;
135
+ }
136
+
137
+ /* -------------------------------------------------------------------------- */
138
+ /* Capabilities */
139
+ /* -------------------------------------------------------------------------- */
140
+
141
+ /**
142
+ * The two words a switched-off control wears.
143
+ *
144
+ * Lifted unchanged from the mobile build, and the "yet" is the whole point: it
145
+ * is the difference between a feature that is broken and a feature that is
146
+ * waiting on something somebody has to switch on.
147
+ */
148
+ export const CAPABILITY_TAGS = {
149
+ unsupported: "Not set up yet",
150
+ unknown: "Not checked yet",
151
+ } as const;
152
+
153
+ /**
154
+ * What a client with no descriptor at all should say. This is the server's own
155
+ * `device-not-probed` sentence, reused rather than reinvented, so an older API
156
+ * that sends no descriptor still explains itself in the product's own words.
157
+ */
158
+ const NOT_PROBED = "This camera has not been asked what it can do yet.";
159
+
160
+ /** The four capabilities the wall draws a control for, and their plain names. */
161
+ export const WALL_CAPABILITY_CONTROLS = [
162
+ { key: "playback", title: "Recorded footage" },
163
+ { key: "events", title: "Events and alarms" },
164
+ { key: "ptz", title: "Move camera" },
165
+ { key: "presets", title: "Stored positions" },
166
+ ] as const;
167
+
168
+ export type TCapabilityView = {
169
+ state: "supported" | "unsupported" | "unknown";
170
+ available: boolean;
171
+ /** `null` when the capability works — a working control needs no tag. */
172
+ tag: string | null;
173
+ /** The server's sentence, or the not-probed one. Never empty. */
174
+ detail: string;
175
+ };
176
+
177
+ /** Rule 2 and rule 3, in one place. */
178
+ export function capabilityView(
179
+ camera: TWallCamera | null | undefined,
180
+ key: string
181
+ ): TCapabilityView {
182
+ const entry = camera?.capabilities?.[key];
183
+ const state =
184
+ entry?.state === "supported" || entry?.state === "unsupported"
185
+ ? entry.state
186
+ : "unknown";
187
+
188
+ return {
189
+ state,
190
+ available: state === "supported",
191
+ tag: state === "supported" ? null : CAPABILITY_TAGS[state],
192
+ detail: (entry?.detail || "").trim() || NOT_PROBED,
193
+ };
194
+ }
195
+
196
+ /* -------------------------------------------------------------------------- */
197
+ /* Tile state */
198
+ /* -------------------------------------------------------------------------- */
199
+
200
+ /**
201
+ * How long a player page may take to load before the tile stops claiming it is
202
+ * coming. Deliberately generous — lifted from the mobile build, where the
203
+ * argument is that reporting a working video service as dead is the worse of
204
+ * the two mistakes.
205
+ */
206
+ export const LIVE_CONNECT_TIMEOUT_MS = 15_000;
207
+
208
+ export type TTileState =
209
+ | "live"
210
+ | "connecting"
211
+ | "no-signal"
212
+ | "offline"
213
+ | "unavailable";
214
+
215
+ /**
216
+ * The four words a tile may say about itself.
217
+ *
218
+ * `offline` and `no-signal` are two different facts and used to share one word:
219
+ * "Offline" on every tile at once means the BROWSER lost the network; "No
220
+ * signal" on one tile means that camera is not answering. A supervisor needs to
221
+ * know which of those they are looking at.
222
+ */
223
+ export const TILE_LABELS = {
224
+ live: "Live",
225
+ connecting: "Connecting…",
226
+ "no-signal": "No signal",
227
+ offline: "No connection",
228
+ unavailable: "Not set up yet",
229
+ } as const;
230
+
231
+ /** What the browser knows about the embedded player page. */
232
+ export type TPlayerState = "loading" | "ready" | "timeout";
233
+
234
+ export type TTileView = {
235
+ state: TTileState;
236
+ label: string;
237
+ /** A sentence for the states that need explaining; `null` when live. */
238
+ detail: string | null;
239
+ /** Whether the player page may be embedded at all. */
240
+ showPlayer: boolean;
241
+ };
242
+
243
+ /**
244
+ * Rule 4, plus every reason a tile may have nothing to show.
245
+ *
246
+ * Order matters: the browser being offline beats everything (it explains every
247
+ * tile at once), then the record's own problems, then the server's verdict on
248
+ * live video, and only then what the embedded page is actually doing.
249
+ */
250
+ export function tileView(
251
+ camera: TWallCamera | null | undefined,
252
+ opts: { online?: boolean; player?: TPlayerState } = {}
253
+ ): TTileView {
254
+ const online = opts.online !== false;
255
+ const dead = (state: TTileState, detail: string | null): TTileView => ({
256
+ state,
257
+ label: TILE_LABELS[state],
258
+ detail,
259
+ showPlayer: false,
260
+ });
261
+
262
+ if (!camera) return dead("unavailable", "No camera in this position.");
263
+ if (!online)
264
+ return dead("offline", "This browser has no internet connection.");
265
+ // The server's refusal wins over anything reconstructed here. It covers the
266
+ // same two cases below AND the ones only it can know — chiefly "no recorder is
267
+ // configured for this camera's relay", which is the honest answer for every
268
+ // camera until the API host is configured, and which used to be replaced by
269
+ // our own guess.
270
+ const reason = camera.unavailableReason?.trim();
271
+ if (reason) return dead("unavailable", reason);
272
+ if (camera.status && camera.status !== "active")
273
+ return dead("unavailable", "This camera is not active.");
274
+ if (!camera.host) return dead("unavailable", "This camera has no address configured.");
275
+
276
+ const live = capabilityView(camera, "liveVideo");
277
+ if (live.state === "unsupported") return dead("unavailable", live.detail);
278
+
279
+ const player = opts.player ?? "loading";
280
+ if (player === "timeout")
281
+ return {
282
+ state: "no-signal",
283
+ label: TILE_LABELS["no-signal"],
284
+ // Invented: only the browser can know the page never loaded, so the
285
+ // server has no reason code for it. Worded as a fact, not a diagnosis —
286
+ // we genuinely cannot tell a dead camera from a dead video service.
287
+ detail: "The live view did not load. The camera or the video service may be down.",
288
+ showPlayer: true,
289
+ };
290
+ if (player === "loading")
291
+ return {
292
+ state: "connecting",
293
+ label: TILE_LABELS.connecting,
294
+ detail: null,
295
+ showPlayer: true,
296
+ };
297
+
298
+ return { state: "live", label: TILE_LABELS.live, detail: null, showPlayer: true };
299
+ }