Add scanner functionality and reusable button component:
- Introduce `ScannerInstructions.vue` for user guidance during scanning. - Add `GenericButton.vue` as a flexible button component with default, disabled, and white styles. - Implement `ScannerCamera.vue` to handle camera activation and video frame capture. - Update `LoginQR.vue` with scanner, instructions, and button integrations. - Add `tesseract.js` dependency in `package.json` and `package-lock.json` for future OCR capabilities.
This commit is contained in:
Generated
+51
@@ -38,6 +38,7 @@
|
||||
"flow": "^0.2.3",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "^11.14.5",
|
||||
"tesseract.js": "^6.0.1",
|
||||
"toastify-js": "^1.12.0",
|
||||
"vite-plugin-singlefile": "^2.1.0",
|
||||
"vue": "^3.5.12",
|
||||
@@ -6493,6 +6494,11 @@
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -7048,6 +7054,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
|
||||
},
|
||||
"node_modules/is-weakmap": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@@ -9559,6 +9570,33 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz",
|
||||
"integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^6.0.0",
|
||||
"wasm-feature-detect": "^1.2.11",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.0.0.tgz",
|
||||
"integrity": "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA=="
|
||||
},
|
||||
"node_modules/tesseract.js/node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||
},
|
||||
"node_modules/throat": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
|
||||
@@ -10272,6 +10310,11 @@
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
@@ -10846,6 +10889,14 @@
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"flow": "^0.2.3",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "^11.14.5",
|
||||
"tesseract.js": "^6.0.1",
|
||||
"toastify-js": "^1.12.0",
|
||||
"vite-plugin-singlefile": "^2.1.0",
|
||||
"vue": "^3.5.12",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="generic-button">
|
||||
<span class="is-flex-wrap-nowrap">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Generic Button Styles */
|
||||
/* Default styles */
|
||||
.generic-button {
|
||||
/* Button */
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 10px;
|
||||
width: 304px;
|
||||
height: 34px;
|
||||
background: #000000;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 3;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
/* Default text styles */
|
||||
.generic-button span {
|
||||
/* Auto layout */
|
||||
height: 18px;
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
color: #FFFFFF;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
/* Disabled styles */
|
||||
.generic-button:disabled {
|
||||
/* Button */
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 10px;
|
||||
width: 304px;
|
||||
height: 34px;
|
||||
background: #A6A5A5;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
/* Disabled text styles */
|
||||
.generic-button:disabled span {
|
||||
/* Auto layout */
|
||||
height: 18px;
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
color: #FFFFFF;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
/** White Button Styles */
|
||||
.generic-button.white {
|
||||
/* Button */
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 10px;
|
||||
width: 304px;
|
||||
height: 34px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
/* White text styles */
|
||||
.generic-button.white span {
|
||||
/* Auto layout */
|
||||
height: 18px;
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
color: #000000;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, defineExpose } from 'vue';
|
||||
const emits = defineEmits(['camera-toggled', 'scanner-toggled']);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const cameraStream = ref<MediaStream | null>(null);
|
||||
const isCameraActive = ref(false);
|
||||
|
||||
function startCamera() {
|
||||
navigator.mediaDevices.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
isCameraActive.value = true;
|
||||
cameraStream.value = stream;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream;
|
||||
videoRef.value.play();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
isCameraActive.value = false;
|
||||
console.error('Camera access error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach(track => track.stop());
|
||||
cameraStream.value = null;
|
||||
}
|
||||
isCameraActive.value = false;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCamera() {
|
||||
if (isCameraActive.value) {
|
||||
stopCamera();
|
||||
} else {
|
||||
startCamera();
|
||||
}
|
||||
}
|
||||
|
||||
const getFrame = () => {
|
||||
if (videoRef.value && isCameraActive.value) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
// Draw the current video frame onto the canvas
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
canvas.width = videoRef.value.videoWidth; // Set canvas width
|
||||
canvas.height = videoRef.value.videoHeight; // Set canvas height
|
||||
// Get a complete frame from the video
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas before drawing
|
||||
// Draw the video frame onto the canvas
|
||||
if (!videoRef.value.videoWidth || !videoRef.value.videoHeight) {
|
||||
console.error('Video dimensions are not available yet.');
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height);
|
||||
// Log the canvas context to ensure it's valid
|
||||
console.log('Canvas context:', ctx);
|
||||
// Log the canvas to ensure it has content
|
||||
console.log('Canvas content:', canvas.toDataURL('image/png').length, 'bytes');
|
||||
// Return the image data as a base64 encoded PNG
|
||||
console.log('Frame captured');
|
||||
console.log('Canvas dimensions:', canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
startCamera();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
// Clean up resources when component is unmounted
|
||||
stopCamera();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
toggleCamera,
|
||||
isCameraActive,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
getFrame
|
||||
});
|
||||
|
||||
watch(() => isCameraActive.value, (newVal) => {
|
||||
emits('camera-toggled', newVal);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scanner-camera">
|
||||
<!--<button @click="console.log(getFrame())" class="has-text-warning">Get Frame</button> -->
|
||||
<video ref="videoRef" autoplay playsinline class="is-fixed-background"></video>
|
||||
<div v-if="!isCameraActive">
|
||||
<p>Camera is not active. Click the button to start it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scanner-camera {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.is-fixed-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
// Define the component
|
||||
export default defineComponent({
|
||||
name: 'ScannerInstructions',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Title',
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'Subtitle',
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'Button Text',
|
||||
},
|
||||
},
|
||||
emits: ['button-click'],
|
||||
methods: {
|
||||
handleButtonClick() {
|
||||
this.$emit('button-click');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section has-text-centered">
|
||||
<!-- Title -->
|
||||
<h4 class="custom-title">{{ $props.title }}</h4>
|
||||
<!-- Subtitle -->
|
||||
<p class="custom-subtitle mb-3">{{ $props.subtitle }}</p>
|
||||
<!-- Button text -->
|
||||
<p class="custom-button-text mb-3" @click="handleButtonClick">
|
||||
{{ $props.buttonText }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-title {
|
||||
/* Scan Plates */
|
||||
width: 304px;
|
||||
height: 24px;
|
||||
|
||||
/* H3 */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 24px */
|
||||
text-align: center;
|
||||
color: #FFFFFF;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.custom-subtitle {
|
||||
/* Position the camera in front of the plates. */
|
||||
width: 304px;
|
||||
height: 19px;
|
||||
|
||||
/* rg-tx */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 120%;
|
||||
|
||||
/* or 19px */
|
||||
text-align: center;
|
||||
color: #F1F1F1;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.custom-button-text {
|
||||
/* Type registration number manually */
|
||||
width: 304px;
|
||||
height: 12px;
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 100%;
|
||||
|
||||
/* identical to box height, or 12px */
|
||||
text-align: center;
|
||||
text-decoration-line: underline;
|
||||
color: #FFFFFF;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import Viewport from "@/components/viewport/Viewport.vue";
|
||||
import ScannerOutline from "@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue";
|
||||
import ScannerInstructions from "@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import ScannerCamera from "@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Viewport>
|
||||
<Viewport class="has-background-black">
|
||||
<ScannerCamera/>
|
||||
<!--<LetterReader/>-->
|
||||
<!-- Instructions for the user -->
|
||||
<ScannerInstructions
|
||||
title="Scan Plates"
|
||||
subtitle="Position the camera in front of the plates."
|
||||
buttonText="Type registration number manually"
|
||||
@button-click="console.log('Button clicked')"
|
||||
/>
|
||||
<!-- Scanner outline object -->
|
||||
<ScannerOutline/>
|
||||
<div class="is-align-content-center is-flex is-justify-content-center mt-6">
|
||||
<ScannerOutline :loading="false"/>
|
||||
</div>
|
||||
<!-- Last scanned frame -->
|
||||
<!-- Buttons -->
|
||||
<div class="is-flex is-justify-content-center mt-6">
|
||||
<GenericButton class="white">Next</GenericButton>
|
||||
</div>
|
||||
<!-- Additional content or components can be added here -->
|
||||
</Viewport>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user