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:
+273
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user