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:
Jeppe Bundgaard
2025-07-31 09:22:20 +02:00
parent d641bb8e37
commit 20bdaf9bc1
6 changed files with 434 additions and 2 deletions
+51
View File
@@ -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": "*"
}
}
}
}
+1
View File
@@ -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>
+20 -2
View File
@@ -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>