@7365admin1/layer-common 3.2.1 → 3.2.2-staging.76

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 (55) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/service-provider-invitation-actions.md +28 -0
  7. package/components/CameraWall.vue +1034 -0
  8. package/components/CameraWallTile.vue +818 -0
  9. package/components/DashboardMain.vue +84 -88
  10. package/components/DashboardPanel.vue +1 -1
  11. package/components/EntryPassInformation.vue +4 -4
  12. package/components/FeedbackMain.vue +7 -7
  13. package/components/HidAccessLogDashboard.vue +119 -44
  14. package/components/HidCameraCaptureDialog.vue +199 -0
  15. package/components/HidIntercomManagement.vue +808 -201
  16. package/components/HidQrCodeConfiguration.vue +907 -95
  17. package/components/HidServiceSettingsPanel.vue +20 -1
  18. package/components/HidUserEnrollment.vue +392 -81
  19. package/components/Layout/NavigationDrawer.vue +43 -2
  20. package/components/NavigationItem.vue +9 -0
  21. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  22. package/components/OnlineFormConfigurationForm.vue +620 -224
  23. package/components/OnlineFormFill.vue +36 -8
  24. package/components/OnlineFormsConfiguration.vue +6 -2
  25. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  26. package/components/QrTemplate/PrintDialog.vue +209 -196
  27. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  28. package/components/ServiceProviderMain.vue +258 -14
  29. package/components/SiteSettings.vue +4 -2
  30. package/components/VisitorForm.vue +201 -78
  31. package/components/VisitorManagement.vue +2 -3
  32. package/components/VisitorSocketPopUp.vue +56 -2
  33. package/composables/useComment.ts +3 -3
  34. package/composables/useHidAmico.ts +127 -0
  35. package/composables/useHidNavigation.ts +0 -7
  36. package/composables/useLocalAuth.ts +9 -36
  37. package/composables/useOnlineFormPages.ts +2 -0
  38. package/composables/useServiceProviderInvitation.ts +145 -0
  39. package/composables/useSipWebPhone.ts +311 -0
  40. package/composables/useSiteSettings.ts +50 -0
  41. package/composables/useVisitorSocket.ts +26 -0
  42. package/composables/useWebUsb.ts +127 -37
  43. package/package.json +3 -1
  44. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  45. package/plugins/secure-member.client.ts +21 -56
  46. package/plugins/vuetify.ts +37 -9
  47. package/test/visitor-socket.test.mjs +36 -0
  48. package/types/site.d.ts +71 -0
  49. package/utils/camera-wall.test.ts +571 -0
  50. package/utils/camera-wall.ts +926 -0
  51. package/utils/theme.test.ts +216 -0
  52. package/utils/theme.ts +163 -0
  53. package/components/HidIdentityMapping.vue +0 -975
  54. package/middleware/member.ts +0 -4
  55. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -721,9 +721,9 @@ const {
721
721
  _getFeedbacks({
722
722
  page: page.value,
723
723
  site: route.params.site as string,
724
- provider: currentUser.value.serviceProvider,
725
- ...(currentUser.value.type != "site" && {
726
- userId: currentUser.value._id,
724
+ provider: currentUser.value?.serviceProvider,
725
+ ...(currentUser.value?.type != "site" && {
726
+ userId: currentUser.value?._id,
727
727
  }),
728
728
  service: "Security",
729
729
  dateFrom: moment(startDate.value, "DD/MM/YYYY").startOf("day"),
@@ -998,9 +998,9 @@ async function handleAccept() {
998
998
  _id: feedbackData.value._id,
999
999
  statusUpdate: {
1000
1000
  status: "In-Progress",
1001
- updatedById: currentUser.value._id,
1002
- updatedByName: `${currentUser.value.givenName} ${currentUser.value.surname}`,
1003
- assignee: currentUser.value._id,
1001
+ updatedById: currentUser.value?._id,
1002
+ updatedByName: `${currentUser.value?.givenName} ${currentUser.value?.surname}`,
1003
+ assignee: currentUser.value?._id,
1004
1004
  provider: currentUser.value?.serviceProvider,
1005
1005
  },
1006
1006
  };
@@ -1158,7 +1158,7 @@ async function submit() {
1158
1158
  isSubmitting.value = true;
1159
1159
 
1160
1160
  const result = await createFeedback({
1161
- createdBy: currentUser.value._id,
1161
+ createdBy: currentUser.value?._id,
1162
1162
  description: _feedback.value.description,
1163
1163
  attachments: _feedback.value.attachments,
1164
1164
  site: route.params.site as string,
@@ -327,6 +327,8 @@ type AccessTab = "resident" | "visitor" | "administrator";
327
327
  type AccessRow = {
328
328
  key: string;
329
329
  identityKey: string;
330
+ identityType?: string;
331
+ visitorId?: string;
330
332
  name: string;
331
333
  facialData: string;
332
334
  faceImage?: string;
@@ -351,6 +353,7 @@ const { getVisitors } = useVisitor();
351
353
 
352
354
  const readers = ref<Record<string, any>[]>([]);
353
355
  const identities = ref<Record<string, any>[]>([]);
356
+ const allIdentities = ref<Record<string, any>[]>([]);
354
357
  const visitorRecords = ref<Record<string, any>>({});
355
358
  const logs = ref<Record<string, any>[]>([]);
356
359
  const liveAccessLogs = ref<Record<string, any>[]>([]);
@@ -418,8 +421,17 @@ const selectedHistoryRows = computed<AccessRow[]>(() => {
418
421
  if (!selectedHistoryRow.value) return [];
419
422
 
420
423
  const selectedKey = selectedHistoryRow.value.identityKey;
421
- const matchedLogs = logs.value.filter((log) => getLogIdentityKey(log) === selectedKey);
422
- const matchedLiveLogs = liveAccessLogs.value.filter((log) => getAccessLogIdentityKey(log) === selectedKey);
424
+ const selectedIdentity = findIdentityByKey(selectedKey);
425
+ const matchedLogs = logs.value.filter((log) =>
426
+ selectedIdentity
427
+ ? accessLogMatchesIdentity(log, selectedIdentity)
428
+ : getLogIdentityKey(log) === selectedKey,
429
+ );
430
+ const matchedLiveLogs = liveAccessLogs.value.filter((log) =>
431
+ selectedIdentity
432
+ ? accessLogMatchesIdentity(log, selectedIdentity)
433
+ : getAccessLogIdentityKey(log) === selectedKey,
434
+ );
423
435
 
424
436
  if (!matchedLogs.length && !matchedLiveLogs.length) {
425
437
  return rows.value.filter((row) => row.identityKey === selectedKey);
@@ -549,28 +561,41 @@ async function loadDashboard() {
549
561
  const isAdministratorTab = activeTab.value === "administrator";
550
562
  const isVisitorTab = activeTab.value === "visitor";
551
563
  const [identityResponse, logResponse, hidAccessLogs, configurationResponse, roleResponse, visitorResponse, readerUserResponse] = await Promise.all([
552
- getIdentities(selectedReaderId.value, {
553
- page: 1,
554
- limit: 500,
555
- ...(!isAdministratorTab ? { type: getIdentityType(activeTab.value) } : {}),
556
- }),
557
- getLogs(selectedReaderId.value, {
558
- page: 1,
559
- limit: 500,
560
- }),
561
- loadLiveAccessLogs(selectedReaderId.value),
562
- loadReaderConfiguration(selectedReaderId.value),
563
- isAdministratorTab ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
564
+ loadDashboardSource(
565
+ getIdentities(selectedReaderId.value, {
566
+ page: 1,
567
+ limit: 500,
568
+ }),
569
+ null,
570
+ "identities",
571
+ ),
572
+ loadDashboardSource(
573
+ getLogs(selectedReaderId.value, {
574
+ page: 1,
575
+ limit: 500,
576
+ }),
577
+ null,
578
+ "persisted access logs",
579
+ ),
580
+ loadDashboardSource(loadLiveAccessLogs(selectedReaderId.value), [], "live access logs"),
581
+ loadDashboardSource(loadReaderConfiguration(selectedReaderId.value), {}, "reader configuration"),
582
+ isAdministratorTab
583
+ ? loadDashboardSource(loadAdministratorRoles(selectedReaderId.value), null, "administrator roles")
584
+ : Promise.resolve(null),
564
585
  isVisitorTab && props.org
565
- ? getVisitors({
566
- org: props.org,
567
- site: props.site,
568
- page: 1,
569
- limit: 500,
570
- order: "desc",
571
- })
586
+ ? loadDashboardSource(
587
+ getVisitors({
588
+ org: props.org,
589
+ site: props.site,
590
+ page: 1,
591
+ limit: 500,
592
+ order: "desc",
593
+ }),
594
+ null,
595
+ "visitor records",
596
+ )
572
597
  : Promise.resolve(null),
573
- loadReaderUsers(selectedReaderId.value),
598
+ loadDashboardSource(loadReaderUsers(selectedReaderId.value), [], "reader users"),
574
599
  ]);
575
600
 
576
601
  if (sequence !== loadSequence.value) return;
@@ -578,18 +603,19 @@ async function loadDashboard() {
578
603
  const loadedIdentities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
579
604
  const mergedIdentities = mergeReaderUsersWithIdentities(readerUserResponse, loadedIdentities);
580
605
  const loadedVisitors = visitorResponse?.items ?? visitorResponse?.data?.items ?? visitorResponse?.data?.visitors ?? [];
581
- visitorRecords.value = isVisitorTab
582
- ? Object.fromEntries(loadedVisitors.map((visitor: Record<string, any>) => [String(visitor._id), visitor]))
583
- : {};
606
+ visitorRecords.value = Object.fromEntries(
607
+ loadedVisitors.map((visitor: Record<string, any>) => [String(visitor._id), visitor]),
608
+ );
584
609
  administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
610
+ allIdentities.value = mergedIdentities;
585
611
  identities.value = isAdministratorTab
586
612
  ? mergedIdentities.filter((identity: Record<string, any>) => {
587
613
  const hidUserId = toHidNumericId(identity.hidUserId);
588
614
  return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
589
615
  })
590
- : isVisitorTab
591
- ? mergedIdentities.filter((identity: Record<string, any>) => Boolean(getVisitorRecord(identity)))
592
- : mergedIdentities;
616
+ : activeTab.value === "visitor"
617
+ ? mergedIdentities.filter(isVisitorIdentity)
618
+ : mergedIdentities.filter((identity: Record<string, any>) => !isVisitorIdentity(identity));
593
619
  logs.value = logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
594
620
  liveAccessLogs.value = hidAccessLogs;
595
621
  readerConfiguration.value = normalizeReaderConfiguration(configurationResponse);
@@ -601,6 +627,15 @@ async function loadDashboard() {
601
627
  }
602
628
  }
603
629
 
630
+ async function loadDashboardSource<T>(request: Promise<T>, fallback: T, label: string): Promise<T> {
631
+ try {
632
+ return await request;
633
+ } catch (error) {
634
+ console.warn(`Unable to load HID ${label}`, error);
635
+ return fallback;
636
+ }
637
+ }
638
+
604
639
  async function loadUserImages(items: Record<string, any>[]) {
605
640
  if (!selectedReaderId.value) return;
606
641
 
@@ -766,6 +801,7 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
766
801
  function resetDashboardData() {
767
802
  selectedHistoryRow.value = undefined;
768
803
  identities.value = [];
804
+ allIdentities.value = [];
769
805
  visitorRecords.value = {};
770
806
  logs.value = [];
771
807
  liveAccessLogs.value = [];
@@ -773,15 +809,11 @@ function resetDashboardData() {
773
809
  administratorUserIds.value = new Set();
774
810
  }
775
811
 
776
- function getIdentityType(tab: AccessTab) {
777
- if (tab === "administrator") return "admin";
778
- return tab;
779
- }
780
-
781
812
  function isVisibleForActiveTab(row: AccessRow) {
782
813
  const identity = findIdentityByKey(row.identityKey);
814
+ const isVisitor = row.identityType === "visitor" || Boolean(row.visitorId) || isVisitorIdentity(identity);
783
815
  if (activeTab.value === "visitor") {
784
- return Boolean(identity && getVisitorRecord(identity));
816
+ return isVisitor;
785
817
  }
786
818
 
787
819
  if (activeTab.value === "administrator") {
@@ -789,7 +821,11 @@ function isVisibleForActiveTab(row: AccessRow) {
789
821
  return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
790
822
  }
791
823
 
792
- return !identity || !getVisitorRecord(identity);
824
+ return !isVisitor;
825
+ }
826
+
827
+ function isVisitorIdentity(identity: Record<string, unknown> | undefined) {
828
+ return Boolean(identity && (identity.type === "visitor" || identity.visitor));
793
829
  }
794
830
 
795
831
  function loadAdministratorRoles(readerId: string) {
@@ -835,13 +871,13 @@ function getIdentityKey(identity: Record<string, any>) {
835
871
  return [
836
872
  identity.hidUserId,
837
873
  identity.registration,
838
- identity.cardNo,
874
+ identity.cardNo || identity.metadata?.qrCodeValue,
839
875
  ].filter(Boolean).join("|");
840
876
  }
841
877
 
842
878
  function findIdentityByKey(identityKey: string) {
843
879
  if (!identityKey) return undefined;
844
- return identities.value.find((identity) => getIdentityKey(identity) === identityKey);
880
+ return allIdentities.value.find((identity) => getIdentityKey(identity) === identityKey);
845
881
  }
846
882
 
847
883
  function getLogIdentityKey(log: Record<string, any>) {
@@ -851,7 +887,7 @@ function getLogIdentityKey(log: Record<string, any>) {
851
887
  return [
852
888
  identity.hidUserId || lookup.hidUserId || rawLog.user_id || rawLog.userId || rawLog.hidUserId,
853
889
  identity.registration || lookup.registration,
854
- identity.cardNo || lookup.cardNo || rawLog.card_value || rawLog.cardNo,
890
+ identity.cardNo || lookup.cardNo || rawLog.card_value || rawLog.cardNo || rawLog.qrcode_value,
855
891
  ].filter(Boolean).join("|");
856
892
  }
857
893
 
@@ -1145,12 +1181,15 @@ function getRawAccessLogs(event: Record<string, any>) {
1145
1181
 
1146
1182
  function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, any>, index: string): AccessRow {
1147
1183
  const identity = findIdentityForAccessLog(rawLog, event);
1184
+ const eventIdentity = event.payload?.identity || event.payload?.identityLookup || {};
1148
1185
  const rawAccessTime = getAccessLogTime(rawLog, event);
1149
1186
  const identityKey = identity ? getIdentityKey(identity) : getLogIdentityKey({ ...event, payload: { ...event.payload, rawAccessLog: rawLog } });
1150
1187
 
1151
1188
  return {
1152
1189
  key: `${event._id || event.id || "log"}-${rawLog.id || rawLog.time || index}`,
1153
1190
  identityKey,
1191
+ identityType: String(identity?.type || eventIdentity.type || "").toLowerCase() || undefined,
1192
+ visitorId: String(identity?.visitor || eventIdentity.visitor || "") || undefined,
1154
1193
  name: identity ? getName(identity) : getRawLogName(rawLog, event),
1155
1194
  facialData: identity ? getFacialData(identity) : "N/A",
1156
1195
  faceImage: identity ? getUserImageSrc(identity) : "",
@@ -1166,32 +1205,33 @@ function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, an
1166
1205
  function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<string, any>) {
1167
1206
  const eventIdentityId = event.payload?.identity?._id;
1168
1207
  if (eventIdentityId) {
1169
- const byId = identities.value.find((identity) => String(identity._id) === String(eventIdentityId));
1208
+ const byId = allIdentities.value.find((identity) => String(identity._id) === String(eventIdentityId));
1170
1209
  if (byId) return byId;
1171
1210
  }
1172
1211
 
1173
1212
  const hidUserId = rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? event.payload?.identity?.hidUserId ?? event.payload?.identityLookup?.hidUserId;
1174
1213
  const registration = rawLog.registration ?? event.payload?.identity?.registration ?? event.payload?.identityLookup?.registration;
1175
1214
  const cardNo = rawLog.card_value || rawLog.cardNo || event.payload?.identity?.cardNo || event.payload?.identityLookup?.cardNo;
1215
+ const qrCodeValue = rawLog.qrcode_value || rawLog.qrCode || rawLog.qrcode;
1176
1216
 
1177
- return identities.value.find((identity) => {
1217
+ return allIdentities.value.find((identity) => {
1178
1218
  return (
1179
1219
  (hidUserId !== undefined && hidUserId !== null && String(identity.hidUserId) === String(hidUserId)) ||
1180
1220
  (registration && identity.registration === registration) ||
1181
- (cardNo && String(identity.cardNo) === String(cardNo))
1221
+ (cardNo && String(identity.cardNo) === String(cardNo)) ||
1222
+ (qrCodeValue && String(identity.metadata?.qrCodeValue || "") === String(qrCodeValue))
1182
1223
  );
1183
1224
  });
1184
1225
  }
1185
1226
 
1186
1227
  function getLatestAccessLogForIdentity(identity: Record<string, any>) {
1187
- const identityKey = getIdentityKey(identity);
1188
1228
  const accessLogs = [
1189
1229
  ...liveAccessLogs.value,
1190
1230
  ...logs.value.flatMap((event) => getRawAccessLogs(event)),
1191
1231
  ];
1192
1232
 
1193
1233
  return accessLogs
1194
- .filter((log) => getAccessLogIdentityKey(log) === identityKey)
1234
+ .filter((log) => accessLogMatchesIdentity(log, identity))
1195
1235
  .sort((a, b) => {
1196
1236
  const aTime = parseDate(getAccessLogTime(a, {}))?.getTime() || 0;
1197
1237
  const bTime = parseDate(getAccessLogTime(b, {}))?.getTime() || 0;
@@ -1199,11 +1239,46 @@ function getLatestAccessLogForIdentity(identity: Record<string, any>) {
1199
1239
  })[0];
1200
1240
  }
1201
1241
 
1242
+ function toUnknownRecord(value: unknown): Record<string, unknown> {
1243
+ return typeof value === "object" && value !== null && !Array.isArray(value)
1244
+ ? value as Record<string, unknown>
1245
+ : {};
1246
+ }
1247
+
1248
+ function accessLogMatchesIdentity(log: unknown, identity: unknown) {
1249
+ const logRecord = toUnknownRecord(log);
1250
+ const identityRecord = toUnknownRecord(identity);
1251
+ const payload = toUnknownRecord(logRecord.payload);
1252
+ const rawLog = Object.keys(toUnknownRecord(payload.rawAccessLog)).length
1253
+ ? toUnknownRecord(payload.rawAccessLog)
1254
+ : logRecord;
1255
+ const eventIdentity = Object.keys(toUnknownRecord(payload.identity)).length
1256
+ ? toUnknownRecord(payload.identity)
1257
+ : toUnknownRecord(payload.identityLookup);
1258
+ const metadata = toUnknownRecord(identityRecord.metadata);
1259
+ const hidUserId = rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? eventIdentity.hidUserId;
1260
+ const registration = rawLog.registration || eventIdentity.registration;
1261
+ const credential =
1262
+ rawLog.card_value ||
1263
+ rawLog.cardNo ||
1264
+ rawLog.qrcode_value ||
1265
+ rawLog.qrCode ||
1266
+ rawLog.qrcode ||
1267
+ eventIdentity.cardNo;
1268
+ const identityCredential = identityRecord.cardNo || metadata.qrCodeValue;
1269
+
1270
+ return Boolean(
1271
+ (hidUserId !== undefined && hidUserId !== null && String(identityRecord.hidUserId) === String(hidUserId)) ||
1272
+ (registration && identityRecord.registration === registration) ||
1273
+ (credential && identityCredential && String(identityCredential) === String(credential)),
1274
+ );
1275
+ }
1276
+
1202
1277
  function getAccessLogIdentityKey(rawLog: Record<string, any>) {
1203
1278
  return [
1204
1279
  rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId,
1205
1280
  rawLog.registration,
1206
- rawLog.card_value || rawLog.cardNo,
1281
+ rawLog.card_value || rawLog.cardNo || rawLog.qrcode_value,
1207
1282
  ].filter(Boolean).join("|");
1208
1283
  }
1209
1284
 
@@ -0,0 +1,199 @@
1
+ <template>
2
+ <v-dialog
3
+ :model-value="modelValue"
4
+ max-width="520"
5
+ persistent
6
+ @update:model-value="emit('update:modelValue', $event)"
7
+ >
8
+ <v-card class="camera-dialog" rounded="lg">
9
+ <v-card-title class="d-flex align-center justify-space-between pa-4">
10
+ <span class="text-subtitle-1 font-weight-bold">Take Facial Photo</span>
11
+ <v-btn
12
+ icon="mdi-close"
13
+ variant="text"
14
+ size="small"
15
+ aria-label="Close camera"
16
+ @click="close"
17
+ />
18
+ </v-card-title>
19
+
20
+ <v-divider />
21
+
22
+ <v-card-text class="pa-4">
23
+ <div class="camera-viewport">
24
+ <video
25
+ ref="videoElement"
26
+ class="camera-preview"
27
+ autoplay
28
+ muted
29
+ playsinline
30
+ />
31
+
32
+ <div v-if="starting" class="camera-state">
33
+ <v-progress-circular indeterminate color="primary" />
34
+ <span class="mt-3">Starting camera...</span>
35
+ </div>
36
+
37
+ <div v-else-if="cameraError" class="camera-state px-6 text-center">
38
+ <v-icon icon="mdi-camera-off-outline" size="42" color="error" />
39
+ <span class="mt-3 text-error">{{ cameraError }}</span>
40
+ </div>
41
+ </div>
42
+
43
+ <canvas ref="canvasElement" class="d-none" />
44
+
45
+ <div class="text-caption text-medium-emphasis mt-3 text-center">
46
+ Keep your face centered and make sure the area is well lit.
47
+ </div>
48
+ </v-card-text>
49
+
50
+ <v-divider />
51
+
52
+ <v-card-actions class="pa-4">
53
+ <v-btn variant="text" @click="close">Cancel</v-btn>
54
+ <v-spacer />
55
+ <v-btn
56
+ color="primary"
57
+ variant="flat"
58
+ prepend-icon="mdi-camera"
59
+ :disabled="starting || !!cameraError || !stream"
60
+ @click="capture"
61
+ >
62
+ Take Photo
63
+ </v-btn>
64
+ </v-card-actions>
65
+ </v-card>
66
+ </v-dialog>
67
+ </template>
68
+
69
+ <script setup lang="ts">
70
+ const props = defineProps<{ modelValue: boolean }>();
71
+ const emit = defineEmits<{
72
+ "update:modelValue": [value: boolean];
73
+ capture: [dataUrl: string];
74
+ }>();
75
+
76
+ const videoElement = ref<HTMLVideoElement | null>(null);
77
+ const canvasElement = ref<HTMLCanvasElement | null>(null);
78
+ const stream = shallowRef<MediaStream | null>(null);
79
+ const starting = ref(false);
80
+ const cameraError = ref("");
81
+
82
+ function stopCamera() {
83
+ stream.value?.getTracks().forEach((track) => track.stop());
84
+ stream.value = null;
85
+ if (videoElement.value) videoElement.value.srcObject = null;
86
+ }
87
+
88
+ function getCameraErrorMessage(caught: unknown) {
89
+ if (caught instanceof DOMException) {
90
+ if (caught.name === "NotAllowedError") {
91
+ return "Camera permission was denied. Allow camera access and try again.";
92
+ }
93
+ if (caught.name === "NotFoundError") {
94
+ return "No camera was found on this device.";
95
+ }
96
+ if (caught.name === "NotReadableError") {
97
+ return "The camera is currently being used by another application.";
98
+ }
99
+ }
100
+ return "Unable to open the camera. Use HTTPS or check the browser camera permission.";
101
+ }
102
+
103
+ async function startCamera() {
104
+ stopCamera();
105
+ cameraError.value = "";
106
+ starting.value = true;
107
+
108
+ try {
109
+ if (!navigator.mediaDevices?.getUserMedia) {
110
+ throw new Error("Camera API is unavailable");
111
+ }
112
+
113
+ stream.value = await navigator.mediaDevices.getUserMedia({
114
+ audio: false,
115
+ video: {
116
+ facingMode: "user",
117
+ width: { ideal: 1280 },
118
+ height: { ideal: 960 },
119
+ },
120
+ });
121
+
122
+ await nextTick();
123
+ if (!videoElement.value) throw new Error("Camera preview is unavailable");
124
+ videoElement.value.srcObject = stream.value;
125
+ await videoElement.value.play();
126
+ } catch (caught: unknown) {
127
+ stopCamera();
128
+ cameraError.value = getCameraErrorMessage(caught);
129
+ } finally {
130
+ starting.value = false;
131
+ }
132
+ }
133
+
134
+ function capture() {
135
+ const video = videoElement.value;
136
+ const canvas = canvasElement.value;
137
+ if (!video || !canvas || !video.videoWidth || !video.videoHeight) return;
138
+
139
+ canvas.width = video.videoWidth;
140
+ canvas.height = video.videoHeight;
141
+ const context = canvas.getContext("2d");
142
+ if (!context) return;
143
+
144
+ context.translate(canvas.width, 0);
145
+ context.scale(-1, 1);
146
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
147
+
148
+ emit("capture", canvas.toDataURL("image/jpeg", 0.9));
149
+ close();
150
+ }
151
+
152
+ function close() {
153
+ stopCamera();
154
+ emit("update:modelValue", false);
155
+ }
156
+
157
+ watch(
158
+ () => props.modelValue,
159
+ (isOpen) => {
160
+ if (isOpen) void startCamera();
161
+ else stopCamera();
162
+ }
163
+ );
164
+
165
+ onBeforeUnmount(stopCamera);
166
+ </script>
167
+
168
+ <style scoped>
169
+ .camera-dialog {
170
+ overflow: hidden;
171
+ }
172
+
173
+ .camera-viewport {
174
+ position: relative;
175
+ width: 100%;
176
+ overflow: hidden;
177
+ aspect-ratio: 3 / 4;
178
+ border-radius: 8px;
179
+ background: #111722;
180
+ }
181
+
182
+ .camera-preview {
183
+ width: 100%;
184
+ height: 100%;
185
+ object-fit: cover;
186
+ transform: scaleX(-1);
187
+ }
188
+
189
+ .camera-state {
190
+ position: absolute;
191
+ inset: 0;
192
+ display: flex;
193
+ flex-direction: column;
194
+ align-items: center;
195
+ justify-content: center;
196
+ background: #111722;
197
+ color: #ffffff;
198
+ }
199
+ </style>