298 lines
8.9 KiB
HTML
298 lines
8.9 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="uk">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Авторизація</title>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.min.js"></script>
|
|
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background-color: #121212;
|
|
color: #ffffff;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
margin: 0;
|
|
}
|
|
|
|
/* Контейнер авторизации */
|
|
#page-auth {
|
|
background: #1e1e1e;
|
|
padding: 30px;
|
|
border-radius: 16px;
|
|
border: 1px solid #2c2c2e;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
width: 100%;
|
|
max-width: 320px;
|
|
}
|
|
|
|
#page-auth-form div {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
input[type="password"] {
|
|
background-color: #2c2c2e;
|
|
border: 1px solid #3a3a3c;
|
|
color: #fff;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
outline: none;
|
|
}
|
|
|
|
button[type="submit"], .btn-scan {
|
|
background-color: #007aff;
|
|
color: white;
|
|
border: none;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
button[type="submit"]:hover { background-color: #0056b3; }
|
|
|
|
.btn-scan {
|
|
background-color: #34c759;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
.btn-scan:hover { background-color: #248a3d; }
|
|
|
|
/* Стили для нативного <dialog> */
|
|
dialog {
|
|
background: #1e1e1e;
|
|
color: white;
|
|
border: 1px solid #2c2c2e;
|
|
border-radius: 20px;
|
|
padding: 20px;
|
|
width: 90%;
|
|
max-width: 360px;
|
|
margin: auto;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
/* Затемнение фона вокруг модалки */
|
|
dialog::backdrop {
|
|
background: rgba(0, 0, 0, 0.75);
|
|
backdrop-filter: blur(4px); /* Легкое размытие заднего плана */
|
|
}
|
|
|
|
.dialog-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.btn-close {
|
|
background: none;
|
|
border: none;
|
|
color: #8e8e93;
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 0 5px;
|
|
}
|
|
|
|
/* Контейнер камеры внутри модалки */
|
|
.scanner-container {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 1 / 1;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
background-color: #000;
|
|
}
|
|
|
|
#video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.laser-box {
|
|
position: absolute;
|
|
top: 15%; left: 15%; width: 70%; height: 70%;
|
|
border: 2px solid rgba(52, 199, 89, 0.5);
|
|
border-radius: 12px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.laser-line {
|
|
position: absolute;
|
|
width: 100%; height: 2px;
|
|
background-color: #34c759;
|
|
box-shadow: 0 0 8px #34c759;
|
|
top: 0;
|
|
animation: scan 2s linear infinite;
|
|
}
|
|
|
|
@keyframes scan {
|
|
0% { top: 0%; }
|
|
50% { top: 100%; }
|
|
100% { top: 0%; }
|
|
}
|
|
|
|
#loading-status {
|
|
position: absolute;
|
|
top: 50%; left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: #8e8e93;
|
|
text-align: center;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="page-auth">
|
|
<form id="page-auth-form">
|
|
<div>
|
|
<input
|
|
type="password"
|
|
name="uuid"
|
|
id="auth-forms-uuid"
|
|
placeholder="UUID"
|
|
required=""
|
|
/>
|
|
|
|
<button type="button" id="open-scanner-btn" class="btn-scan">
|
|
<span>Сканувати QR-код</span>
|
|
</button>
|
|
|
|
<button type="submit">
|
|
<span>Авторизуватися</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<dialog id="scanner-dialog">
|
|
<div class="dialog-header">
|
|
<h3 style="margin:0; font-size:18px;">Сканування коду</h3>
|
|
<button class="btn-close" id="close-scanner-btn" aria-label="Закрити">×</button>
|
|
</div>
|
|
<div class="scanner-container">
|
|
<div id="loading-status">Запуск камери...</div>
|
|
<video id="video" autoplay playsinline></video>
|
|
<div class="laser-box">
|
|
<div class="laser-line"></div>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<canvas id="canvas" style="display: none;"></canvas>
|
|
|
|
<script>
|
|
const dialog = document.getElementById('scanner-dialog');
|
|
const openBtn = document.getElementById('open-scanner-btn');
|
|
const closeBtn = document.getElementById('close-scanner-btn');
|
|
const uuidInput = document.getElementById('auth-forms-uuid');
|
|
|
|
const video = document.getElementById('video');
|
|
const canvas = document.getElementById('canvas');
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
const loadingStatus = document.getElementById('loading-status');
|
|
|
|
let streamRef = null;
|
|
let isScanning = false;
|
|
|
|
// --- Логика управления сканером ---
|
|
|
|
async function startCamera() {
|
|
loadingStatus.style.display = 'block';
|
|
loadingStatus.innerText = "Запуск камери...";
|
|
isScanning = true;
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { exact: "environment" } }
|
|
});
|
|
streamRef = stream;
|
|
video.srcObject = stream;
|
|
video.setAttribute("playsinline", true);
|
|
video.play();
|
|
|
|
video.addEventListener('loadedmetadata', () => {
|
|
loadingStatus.style.display = 'none';
|
|
requestAnimationFrame(tick);
|
|
});
|
|
} catch (err) {
|
|
// Фолбек, если основная камера недоступна (например, на ПК)
|
|
try {
|
|
const fallbackStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
streamRef = fallbackStream;
|
|
video.srcObject = fallbackStream;
|
|
video.play();
|
|
loadingStatus.style.display = 'none';
|
|
requestAnimationFrame(tick);
|
|
} catch (fallbackErr) {
|
|
loadingStatus.innerText = "Помилка доступу до камери";
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopCamera() {
|
|
isScanning = false;
|
|
if (streamRef) {
|
|
streamRef.getTracks().forEach(track => track.stop());
|
|
streamRef = null;
|
|
}
|
|
video.srcObject = null;
|
|
}
|
|
|
|
function tick() {
|
|
if (!isScanning) return;
|
|
|
|
if (video.readyState === video.HAVE_ENOUGH_DATA) {
|
|
canvas.height = video.videoHeight;
|
|
canvas.width = video.videoWidth;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
|
inversionAttempts: "dontInvert",
|
|
});
|
|
|
|
if (code && code.data.trim() !== "") {
|
|
// Успешно считали UUID
|
|
uuidInput.value = code.data;
|
|
|
|
// Закрываем модалку (событие close сработает автоматически)
|
|
dialog.close();
|
|
return;
|
|
}
|
|
}
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
// --- Слушатели событий модального окна ---
|
|
|
|
// Открыть сканер
|
|
openBtn.addEventListener('click', () => {
|
|
dialog.showModal(); // Открывает как модальное окно (с бэкдропом)
|
|
startCamera();
|
|
});
|
|
|
|
// Закрыть сканер по кнопке крестика
|
|
closeBtn.addEventListener('click', () => {
|
|
dialog.close();
|
|
});
|
|
|
|
// Важно: событие 'close' срабатывает всегда (и при клике на крестик, и при нажатии Esc)
|
|
dialog.addEventListener('close', () => {
|
|
stopCamera();
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html> |