Initial frontend commit

This commit is contained in:
2026-01-27 15:44:48 +01:00
commit 94a8c074fa

822
index.html Normal file
View File

@@ -0,0 +1,822 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Camera Text Recognition - API Integrated</title>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: white;
height: 100vh;
overflow: hidden;
}
.container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.capture-mode {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
gap: 20px;
padding-bottom: 200px;
}
.camera-container {
position: relative;
width: 100%;
max-width: 600px;
background: black;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.8);
}
video {
width: 100%;
height: auto;
display: block;
}
canvas {
display: none;
}
.crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 250px;
height: 250px;
border: 3px solid #06b6d4;
border-radius: 8px;
opacity: 0.6;
pointer-events: none;
}
.camera-hint {
position: absolute;
bottom: 15px;
left: 0;
right: 0;
text-align: center;
color: #06b6d4;
font-size: 12px;
font-weight: 500;
}
.status-bar {
display: flex;
gap: 16px;
justify-content: center;
font-size: 12px;
color: #94a3b8;
}
.status-bar span {
display: flex;
align-items: center;
gap: 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
}
.status-dot.ready {
background: #10b981;
}
.result-box {
background: rgba(15, 23, 42, 0.8);
border: 2px solid #06b6d4;
border-radius: 8px;
padding: 16px;
max-width: 600px;
width: 100%;
text-align: center;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
}
.result-box label {
display: block;
color: #06b6d4;
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
}
.result-box p {
font-size: 18px;
font-weight: bold;
color: white;
margin: 4px 0;
}
.result-box.error {
border-color: #ef4444;
}
.result-box.error label {
color: #ef4444;
}
.result-box.success {
border-color: #10b981;
}
.result-box.success label {
color: #10b981;
}
.result-box.not-found {
border-color: #f97316;
}
.result-box.not-found label {
color: #f97316;
}
.controls {
background: #1e293b;
border-radius: 8px;
padding: 16px;
display: flex;
gap: 16px;
align-items: center;
max-width: 600px;
flex-wrap: wrap;
justify-content: center;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
width: calc(100% - 40px);
}
.language-select {
background: #334155;
border: 1px solid #475569;
color: white;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button {
background: linear-gradient(135deg, #06b6d4 0%, #0284c7 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(6, 182, 212, 0.3);
}
button:disabled {
background: #475569;
cursor: not-allowed;
opacity: 0.6;
}
.playing-mode {
flex: 1;
display: flex;
flex-direction: column;
background: black;
position: relative;
}
.video-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-title {
position: absolute;
top: 20px;
left: 20px;
font-size: 28px;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
z-index: 10;
}
.video-meta {
position: absolute;
top: 70px;
left: 20px;
font-size: 12px;
color: #94a3b8;
z-index: 10;
max-width: 300px;
}
.close-button {
position: absolute;
top: 20px;
right: 20px;
background: #dc2626 !important;
padding: 12px 16px;
border-radius: 50%;
z-index: 10;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.close-button:hover {
background: #991b1b !important;
}
.controls-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, black, transparent);
padding: 40px 20px 20px;
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.hidden {
display: none !important;
}
.spinner {
display: inline-block;
border: 3px solid #334155;
border-top: 3px solid #06b6d4;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.confidence {
font-size: 12px;
color: #94a3b8;
margin-top: 8px;
}
.error-message {
color: #ef4444;
font-size: 14px;
margin-top: 8px;
}
.info-box {
position: absolute;
bottom: 100px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
border: 1px solid #334155;
padding: 12px;
border-radius: 6px;
font-size: 12px;
color: #94a3b8;
max-width: 200px;
}
.match-details {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="container">
<!-- CAPTURE MODE -->
<div id="captureMode" class="capture-mode">
<div class="status-bar">
<span><span class="status-dot" id="ocrDot"></span> OCR: <span id="ocrStatus">Loading...</span></span>
<span><span class="status-dot" id="apiDot"></span> API: <span id="apiStatus">Checking...</span></span>
</div>
<div class="camera-container">
<video id="video" autoplay playsinline></video>
<canvas id="canvas"></canvas>
<div class="crosshair"></div>
<div class="camera-hint">Point camera at text to recognize</div>
</div>
<div id="resultBox" class="result-box hidden">
<label id="resultLabel">Recognized Text:</label>
<p id="resultText"></p>
<p id="resultStatus"></p>
<div class="match-details" id="matchDetails"></div>
<div class="confidence" id="confidence"></div>
<div class="error-message" id="errorMessage"></div>
</div>
<div class="controls">
<label for="language">Language:</label>
<select id="language" class="language-select">
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
</select>
<button id="permissionBtn" onclick="initCamera()" style="background: #3b82f6;">
🔐 Request Camera Permission
</button>
<button id="captureBtn" onclick="captureAndRecognize()" disabled>
📷 Capture & Recognize
</button>
</div>
</div>
<!-- PLAYING MODE -->
<div id="playingMode" class="playing-mode hidden">
<div class="video-wrapper">
<div class="video-title" id="videoTitle"></div>
<div class="video-meta" id="videoMeta"></div>
<button class="close-button" onclick="closeVideo()"></button>
<video id="videoPlayer" class="video-player" autoplay muted controls></video>
<div class="info-box" id="infoBox"></div>
</div>
<div class="controls-bar">
<button id="playPauseBtn" onclick="togglePlayPause()">▶️ Play</button>
<button id="muteBtn" onclick="toggleMute()">🔊 Mute</button>
<button onclick="closeVideo()">↩️ Back</button>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════
const API_BASE_URL = 'https://hotspots.fabio.ovh';
// Video base URL (configure based on where videos are hosted)
const VIDEO_BASE_URL = ''; // Leave empty for local/relative paths
// ═══════════════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════════════
let currentLanguage = 'en';
let currentContent = null;
let isPlaying = false;
let tesseractWorker = null;
let ocrReady = false;
let apiReady = false;
// ═══════════════════════════════════════════════════════════════════════════
// API FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
async function checkApiHealth() {
try {
const response = await fetch(`${API_BASE_URL}/health`);
const data = await response.json();
apiReady = data.status === 'healthy' && data.database_loaded;
updateApiStatus(apiReady);
console.log('✅ API Health:', data);
return apiReady;
} catch (err) {
console.error('❌ API Health check failed:', err);
apiReady = false;
updateApiStatus(false);
return false;
}
}
async function matchTextWithApi(text) {
try {
const response = await fetch(`${API_BASE_URL}/match`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
language: currentLanguage
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
console.log('API Match result:', data);
return data;
} catch (err) {
console.error('API Match error:', err);
throw err;
}
}
function updateApiStatus(ready) {
const dot = document.getElementById('apiDot');
const status = document.getElementById('apiStatus');
if (ready) {
dot.classList.add('ready');
status.textContent = 'Ready';
} else {
dot.classList.remove('ready');
status.textContent = 'Offline';
}
updateCaptureButton();
}
function updateOcrStatus(ready) {
const dot = document.getElementById('ocrDot');
const status = document.getElementById('ocrStatus');
if (ready) {
dot.classList.add('ready');
status.textContent = 'Ready';
} else {
dot.classList.remove('ready');
status.textContent = 'Loading...';
}
updateCaptureButton();
}
function updateCaptureButton() {
const btn = document.getElementById('captureBtn');
btn.disabled = !(ocrReady && apiReady);
}
// ═══════════════════════════════════════════════════════════════════════════
// OCR FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
async function initOCR() {
try {
console.log('=== TESSERACT INITIALIZATION START ===');
tesseractWorker = await Tesseract.createWorker();
await tesseractWorker.loadLanguage('eng');
await tesseractWorker.initialize('eng');
ocrReady = true;
updateOcrStatus(true);
console.log('✅ TESSERACT FULLY READY');
} catch (err) {
console.error('❌ OCR initialization failed:', err);
ocrReady = false;
updateOcrStatus(false);
showError('OCR initialization failed: ' + err.message);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CAMERA FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
async function initCamera() {
try {
console.log('=== CAMERA INITIALIZATION ===');
const constraints = {
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('video');
video.srcObject = stream;
video.onloadedmetadata = () => {
video.play();
console.log('✅ Camera ready');
};
// Hide permission button after success
document.getElementById('permissionBtn').style.display = 'none';
} catch (err) {
console.error('Camera error:', err);
if (err.name === 'NotAllowedError') {
showError('❌ Camera permission denied. Please allow camera access.');
} else if (err.name === 'NotFoundError') {
showError('❌ No camera found on this device.');
} else {
showError('❌ Camera access failed: ' + err.message);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CAPTURE & RECOGNIZE
// ═══════════════════════════════════════════════════════════════════════════
async function captureAndRecognize() {
if (!tesseractWorker || !apiReady) {
showError('OCR or API not ready. Please wait...');
return;
}
const btn = document.getElementById('captureBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Recognizing...';
currentLanguage = document.getElementById('language').value;
try {
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
if (video.readyState !== 4 || video.videoWidth === 0) {
showError('Camera not ready. Try again.');
return;
}
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
// OCR
btn.innerHTML = '<span class="spinner"></span> Reading text...';
const result = await tesseractWorker.recognize(canvas);
const { data: { text, confidence } } = result;
const recognizedText = text.trim();
console.log('Recognized:', recognizedText, 'Confidence:', confidence);
if (!recognizedText) {
showNoTextFound();
return;
}
// Show recognized text
showRecognizedText(recognizedText, confidence);
// Match with API
btn.innerHTML = '<span class="spinner"></span> Matching...';
const matchResult = await matchTextWithApi(recognizedText);
if (matchResult.found) {
showMatchFound(matchResult, recognizedText, confidence);
// Auto-load content after short delay
setTimeout(() => loadContent(matchResult), 1500);
} else {
showContentNotFound(recognizedText);
}
} catch (err) {
console.error('Recognition error:', err);
showError('Recognition failed: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '📷 Capture & Recognize';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UI FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
function showRecognizedText(text, confidence) {
const box = document.getElementById('resultBox');
const label = document.getElementById('resultLabel');
const resultText = document.getElementById('resultText');
const confidenceEl = document.getElementById('confidence');
const errorMsg = document.getElementById('errorMessage');
const matchDetails = document.getElementById('matchDetails');
box.classList.remove('hidden', 'error', 'not-found', 'success');
label.textContent = '🔍 Text Recognized';
resultText.textContent = text;
confidenceEl.textContent = `OCR Confidence: ${confidence.toFixed(1)}%`;
matchDetails.textContent = 'Searching database...';
errorMsg.textContent = '';
}
function showMatchFound(match, recognizedText, ocrConfidence) {
const box = document.getElementById('resultBox');
const label = document.getElementById('resultLabel');
const resultText = document.getElementById('resultText');
const matchDetails = document.getElementById('matchDetails');
const confidenceEl = document.getElementById('confidence');
box.classList.remove('error', 'not-found');
box.classList.add('success');
label.textContent = '✅ Match Found!';
resultText.textContent = match.name;
matchDetails.textContent = `Hotspot ID: ${match.hotspot_id}`;
confidenceEl.textContent = `OCR: ${ocrConfidence.toFixed(1)}% | Match: ${(match.confidence * 100).toFixed(0)}%`;
currentContent = match;
}
function showNoTextFound() {
const box = document.getElementById('resultBox');
const label = document.getElementById('resultLabel');
const resultText = document.getElementById('resultText');
const errorMsg = document.getElementById('errorMessage');
const matchDetails = document.getElementById('matchDetails');
box.classList.remove('hidden', 'success', 'not-found');
box.classList.add('error');
label.textContent = '⚠️ No Text Detected';
resultText.textContent = '';
matchDetails.textContent = '';
errorMsg.textContent = 'No readable text found. Try pointing at clear, well-lit text.';
}
function showContentNotFound(recognizedText) {
const box = document.getElementById('resultBox');
const label = document.getElementById('resultLabel');
const resultText = document.getElementById('resultText');
const resultStatus = document.getElementById('resultStatus');
const errorMsg = document.getElementById('errorMessage');
const matchDetails = document.getElementById('matchDetails');
box.classList.remove('hidden', 'success', 'error');
box.classList.add('not-found');
label.textContent = '❌ No Match Found';
resultText.textContent = recognizedText;
resultStatus.textContent = '';
matchDetails.textContent = 'Text does not match any hotspot in database';
errorMsg.textContent = 'Try: Brussels, Paris, Berlin, Rome...';
}
function showError(message) {
const box = document.getElementById('resultBox');
const label = document.getElementById('resultLabel');
const resultText = document.getElementById('resultText');
const errorMsg = document.getElementById('errorMessage');
const matchDetails = document.getElementById('matchDetails');
box.classList.remove('hidden', 'success', 'not-found');
box.classList.add('error');
label.textContent = '⚠️ Error';
resultText.textContent = '';
matchDetails.textContent = '';
errorMsg.textContent = message;
}
// ═══════════════════════════════════════════════════════════════════════════
// VIDEO PLAYBACK
// ═══════════════════════════════════════════════════════════════════════════
function loadContent(match) {
const videoPlayer = document.getElementById('videoPlayer');
const videoTitle = document.getElementById('videoTitle');
const videoMeta = document.getElementById('videoMeta');
const infoBox = document.getElementById('infoBox');
videoTitle.textContent = match.name;
videoMeta.textContent = `Hotspot ID: ${match.hotspot_id}`;
infoBox.innerHTML = `
<strong>${match.name}</strong><br>
ID: ${match.hotspot_id}<br>
Match: ${(match.confidence * 100).toFixed(0)}%<br>
${match.video_ids ? `Videos: ${match.video_ids.join(', ')}` : 'No video mapped'}
`;
// Try to load video if video_ids exist
if (match.video_ids && match.video_ids.length > 0) {
const videoId = match.video_ids[0];
const videoUrl = VIDEO_BASE_URL ? `${VIDEO_BASE_URL}/${videoId}.mp4` : `${videoId}.mp4`;
videoPlayer.src = videoUrl;
videoPlayer.onerror = () => {
// Fallback to sample video
console.log('Video not found, using sample');
videoPlayer.src = 'https://commondatastorage.googleapis.com/gtv-videos-library/sample/BigBuckBunny.mp4';
};
} else {
// Use sample video
videoPlayer.src = 'https://commondatastorage.googleapis.com/gtv-videos-library/sample/BigBuckBunny.mp4';
}
videoPlayer.play();
isPlaying = true;
updatePlayPauseButton();
document.getElementById('captureMode').classList.add('hidden');
document.getElementById('playingMode').classList.remove('hidden');
}
function closeVideo() {
document.getElementById('videoPlayer').pause();
document.getElementById('playingMode').classList.add('hidden');
document.getElementById('captureMode').classList.remove('hidden');
document.getElementById('resultBox').classList.add('hidden');
currentContent = null;
isPlaying = false;
}
function togglePlayPause() {
const video = document.getElementById('videoPlayer');
if (isPlaying) {
video.pause();
} else {
video.play();
}
isPlaying = !isPlaying;
updatePlayPauseButton();
}
function updatePlayPauseButton() {
const btn = document.getElementById('playPauseBtn');
btn.textContent = isPlaying ? '⏸️ Pause' : '▶️ Play';
}
function toggleMute() {
const video = document.getElementById('videoPlayer');
const btn = document.getElementById('muteBtn');
video.muted = !video.muted;
btn.textContent = video.muted ? '🔇 Unmute' : '🔊 Mute';
}
// ═══════════════════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', async () => {
console.log('=== APP INITIALIZATION ===');
// Language change handler
document.getElementById('language').addEventListener('change', (e) => {
currentLanguage = e.target.value;
});
// Initialize in parallel
await Promise.all([
initOCR(),
checkApiHealth()
]);
// Auto-init camera on mobile
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) {
initCamera();
}
console.log('=== APP READY ===');
});
// Cleanup
window.addEventListener('beforeunload', async () => {
if (tesseractWorker) {
await tesseractWorker.terminate();
}
});
</script>
</body>
</html>