fix: show manual entry when OCR digits found but lookup fails

- Always show manual entry field regardless of OCR result or GPS presence
- Context-sensitive label: different text when OCR found digits vs none
- Add Assign button to manual lookup result (POST /api/assign-machine-id)
- Add Push GPS button when GPS available after assign
- Store _lastPhotoId / _lastPhotoGps for manual entry functions
- Add lightbox: full-screen photo viewer with pinch/scroll/double-tap zoom
This commit is contained in:
2026-05-25 20:35:41 -04:00
parent 9512819e3e
commit 04ac2fd7c7
+273 -12
View File
@@ -302,6 +302,41 @@ if ('serviceWorker' in navigator) {
.walk-timeline { font-size: 10px; color: var(--text2); margin-top: 4px; max-height: 120px; overflow-y: auto; }
.walk-timeline div { padding: 1px 0; }
/* Lightbox — full-screen photo viewer with zoom */
#lightbox {
display: none; position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,0.95);
touch-action: none; user-select: none; -webkit-user-select: none;
}
#lightbox.active { display: flex; align-items: center; justify-content: center; }
#lightboxClose {
position: fixed; top: 12px; right: 12px; z-index: 100000;
width: 36px; height: 36px; border-radius: 50%;
background: rgba(255,255,255,0.15); border: none;
color: #fff; font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
-webkit-tap-highlight-color: transparent;
}
#lightboxClose:active { background: rgba(255,255,255,0.3); }
#lightboxContainer {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
overflow: hidden; position: relative;
}
#lightboxImage {
max-width: 100%; max-height: 100%;
object-fit: contain; cursor: zoom-in;
transition: none;
will-change: transform;
}
#lightboxImage.zoomed { cursor: grab; }
#lightboxImage.zoomed:active { cursor: grabbing; }
#lightboxZoomHint {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.4); font-size: 11px;
pointer-events: none; transition: opacity 0.5s;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
@@ -359,7 +394,7 @@ if ('serviceWorker' in navigator) {
<!-- Detail card -->
<div id="detail">
<img id="detailPreview" alt="">
<img id="detailPreview" alt="" onclick="openLightbox(this.src)" style="cursor:zoom-in;">
<div id="detailExif"></div>
<div style="margin-top:10px;">
<button class="btn btn-primary btn-sm" onclick="uploadSelected()">🔍 Upload & Run OCR</button>
@@ -433,6 +468,8 @@ let selectedIdx = -1;
let currentFilter = 'all';
let bulkMap = null;
let bulkData = [];
let _lastPhotoId = null; // stored by uploadSelected for manualLookup assign
let _lastPhotoGps = null; // {lat, lng} for manualLookup push
// On load: fetch previously processed photos
document.addEventListener('DOMContentLoaded', loadPreviousPhotos);
@@ -843,17 +880,23 @@ async function uploadSelected() {
'<button class="btn btn-primary btn-xs" onclick="reprocessCurrent()">🔄 Re-run</button>' +
'</div>';
// Manual entry when GPS exists but OCR failed
if ((!ocr.match_5dash6 && !ocr.match_5plus) && data.exif && data.exif.gps) {
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);">' +
'<div style="font-size:11px;color:var(--text2);margin-bottom:4px;">✏️ GPS found but no machine ID in OCR. Enter it manually:</div>' +
'<div style="display:flex;gap:6px;">' +
'<input type="text" id="manualMid" placeholder="e.g. 12345-678901" style="flex:1;background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:13px;">' +
'<button class="btn btn-primary btn-xs" style="width:auto;white-space:nowrap;" onclick="manualLookup()">🔍 Lookup</button>' +
'</div>' +
'<div id="manualResult" style="margin-top:4px;"></div>' +
'</div>';
}
// Store for manualLookup assign
_lastPhotoId = data.photo_id;
_lastPhotoGps = data.exif && data.exif.gps ? {lat: data.exif.gps.lat, lng: data.exif.gps.lng} : null;
// Always show manual entry field — user can correct OCR or enter ID manually
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);">' +
'<div style="font-size:11px;color:var(--text2);margin-bottom:4px;">' +
(ocr.match_5dash6 || ocr.match_5plus
? '✏️ Try a different ID if the match above is wrong:'
: '✏️ No machine ID found in OCR. Enter it manually:') +
'</div>' +
'<div style="display:flex;gap:6px;">' +
'<input type="text" id="manualMid" placeholder="e.g. 12345-678901" style="flex:1;background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:13px;">' +
'<button class="btn btn-primary btn-xs" style="width:auto;white-space:nowrap;" onclick="manualLookup()">🔍 Lookup</button>' +
'</div>' +
'<div id="manualResult" style="margin-top:4px;"></div>' +
'</div>';
div.innerHTML = html;
} catch (e) {
@@ -876,6 +919,12 @@ function manualLookup() {
'<div class="asset-name">' + esc(a.name) + '</div>' +
'<div class="asset-meta"><span>🆔 ' + esc(a.machine_id) + '</span><span>📦 ' + esc(a.category) + '</span>' +
(a.status === 'active' ? '🟢 Active' : '⚪ ' + esc(a.status)) +
'</div>' +
'<div style="display:flex;gap:6px;margin-top:6px;">' +
'<button class="btn btn-primary btn-xs" style="width:auto;" onclick="assignManualId(' + JSON.stringify(a.machine_id) + ')">✅ Assign to Photo</button>' +
(_lastPhotoGps
? '<button class="btn btn-xs" style="width:auto;background:var(--green);color:#000;" onclick="pushGpsManual(' + a.id + ')">📤 Push GPS</button>'
: '') +
'</div></div>';
} else {
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ No asset found for <strong>' + esc(val) + '</strong></span>';
@@ -886,6 +935,54 @@ function manualLookup() {
});
}
// Assign a machine ID to the last analyzed photo (manual entry)
async function assignManualId(machineId) {
if (!_lastPhotoId || !machineId) { alert('No photo loaded'); return; }
const resultDiv = document.getElementById('manualResult');
try {
const r = await fetch('/api/assign-machine-id', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({photo_id: _lastPhotoId, machine_id: machineId})
});
const d = await r.json();
if (d.asset) {
let extra = '';
if (d.needs_gps && _lastPhotoGps) {
extra = '<button class="btn btn-xs" style="margin-top:4px;background:var(--green);color:#000;width:auto;" onclick="pushGpsManual(' + d.asset.id + ')">📤 Push GPS</button>';
}
resultDiv.innerHTML = '<div style="font-size:12px;color:var(--green);">✅ Assigned: <strong>' + esc(d.asset.name) + '</strong> 🆔 ' + esc(d.asset.machine_id) + '</div>' + extra +
'<div style="font-size:10px;color:var(--text3);margin-top:2px;">Saved to photo #' + _lastPhotoId + '</div>';
} else {
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ Could not assign: ' + esc(d.reason || 'unknown error') + '</span>';
}
} catch (e) {
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
}
}
// Push GPS from the last analyzed photo to a canteen asset
async function pushGpsManual(assetId) {
if (!_lastPhotoGps || !assetId) { alert('No GPS data available'); return; }
const resultDiv = document.getElementById('manualResult');
try {
const r = await fetch('/api/push-gps', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({asset_id: assetId, latitude: _lastPhotoGps.lat, longitude: _lastPhotoGps.lng})
});
const d = await r.json();
if (d.updated) {
resultDiv.innerHTML = '<div style="font-size:12px;color:var(--green);">✅ GPS pushed successfully! 📍 ' + Number(_lastPhotoGps.lat).toFixed(5) + ', ' + Number(_lastPhotoGps.lng).toFixed(5) + '</div>'
+ '<div style="font-size:10px;color:var(--text3);">Canteen DB updated</div>';
} else {
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ Push failed: ' + esc(d.reason || 'unknown') + '</span>';
}
} catch (e) {
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
}
}
async function reprocessCurrent() {
const eng = document.getElementById('reRunEng').value;
const model = document.getElementById('reRunModel').value;
@@ -1650,5 +1747,169 @@ if (navigator.serviceWorker) {
// Initialize banner on load
setTimeout(updateOfflineBanner, 500);
</script>
<!-- Lightbox — full-screen photo viewer with zoom -->
<div id="lightbox" onclick="closeLightbox()">
<button id="lightboxClose" onclick="event.stopPropagation();closeLightbox()"></button>
<div id="lightboxContainer" onclick="event.stopPropagation()">
<img id="lightboxImage" src="" alt="Full screen photo" draggable="false">
</div>
<div id="lightboxZoomHint">Pinch / scroll to zoom · Double-tap to fit</div>
</div>
<script>
// Lightbox — zoomable full-screen photo viewer
let _lbScale = 1, _lbMinScale = 1, _lbMaxScale = 6;
let _lbTranslateX = 0, _lbTranslateY = 0;
let _lbIsPanning = false, _lbStartX = 0, _lbStartY = 0;
let _lbLastDist = 0, _lbLastTouchX = 0, _lbLastTouchY = 0;
function openLightbox(src) {
const lb = document.getElementById('lightbox');
const img = document.getElementById('lightboxImage');
img.src = src;
_resetLightbox();
lb.classList.add('active');
document.body.style.overflow = 'hidden';
// Show zoom hint briefly
const hint = document.getElementById('lightboxZoomHint');
hint.style.opacity = '1';
setTimeout(() => { hint.style.opacity = '0'; }, 3000);
}
function closeLightbox() {
const lb = document.getElementById('lightbox');
lb.classList.remove('active');
document.body.style.overflow = '';
document.getElementById('lightboxImage').src = '';
}
function _resetLightbox() {
_lbScale = 1; _lbTranslateX = 0; _lbTranslateY = 0;
_lbIsPanning = false;
const img = document.getElementById('lightboxImage');
img.style.transform = '';
img.classList.remove('zoomed');
img.style.cursor = 'zoom-in';
}
// --- Mouse wheel zoom ---
document.getElementById('lightboxImage').addEventListener('wheel', function(e) {
e.preventDefault();
const rect = this.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const delta = e.deltaY > 0 ? -0.15 : 0.15;
_zoomLightbox(this, mx, my, delta);
}, { passive: false });
// --- Double-click/double-tap zoom toggle ---
document.getElementById('lightboxImage').addEventListener('dblclick', function(e) {
if (_lbScale > 1.2) {
_resetLightbox();
} else {
const rect = this.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
_lbScale = 2.5;
_lbTranslateX = -(mx * (_lbScale - 1)) / _lbScale;
_lbTranslateY = -(my * (_lbScale - 1)) / _lbScale;
_applyLightboxTransform(this);
}
});
// --- Pan via mouse drag ---
document.getElementById('lightboxImage').addEventListener('mousedown', function(e) {
if (_lbScale <= 1) return;
_lbIsPanning = true;
_lbStartX = e.clientX - _lbTranslateX;
_lbStartY = e.clientY - _lbTranslateY;
this.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', function(e) {
if (!_lbIsPanning) return;
_lbTranslateX = e.clientX - _lbStartX;
_lbTranslateY = e.clientY - _lbStartY;
_applyLightboxTransform(document.getElementById('lightboxImage'));
});
document.addEventListener('mouseup', function() {
_lbIsPanning = false;
const img = document.getElementById('lightboxImage');
if (img && _lbScale > 1) img.style.cursor = 'grab';
});
// --- Touch: pinch-zoom + pan ---
document.getElementById('lightboxContainer').addEventListener('touchstart', function(e) {
if (e.touches.length === 2) {
_lbLastDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
} else if (e.touches.length === 1) {
_lbIsPanning = true;
_lbStartX = e.touches[0].clientX - _lbTranslateX;
_lbStartY = e.touches[0].clientY - _lbTranslateY;
}
}, { passive: true });
document.getElementById('lightboxContainer').addEventListener('touchmove', function(e) {
const img = document.getElementById('lightboxImage');
if (e.touches.length === 2) {
e.preventDefault();
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const delta = (dist - _lbLastDist) * 0.005;
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
_zoomLightbox(img, cx - img.getBoundingClientRect().left, cy - img.getBoundingClientRect().top, delta);
_lbLastDist = dist;
} else if (e.touches.length === 1 && _lbIsPanning && _lbScale > 1) {
_lbTranslateX = e.touches[0].clientX - _lbStartX;
_lbTranslateY = e.touches[0].clientY - _lbStartY;
_applyLightboxTransform(img);
}
}, { passive: false });
document.getElementById('lightboxContainer').addEventListener('touchend', function() {
_lbIsPanning = false;
});
// --- Swipe down to close ---
let _lbSwipeStartY = 0;
document.getElementById('lightboxContainer').addEventListener('touchstart', function(e) {
if (e.touches.length === 1) _lbSwipeStartY = e.touches[0].clientY;
}, { passive: true });
document.getElementById('lightboxContainer').addEventListener('touchmove', function(e) {
if (e.touches.length === 1 && _lbScale <= 1) {
const dy = e.touches[0].clientY - _lbSwipeStartY;
if (dy > 100) closeLightbox();
}
}, { passive: true });
// --- Keyboard: Escape to close ---
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLightbox();
});
function _zoomLightbox(img, mx, my, delta) {
const newScale = Math.min(_lbMaxScale, Math.max(_lbMinScale, _lbScale + delta * _lbScale));
if (newScale === _lbScale) return;
const ratio = newScale / _lbScale;
_lbTranslateX = mx - ratio * (mx - _lbTranslateX);
_lbTranslateY = my - ratio * (my - _lbTranslateY);
_lbScale = newScale;
if (_lbScale <= 1) {
_resetLightbox();
} else {
_applyLightboxTransform(img);
}
}
function _applyLightboxTransform(img) {
img.style.transform = 'translate(' + _lbTranslateX + 'px, ' + _lbTranslateY + 'px) scale(' + _lbScale + ')';
img.classList.add('zoomed');
img.style.cursor = 'grab';
}
</script>
</body>
</html>