Files
exif-test/static/index.html
T
shawn ec7478b287 Add machine ID search bar to EXIF Photos Map with highlight zoom
- Search input + Find/Clear buttons above the map
- Partial match search against all marker machine_ids
- Matched markers get amber pulsing highlight icon (22px)
- Auto-zoom to fit matched markers
- Clear button restores original view
- Store _exifGpsPhotos globally for search
- Store machineId on each marker at creation time
- Store default icon reference for proper restore on clear
- CSS: animated amber pulse ring around highlighted markers
2026-05-26 17:41:39 -04:00

2431 lines
109 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>EXIF Scanner — Find GPS Photos</title>
<script>
// Service Worker registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root {
--bg: #0f172a; --card: #1e293b; --card2: #334155;
--text: #f1f5f9; --text2: #94a3b8; --text3: #64748b;
--accent: #3b82f6; --accent2: #2563eb; --green: #22c55e;
--amber: #f59e0b; --red: #ef4444; --border: #334155;
--radius: 12px; --radius-sm: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text);
max-width: 480px; margin: 0 auto; padding: 16px;
min-height: 100dvh; -webkit-tap-highlight-color: transparent;
overflow-x: hidden;
}
h1 { font-size: 20px; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; }
.upload-area {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 28px 20px; text-align: center; cursor: pointer;
transition: border-color 0.2s; background: var(--card);
margin-bottom: 12px;
}
.upload-area:active { border-color: var(--accent); background: var(--card2); }
.upload-area .icon { font-size: 36px; display: block; margin-bottom: 6px; }
.upload-area .label { font-size: 15px; font-weight: 600; }
.upload-area .hint { font-size: 11px; color: var(--text2); margin-top: 4px; }
#summary {
display: none; background: var(--card); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 12px;
}
.summary-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px;
text-align: center;
}
.summary-num { font-size: 24px; font-weight: 800; }
.summary-label { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
.summary-gps { color: var(--green); }
.summary-nogps { color: var(--amber); }
.summary-total { color: var(--text); }
#gallery {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 12px;
}
.photo-card {
position: relative; border-radius: var(--radius-sm); overflow: hidden;
aspect-ratio: 1; background: var(--card); cursor: pointer;
border: 2px solid var(--border); transition: border-color 0.15s;
}
.photo-card.selected { border-color: var(--accent); }
.photo-card img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.photo-card .overlay {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 4px 6px; font-size: 10px; font-weight: 700;
display: flex; align-items: center; gap: 3px;
}
.overlay-gps { background: rgba(34,197,94,0.85); color: #000; }
.overlay-nogps { background: rgba(245,158,11,0.85); color: #000; }
.overlay-scanning { background: rgba(59,130,246,0.85); color: #fff; }
.photo-card .check {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: #fff;
display: none; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
}
.photo-card.selected .check { display: flex; }
#detail {
display: none; background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px;
}
#detailPreview { width: 100%; border-radius: var(--radius-sm); margin-bottom: 10px; }
.detail-section {
font-size: 13px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 8px;
display: flex; align-items: center; gap: 6px;
}
.badge {
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.badge-ok { background: var(--green); color: #000; }
.badge-warn { background: var(--amber); color: #000; }
.badge-fail { background: var(--red); color: #fff; }
.badge-info { background: var(--accent); color: #fff; }
.badge-dup { background: var(--red); color: #fff; }
.badge-sticker { background: #8b5cf6; color: #fff; }
.exif-row {
display: flex; justify-content: space-between; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 13px;
}
.exif-row:last-child { border-bottom: none; }
.exif-key { color: var(--text2); flex-shrink: 0; margin-right: 12px; }
.exif-val { color: var(--text); text-align: right; word-break: break-all; }
.gps-coords {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 16px; font-weight: 700; color: var(--green);
text-align: center; padding: 12px; background: var(--card2);
border-radius: var(--radius-sm); margin: 8px 0;
}
.btn {
display: block; width: 100%; padding: 14px; font-size: 15px;
font-weight: 600; border: none; border-radius: var(--radius-sm);
cursor: pointer; transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-outline { background: transparent; border: 2px solid var(--border); color: var(--text); }
.btn-danger { background: transparent; border: 2px solid var(--red); color: var(--red); }
.btn:disabled { opacity: 0.4; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 8px; }
.btn-sm { padding: 8px 14px; font-size: 12px; width: auto; }
.btn-xs { padding: 4px 10px; font-size: 11px; width: auto; }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid var(--text3); border-top-color: var(--text);
border-radius: 50%; animation: spin 0.6s linear infinite;
vertical-align: middle; margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty { color: var(--text3); font-size: 12px; text-align: center; padding: 12px; font-style: italic; }
#fileInput { display: none; }
#filterBar {
display: none; align-items: center; gap: 6px; margin-bottom: 10px;
}
.filter-chip {
font-size: 11px; font-weight: 600; padding: 5px 10px;
border-radius: 14px; border: 1px solid var(--border);
background: var(--card2); color: var(--text2);
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Toggle switch */
.switch { position: relative; display: inline-block; width: 32px; height: 18px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background: var(--card2); border: 1px solid var(--border); border-radius: 18px; transition: .2s; }
.slider::before { content: ''; position: absolute; height: 12px; width: 12px; left: 2px; bottom: 2px; background: var(--text3); border-radius: 50%; transition: .2s; }
.switch input:checked + .slider { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(14px); background: #fff; }
.asset-card {
background: var(--card2); border-radius: var(--radius-sm);
padding: 12px; margin-top: 10px; border-left: 3px solid var(--green);
}
.asset-card .asset-name { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
.asset-card .asset-meta {
font-size: 12px; color: var(--text2);
display: flex; flex-wrap: wrap; gap: 8px;
}
.asset-card .asset-meta span {
background: var(--card); padding: 2px 8px; border-radius: 10px;
white-space: nowrap;
}
.section {
background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px;
}
.ocr-text {
background: var(--card2); border-radius: var(--radius-sm);
padding: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px; white-space: pre-wrap; word-break: break-all;
max-height: 120px; overflow-y: auto; color: var(--text2);
margin-top: 6px;
}
.ocr-toggle {
display: flex; align-items: center; gap: 10px; margin-bottom: 8px;
font-size: 12px; color: var(--text2); flex-wrap: wrap;
}
.ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.ocr-toggle input[type="checkbox"] { accent-color: var(--accent); }
.engine-badge {
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
background: var(--card2); color: var(--text2); display: inline-block; margin-left: 4px;
}
.engine-badge.llm { background: var(--accent); color: #fff; }
.engine-badge.tesseract { background: var(--card2); color: var(--text2); }
#mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; }
.bulk-card {
background: var(--card); border-radius: var(--radius-sm); padding: 10px;
margin-bottom: 8px; display: flex; align-items: center; gap: 10px;
border-left: 3px solid var(--border);
}
.bulk-card.gps-ready { border-left-color: var(--green); }
.bulk-card .bulk-thumb { width: 48px; height: 48px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
.bulk-card .bulk-info { flex: 1; min-width: 0; }
.bulk-card .bulk-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bulk-card .bulk-meta { font-size: 11px; color: var(--text2); }
.bulk-card .bulk-action { flex-shrink: 0; }
.btn-push {
font-size: 11px; font-weight: 700; padding: 6px 12px; border-radius: 14px;
border: none; cursor: pointer; -webkit-tap-highlight-color: transparent;
background: var(--green); color: #000;
}
.btn-push:disabled { opacity: 0.4; }
.btn-push.done { background: var(--card2); color: var(--text3); }
.match-badge {
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.match-badge.matched { background: var(--green); color: #000; }
.match-badge.no-match { background: var(--border); color: var(--text2); }
.match-badge.no-gps { background: var(--amber); color: #000; }
.match-badge.has-gps { background: var(--card2); color: var(--text2); }
.match-badge.dup { background: var(--red); color: #fff; }
.match-badge.sticker { background: #8b5cf6; color: #fff; }
/* Reprocess card */
.reprocess-row {
display: flex; align-items: center; gap: 8px; margin-top: 8px;
padding-top: 8px; border-top: 1px solid var(--border);
}
.reprocess-row select {
background: var(--card2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 4px 8px; font-size: 11px; flex: 1;
}
.sticker-chip.active { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
/* Previous photos */
.prev-photo-card {
background: var(--card); border-radius: var(--radius-sm); padding: 10px;
margin-bottom: 8px; border-left: 3px solid var(--border);
font-size: 12px;
}
.prev-photo-card .prev-header {
display: flex; justify-content: space-between; align-items: center; gap: 6px;
margin-bottom: 4px;
}
.prev-photo-card .prev-filename {
font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.prev-photo-card .prev-time { font-size: 10px; color: var(--text3); white-space: nowrap; }
.prev-photo-card .prev-details { font-size: 11px; color: var(--text2); }
.prev-photo-card .prev-actions {
margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap;
align-items: center;
}
.prev-photo-card .prev-actions select,
.prev-photo-card .prev-actions input {
background: var(--card2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 3px 6px; font-size: 11px;
}
.prev-photo-card .prev-actions label {
display: flex; align-items: center; gap: 3px; font-size: 10px; color: var(--text2);
}
.prev-photo-card .prev-actions label input { accent-color: #8b5cf6; }
.prev-photo-card .sticker-color {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px;
}
.prev-thumb {
width: 100%; aspect-ratio: 1; object-fit: cover;
border-radius: var(--radius-sm); margin-bottom: 8px;
cursor: zoom-in; display: block; background: var(--card2);
}
/* Inline machine ID editing in Previous Photos */
.prev-mid-editable {
cursor: text; border-bottom: 1px dashed var(--text3);
transition: border-color 0.15s, color 0.15s;
padding: 0 2px;
}
.prev-mid-editable:hover { border-color: var(--accent); color: var(--text); }
.prev-mid-editable::after { content: ' ✏️'; font-size: 9px; opacity: 0.5; }
.prev-mid-editable:hover::after { opacity: 1; }
.prev-mid-input {
background: var(--card2); color: var(--text); border: 1px solid var(--accent);
border-radius: 6px; padding: 2px 6px; font-size: 11px; font-weight: 700;
width: 100px; outline: none;
}
.prev-mid-input:focus { border-color: var(--accent2); box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }
.export-modal-backdrop { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; }
.export-modal { background: var(--card); border-radius: var(--radius); padding: 20px; max-width: 320px; width: 90%; text-align: center; }
.export-modal h3 { margin-bottom: 12px; }
.export-modal .btn { margin-bottom: 8px; }
.ocr-edit { cursor: pointer; font-weight: 600; color: var(--accent); border-bottom: 1px dashed var(--accent); }
.ocr-input { background: var(--card2); color: var(--text); border: 1px solid var(--accent); border-radius: 6px; padding: 2px 6px; font-size: 12px; width: 140px; }
.drift-ok { color: var(--green); } .drift-warn { color: var(--amber); } .drift-bad { color: var(--red); }
.map-toggle { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 11px; color: var(--text2); }
.map-toggle label { cursor: pointer; display: flex; align-items: center; gap: 4px; }
.map-toggle input[type=checkbox] { accent-color: var(--accent); }
.batch-bar { position: sticky; bottom: 0; background: var(--card); border-top: 2px solid var(--accent); padding: 10px; display: none; align-items: center; gap: 8px; z-index: 100; margin-top: 8px; border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
.batch-bar input[type=text] { flex: 1; background: var(--card2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 12px; }
.bulk-card.batch-mode { padding-left: 4px; }
.bulk-card input[type=checkbox] { accent-color: var(--accent); margin-right: 4px; }
.session-picker { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 12px; color: var(--text2); }
.session-picker select, .session-picker input { background: var(--card2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font-size: 11px; }
.session-card { background: var(--card2); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 6px; cursor: pointer; border-left: 3px solid var(--accent); }
.session-card .sess-name { font-weight: 600; font-size: 13px; }
.session-card .sess-meta { font-size: 10px; color: var(--text3); margin-top: 2px; }
.session-card .sess-photos { margin-top: 6px; display: none; }
.session-card.open .sess-photos { display: block; }
.heatmap-sliders { display: flex; gap: 8px; margin: 4px 0 6px; align-items: center; font-size: 10px; color: var(--text3); }
.heatmap-sliders input[type=range] { flex: 1; accent-color: var(--accent); }
.nearby-line { cursor: pointer; font-size: 11px; margin-top: 2px; }
.nearby-list { font-size: 10px; color: var(--text2); margin-top: 2px; padding-left: 4px; }
.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;
}
/* EXIF Photos Map markers */
.exif-marker-icon {
background: none !important;
border: none !important;
}
.exif-marker-dot {
width: 12px; height: 12px;
background: var(--accent);
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
.exif-marker-highlight {
width: 20px !important; height: 20px !important;
background: #f59e0b !important;
border: 3px solid #fff !important;
box-shadow: 0 0 0 3px rgba(245,158,11,0.4), 0 2px 8px rgba(0,0,0,0.5) !important;
animation: exifSearchPulse 1.5s ease-in-out infinite;
}
@keyframes exifSearchPulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(245,158,11,0.4), 0 2px 8px rgba(0,0,0,0.5); }
50% { box-shadow: 0 0 0 6px rgba(245,158,11,0.25), 0 2px 12px rgba(0,0,0,0.6); }
}
.leaflet-popup-content-wrapper {
border-radius: 10px !important;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
</head>
<body>
<h1>📸 EXIF Scanner</h1>
<p class="subtitle">Select multiple photos — instantly see which have GPS</p>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<span class="icon">📱</span>
<span class="label">Tap to Select Photos</span>
<span class="hint">Pick from gallery — multi-select enabled</span>
</div>
<div id="offlineBanner" style="display:none;background:var(--amber);color:#000;text-align:center;padding:8px;border-radius:var(--radius-sm);margin-bottom:8px;font-size:12px;font-weight:600;">
📡 Offline — <span id="offlineCount">0</span> photos queued
</div>
<input type="file" id="fileInput" accept="image/*" multiple>
<!-- Options -->
<div class="ocr-toggle" style="margin-bottom:4px;">
<label style="gap:6px;">
<span style="font-size:11px;">OCR Engine:</span>
<select id="ocrEngine" onchange="onOcrToggle()" style="background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:3px 8px;font-size:11px;">
<option value="tesseract">🖥️ Tesseract</option>
<option value="llm" selected>🧠 LLM (mimo-v2-omni)</option>
<option value="google">🌐 Google Gemini</option>
</select>
</label>
<label>
<input type="checkbox" id="stickerToggle" onchange="onOcrToggle()">
🏷️ Sticker Mode
</label>
<span style="font-size:10px;color:var(--text3);" id="modelLabel">mimo-v2-omni</span>
</div>
<!-- Summary stats -->
<div id="summary">
<div class="summary-grid">
<div><div class="summary-num summary-total" id="sumTotal">0</div><div class="summary-label">Total</div></div>
<div><div class="summary-num summary-gps" id="sumGps">0</div><div class="summary-label">📍 Have GPS</div></div>
<div><div class="summary-num summary-nogps" id="sumNoGps">0</div><div class="summary-label">⚠️ No GPS</div></div>
<div style="display:flex;align-items:center;gap:6px;justify-content:center;">
<label class="switch">
<input type="checkbox" id="gpsToggle" onchange="renderGallery()">
<span class="slider"></span>
</label>
<span style="font-size:11px;color:var(--text2);white-space:nowrap;">📍 GPS only</span>
</div>
</div>
</div>
<!-- Filter chips -->
<div id="filterBar">
<span class="filter-chip active" onclick="setFilter('all')">All</span>
<span class="filter-chip" onclick="setFilter('gps')">📍 GPS</span>
<span class="filter-chip" onclick="setFilter('nogps')">⚠️ No GPS</span>
</div>
<!-- Photo grid -->
<div id="gallery"></div>
<!-- Detail card -->
<div id="detail">
<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>
</div>
</div>
<!-- Server results -->
<div class="section" id="serverSection" style="display:none;">
<div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div>
<div id="serverResults"></div>
</div>
<!-- Session picker -->
<div class="session-picker" id="sessionPicker" style="display:none;">
<span>📂 Session:</span>
<input type="text" id="sessionName" placeholder="Building B Floor 2" style="flex:1;" value="">
<select id="sessionBuilding" onchange="onSessionChange()"><option value="">Building...</option></select>
<select id="sessionFloor"><option value="">Floor...</option></select>
<button class="btn btn-xs" style="background:var(--card2);color:var(--text);border:1px solid var(--border);" onclick="createSessionFromPicker()">+ New</button>
</div>
<!-- Bulk process button -->
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
🔍 Bulk Process GPS Photos
</button>
<!-- Bulk results -->
<div class="section" id="bulkSection" style="display:none;">
<div class="detail-section">📊 Bulk Results
<span class="match-badge" id="bulkSummary" style="margin-left:auto;"></span>
</div>
<div class="map-toggle" id="mapToggles" style="display:none;">
<label><input type="checkbox" id="walkPathToggle" onchange="toggleWalkPath()"> 🗺️ Walk Path</label>
<label><input type="checkbox" id="heatmapToggle" onchange="toggleHeatmap()"> 🔥 Heatmap</label>
</div>
<div class="heatmap-sliders" id="heatmapSliders" style="display:none;">
<span>Radius</span><input type="range" id="heatRadius" min="5" max="50" value="15" oninput="updateHeatmap()">
<span>Intensity</span><input type="range" id="heatIntensity" min="1" max="10" value="5" oninput="updateHeatmap()">
</div>
<div id="mapContainer"></div>
<div style="display:flex;gap:6px;margin-bottom:6px;">
<button class="btn btn-xs" id="exportBtn" style="background:var(--accent);color:#fff;display:none;" onclick="showExportModal()">📊 Export</button>
<button class="btn btn-xs" id="batchBtn" style="background:transparent;border:1px solid var(--border);color:var(--text2);display:none;" onclick="toggleBatchMode()">📎 Batch Select</button>
</div>
<div id="bulkResults"></div>
<div class="batch-bar" id="batchBar">
<span id="batchCount" style="font-size:11px;white-space:nowrap;">📎 0 selected</span>
<input type="text" id="batchMachineId" placeholder="Machine ID..." disabled>
<button class="btn btn-xs btn-primary" id="batchLookup" disabled onclick="batchLookup()">🔍</button>
<button class="btn btn-xs" id="batchPushGps" style="background:var(--green);color:#000;display:none;" onclick="batchPushGps()">📤 Push GPS</button>
</div>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button>
</div>
<!-- Sessions -->
<div class="section" id="sessionsSection" style="display:none;">
<div class="detail-section">📋 Sessions <span class="badge badge-info" id="sessionCount">0</span></div>
<div id="sessionsList"></div>
</div>
<!-- Previously processed -->
<div class="section" id="prevSection" style="display:none;">
<div class="detail-section">📂 Previously Processed <span class="badge badge-info" id="prevCount">0</span></div>
<div id="prevResults"></div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
SECTION: EXIF Photos Map — all GPS-tagged photos on a map
═══════════════════════════════════════════════════════════════════════ -->
<div class="section" id="exifMapSection">
<div class="detail-section">
📍 EXIF Photos Map
<span class="badge badge-info" id="exifMapCount">0</span>
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--accent);color:var(--accent);float:right;" onclick="loadExifMap()">🔄 Refresh</button>
</div>
<div class="map-toggle" style="margin-bottom:6px;">
<label><input type="checkbox" id="exifHeatmapToggle" onchange="toggleExifHeatmap()"> 🔥 Heatmap</label>
<span style="font-size:10px;color:var(--text3);margin-left:auto;" id="exifMapInfo">Loading...</span>
</div>
<div class="exif-map-search" style="display:flex;gap:6px;margin-bottom:6px;">
<input type="text" id="exifMachineSearch" placeholder="🔍 Search machine ID..." style="flex:1;padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--card2);color:var(--text);font-size:13px;outline:none;" onkeydown="if(event.key==='Enter') searchExifMapByMachine()">
<button class="btn btn-xs" style="background:var(--accent);color:#fff;border:none;white-space:nowrap;" onclick="searchExifMapByMachine()">🔎 Find</button>
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--border);color:var(--text2);white-space:nowrap;" onclick="clearExifSearch()">✕ Clear</button>
</div>
<div id="exifSearchStatus" style="font-size:11px;color:var(--text3);margin-bottom:4px;min-height:16px;"></div>
<div id="exifMapContainer" style="height:350px;border-radius:var(--radius-sm);overflow:hidden;margin-bottom:8px;"></div>
<div id="exifPhotoCountRow" style="display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--text2);">
<span>📸 <span id="exifTotalPhotos">0</span> total</span>
<span>📍 <span id="exifGpsPhotos" style="color:var(--green);font-weight:600;">0</span> with GPS</span>
<span>⚠️ <span id="exifNoGpsPhotos" style="color:var(--amber);">0</span> no GPS</span>
</div>
<div style="margin-top:6px;">
<button class="btn btn-outline btn-xs" onclick="openExifMapFullscreen()" style="width:100%;">🗺️ Open Full Map</button>
</div>
</div>
<script>
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
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
// EXIF Photos Map globals
let exifMap = null;
let exifHeatLayer = null;
let exifMarkers = []; // L.marker objects (parallel to _exifGpsPhotos)
let _exifGpsPhotos = []; // photo data with GPS, used for search
// On load: fetch previously processed photos + EXIF map
document.addEventListener('DOMContentLoaded', function() {
loadPreviousPhotos();
setTimeout(loadExifMap, 500);
});
function loadPreviousPhotos() {
fetch('/api/photos?limit=30')
.then(r => r.json())
.then(data => {
if (!data.photos || !data.photos.length) return;
const section = document.getElementById('prevSection');
const div = document.getElementById('prevResults');
section.style.display = 'block';
document.getElementById('prevCount').textContent = data.photos.length;
div.innerHTML = data.photos.map(p => renderPrevCard(p)).join('');
// Init maps and asset lookups after rendering
data.photos.forEach(p => {
if (p.gps_lat && p.gps_lng) {
initPrevMap(p);
}
if (p.machine_id) {
lookupPrevAsset(p);
}
});
})
.catch(() => {});
}
function initPrevMap(p) {
const el = document.getElementById('prevMap' + p.id);
if (!el || typeof L === 'undefined') return;
// Clear any existing map
el.innerHTML = '';
el.style.background = 'var(--card2)';
try {
const map = L.map(el, { zoomControl: true }).setView([p.gps_lat, p.gps_lng], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OSM', maxZoom: 19
}).addTo(map);
const label = p.machine_id ? '<b>' + esc(p.machine_id) + '</b>' : '📸 Photo location';
L.marker([p.gps_lat, p.gps_lng])
.addTo(map)
.bindPopup(label + '<br>📍 ' + Number(p.gps_lat).toFixed(5) + ', ' + Number(p.gps_lng).toFixed(5));
setTimeout(() => { try { map.invalidateSize(); } catch(e) {} }, 400);
// Store map ref for later cleanup
if (!window._prevMaps) window._prevMaps = {};
window._prevMaps[p.id] = map;
} catch (e) { /* ignore map errors */ }
}
function lookupPrevAsset(p) {
const el = document.getElementById('prevAsset' + p.id);
const infoEl = document.getElementById('prevMachineInfo' + p.id);
if (!el) return;
fetch('/api/lookup?machine_id=' + encodeURIComponent(p.machine_id))
.then(r => r.json())
.then(data => {
if (data.found) {
const a = data.asset;
el.innerHTML = '🆔 ' + esc(a.machine_id) + ' · <strong>' + esc(a.name) + '</strong>' +
(a.category ? ' · 📦 ' + esc(a.category) : '') +
(a.status === 'active' ? ' 🟢 Active' : '');
if (infoEl) {
let info = '';
if (a.building_name) info += '🏢 ' + esc(a.building_name) + ' ';
if (a.floor) info += '📶 Floor ' + esc(a.floor) + ' ';
if (a.room) info += '🚪 ' + esc(a.room) + ' ';
if (a.address) info += '🏠 ' + esc(a.address);
if (a.make || a.model) info += '🔧 ' + esc(a.make || '') + ' ' + esc(a.model || '');
infoEl.innerHTML = info ? info : '(No location details)';
}
} else {
el.innerHTML = '⚠️ No asset match for <strong>' + esc(p.machine_id) + '</strong>';
}
})
.catch(() => { el.innerHTML = '⚠️ Lookup failed'; });
}
function renderPrevCard(p) {
const hasMatch = p.machine_id ? 'matched' : 'no-match';
const matchText = p.machine_id ? 'matched' : (p.ocr_match_5plus ? 'digits found' : 'no match');
const engine = p.ocr_engine || 'tesseract';
const engCls = engine === 'llm' || engine === 'llm_batch' ? 'llm' : engine === 'google' ? 'llm' : 'tesseract';
const time = p.created_at ? new Date(p.created_at + 'Z').toLocaleString() : '';
const colorHtml = p.sticker_color
? `<span class="sticker-color" style="background:${p.sticker_color === 'green' ? 'var(--green)' : p.sticker_color === 'orange' ? 'var(--amber)' : p.sticker_color === 'yellow' ? '#eab308' : 'var(--text3)'}"></span>`
: '';
const hasCoords = p.gps_lat && p.gps_lng;
const photoUrl = `/api/photos/${p.id}/file`;
return `<div class="prev-photo-card">
<img class="prev-thumb" src="${photoUrl}" alt="" onclick="openLightbox('${photoUrl}')" loading="lazy">
<div class="prev-header">
<span class="prev-filename">${esc(p.orig_filename)}</span>
<span class="prev-time">${time}</span>
</div>
<div class="prev-details">
Machine: <span id="midSpan${p.id}" class="prev-mid-editable" data-mid="${esc(p.machine_id || '')}" onclick="editMachineIdInline(${p.id})">${esc(p.machine_id || '—')}</span>
<span class="match-badge ${hasMatch}">${esc(matchText)}</span>
<span class="engine-badge ${engCls}">${esc(engine)}${p.ocr_model ? ' ' + esc(p.ocr_model) : ''}</span>
${colorHtml}
${hasCoords ? ` <span class="match-badge has-gps">📍 ${Number(p.gps_lat).toFixed(4)}, ${Number(p.gps_lng).toFixed(4)}</span>` : ''}
</div>
<div class="prev-actions">
<select id="reEng${p.id}">
<option value="tesseract">Tesseract</option>
<option value="llm" ${engine.startsWith('llm') ? 'selected' : ''}>LLM</option>
<option value="google" ${engine === 'google' ? 'selected' : ''}>🌐 Google</option>
</select>
<select id="reModel${p.id}">
<option value="">default</option>
<option value="mimo-v2-omni">mimo-v2-omni</option>
<option value="mimo-v2-pro">mimo-v2-pro</option>
<option value="kimi-k2.5">kimi-k2.5</option>
<option value="glm-5.1">glm-5.1</option>
<option value="gemini-2.5-flash">gemini-2.5-flash</option>
</select>
<label><input type="checkbox" id="reSticker${p.id}"> 🏷️</label>
<button class="btn btn-primary btn-xs" onclick="reprocessPhoto(${p.id})">🔄 Re-run</button>
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--red);color:var(--red);" onclick="deletePhoto(${p.id})">🗑️</button>
<button class="btn btn-xs" style="background:transparent;border:1px solid var(--amber);color:var(--amber);" onclick="resetPhoto(${p.id})">🔄 Reset</button>
</div>
${hasCoords ? `<div style="margin-top:6px;">
<div id="prevMap${p.id}" class="mini-map" style="height:${p.machine_id ? 120 : 100}px;border-radius:6px;background:var(--card2);"></div>
</div>` : ''}
<div id="prevAsset${p.id}" class="prev-asset-info" style="font-size:11px;margin-top:4px;color:var(--text2);"></div>
<div id="prevMachineInfo${p.id}" class="prev-asset-info" style="margin-top:2px;"></div>
</div>`;
}
/* ── Inline machine ID editing in Previous Photos ── */
function editMachineIdInline(photoId) {
const span = document.getElementById('midSpan' + photoId);
if (!span) return;
const currentVal = span.dataset.mid || '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'prev-mid-input';
input.value = currentVal;
input.placeholder = 'Enter machine ID';
input.dataset.photoId = photoId;
input.dataset.original = currentVal;
span.replaceWith(input);
input.focus();
input.select();
input.addEventListener('blur', () => saveMachineIdInline(input, photoId));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') { saveMachineIdInline(input, photoId, true); }
});
}
function saveMachineIdInline(input, photoId, cancel) {
const orig = input.dataset.original || '';
const val = cancel ? orig : input.value.trim();
// Restore span first so UI is consistent
function restoreSpan(mid, label) {
const span = document.createElement('span');
span.id = 'midSpan' + photoId;
span.className = 'prev-mid-editable';
span.dataset.mid = mid || '';
span.textContent = label || mid || '—';
span.onclick = () => editMachineIdInline(photoId);
input.replaceWith(span);
return span;
}
if (cancel || val === orig) {
restoreSpan(orig, orig || '—');
return;
}
fetch('/api/assign-machine-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ photo_id: photoId, machine_id: val })
})
.then(r => r.json())
.then(data => {
restoreSpan(val, val);
// Update the badge
const card = document.getElementById('midSpan' + photoId)?.closest('.prev-photo-card');
if (card) {
const badge = card.querySelector('.match-badge');
if (badge) {
badge.textContent = 'matched';
badge.className = 'match-badge matched';
}
}
// Update asset info — create elements if they don't exist yet
let assetEl = document.getElementById('prevAsset' + photoId);
let infoEl = document.getElementById('prevMachineInfo' + photoId);
if (!assetEl && card) {
assetEl = document.createElement('div');
assetEl.id = 'prevAsset' + photoId;
assetEl.style.cssText = 'font-size:11px;margin-top:4px;color:var(--text2);';
card.appendChild(assetEl);
}
if (!infoEl && card) {
infoEl = document.createElement('div');
infoEl.id = 'prevMachineInfo' + photoId;
infoEl.style.cssText = 'margin-top:2px;';
card.appendChild(infoEl);
}
if (data.asset) {
const a = data.asset;
if (assetEl) {
assetEl.innerHTML = '🆔 ' + esc(a.machine_id) + ' · <strong>' + esc(a.name) + '</strong>' +
(a.category ? ' · 📦 ' + esc(a.category) : '') +
(a.status === 'active' ? ' 🟢 Active' : '');
}
if (infoEl) {
let info = '';
if (a.building_name) info += '🏢 ' + esc(a.building_name) + ' ';
if (a.floor) info += '📶 Floor ' + esc(a.floor) + ' ';
if (a.room) info += '🚪 ' + esc(a.room) + ' ';
if (a.address) info += '🏠 ' + esc(a.address);
if (a.make || a.model) info += '🔧 ' + esc(a.make || '') + ' ' + esc(a.model || '');
infoEl.innerHTML = info || '(No location details)';
}
} else {
if (assetEl) assetEl.innerHTML = '⚠️ No asset match for <strong>' + esc(val) + '</strong>';
}
})
.catch(() => {
restoreSpan(orig, orig || '—');
// Show error toast
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:var(--red);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;z-index:9999;animation:fadeIn 0.2s;';
toast.textContent = 'Failed to save machine ID';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
});
}
async function resetPhoto(photoId) {
if (!confirm('Reset this entry and re-process from scratch?')) return;
// Find the card to get the original filename
const card = document.querySelector(`#reEng${photoId}`)?.closest('.prev-photo-card');
const filename = card?.querySelector('.prev-filename')?.textContent?.trim() || 'photo.jpg';
try {
// 1. Fetch the original file BEFORE reset deletes it
const fileResp = await fetch(`/api/photos/${photoId}/file`);
if (!fileResp.ok) throw new Error('Could not load photo file');
const blob = await fileResp.blob();
// 2. Reset in DB (deletes DB record + file from disk)
const resetResp = await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' });
if (!resetResp.ok) throw new Error('Reset failed');
// 3. Rebuild a File object and scan it like a fresh upload
const file = new File([blob], filename, { type: blob.type || 'image/jpeg' });
const photoData = await scanPhoto(file);
photoData.file = file;
// 4. Clear current state and add to gallery
resetAll();
allPhotos = [photoData];
updateSummary();
renderGallery();
// 5. Show the detail view with preview image + manual entry
showDetail(0);
document.getElementById('serverSection').style.display = 'none';
document.getElementById('detail').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
alert('Reset failed: ' + e.message);
}
}
async function resetAndReupload(photoId) {
if (!photoId) { alert('No photo ID available'); return; }
if (!confirm('Reset this entry and re-process as fresh?')) return;
try {
// Try to use local file first, fall back to fetching from server
let file = null;
if (selectedIdx >= 0 && allPhotos[selectedIdx]?.file) {
file = allPhotos[selectedIdx].file;
} else {
const fileResp = await fetch(`/api/photos/${photoId}/file`);
if (fileResp.ok) {
const blob = await fileResp.blob();
file = new File([blob], 'photo.jpg', { type: blob.type || 'image/jpeg' });
}
}
if (!file) throw new Error('No photo file available');
await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' });
// Scan and show in detail view
const photoData = await scanPhoto(file);
photoData.file = file;
resetAll();
allPhotos = [photoData];
updateSummary();
renderGallery();
showDetail(0);
document.getElementById('serverSection').style.display = 'none';
document.getElementById('detail').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
alert('Reset failed: ' + e.message);
}
}
async function reprocessPhoto(photoId) {
const eng = document.getElementById('reEng' + photoId).value;
const model = document.getElementById('reModel' + photoId).value;
const sticker = document.getElementById('reSticker' + photoId).checked;
let params = '?ocr_engine=' + eng;
if (model) params += '&ocr_model=' + model;
if (sticker) params += '&sticker_mode=true';
const card = document.querySelector(`#reEng${photoId}`).closest('.prev-photo-card');
const actions = card.querySelector('.prev-actions');
actions.innerHTML = '<span class="spinner" style="width:14px;height:14px;"></span> Reprocessing...';
try {
const resp = await fetch(`/api/photos/${photoId}/reprocess${params}`, { method: 'POST' });
const data = await resp.json();
// Refresh the card
const updated = { ...data, orig_filename: card.querySelector('.prev-filename')?.textContent || 'Photo' };
// Just reload the full list
loadPreviousPhotos();
} catch (e) {
actions.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Failed</span>';
}
}
async function deletePhoto(photoId) {
if (!confirm('Delete this entry and its file?')) return;
try {
const resp = await fetch(`/api/photos/${photoId}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete failed');
loadPreviousPhotos();
} catch (e) {
alert('Delete failed: ' + e.message);
}
}
// --- File handling ---
document.getElementById('fileInput').addEventListener('change', async function(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
document.getElementById('uploadArea').style.display = 'none';
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('clearBtn').style.display = 'block';
document.getElementById('gallery').innerHTML = files.map((_, i) =>
'<div class="photo-card" id="card' + i + '">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:24px;">' +
'<span class="spinner" style="width:24px;height:24px;"></span>' +
'</div>' +
'<div class="overlay overlay-scanning">Scanning...</div>' +
'</div>'
).join('');
const results = [];
const batchSize = 2;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(f => scanPhoto(f)));
results.push(...batchResults);
}
allPhotos = results;
updateSummary();
renderGallery();
});
async function scanPhoto(file) {
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
result.thumb = URL.createObjectURL(file);
try {
const exif = await exifr.parse(file);
if (exif && Object.keys(exif).length > 0) {
result.exif = {};
for (const [k, v] of Object.entries(exif)) {
if (v !== undefined && v !== null) result.exif[k] = v;
}
if (exif.latitude !== undefined && exif.longitude !== undefined) {
result.hasGps = true;
result.lat = exif.latitude;
result.lng = exif.longitude;
}
}
} catch (_) {}
return result;
}
function updateSummary() {
const gps = allPhotos.filter(p => p.hasGps).length;
const nogps = allPhotos.length - gps;
document.getElementById('sumTotal').textContent = allPhotos.length;
document.getElementById('sumGps').textContent = gps;
document.getElementById('sumNoGps').textContent = nogps;
document.getElementById('summary').style.display = 'block';
document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none';
}
function updateFilterChips() {
const chipMap = { all: 'All', gps: '📍 GPS', nogps: '⚠️ No GPS' };
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
const target = Array.from(document.querySelectorAll('.filter-chip'))
.find(c => c.textContent.trim() === chipMap[currentFilter]);
if (target) target.classList.add('active');
}
function setFilter(f) {
currentFilter = f;
updateFilterChips();
renderGallery();
}
function renderGallery() {
let filtered = allPhotos;
if (currentFilter === 'gps') filtered = allPhotos.filter(p => p.hasGps);
else if (currentFilter === 'nogps') filtered = allPhotos.filter(p => !p.hasGps);
// Apply GPS-only toggle (overrides chips for hide-no-gps use case)
const gpsToggle = document.getElementById('gpsToggle');
if (gpsToggle && gpsToggle.checked) {
filtered = filtered.filter(p => p.hasGps);
}
// Sync filter chip visual state with currentFilter
updateFilterChips();
const gallery = document.getElementById('gallery');
gallery.innerHTML = filtered.map(p => {
const idx = allPhotos.indexOf(p);
const selClass = idx === selectedIdx ? ' selected' : '';
const overlayClass = p.hasGps ? 'overlay-gps' : 'overlay-nogps';
const overlayText = p.hasGps ? '📍 ' + p.lat.toFixed(4) + ', ' + p.lng.toFixed(4) : '⚠️ No GPS';
return '<div class="photo-card' + selClass + '" id="card' + idx + '" onclick="showDetail(' + idx + ')">' +
'<img src="' + p.thumb + '" alt="">' +
'<div class="check">✓</div>' +
'<div class="overlay ' + overlayClass + '">' + overlayText + '</div>' +
'</div>';
}).join('');
if (filtered.length === 0) {
gallery.innerHTML = '<div class="empty" style="grid-column:1/-1;">No photos match filter</div>';
}
}
function showDetail(idx) {
selectedIdx = idx;
const p = allPhotos[idx];
if (!p) return;
renderGallery();
const detail = document.getElementById('detail');
detail.style.display = 'block';
document.getElementById('detailPreview').src = p.thumb;
document.getElementById('serverSection').style.display = 'none';
let html = '';
html += '<div class="exif-row"><span class="exif-key">Name</span><span class="exif-val">' + esc(p.file.name) + '</span></div>';
html += '<div class="exif-row"><span class="exif-key">Size</span><span class="exif-val">' + formatSize(p.file.size) + '</span></div>';
if (p.exif) {
const priority = ['Make', 'Model', 'DateTimeOriginal', 'Orientation'];
const keys = Object.keys(p.exif);
const sorted = [...new Set([...priority.filter(k => k in p.exif), ...keys.filter(k => !priority.includes(k))])];
for (const k of sorted) {
const v = p.exif[k];
const label = k.replace(/([A-Z])/g, ' $1').trim();
const display = Array.isArray(v) ? v.map(x => typeof x === 'number' ? x.toFixed(3) : x).join(', ') : String(v);
html += '<div class="exif-row"><span class="exif-key">' + esc(label) + '</span><span class="exif-val">' + esc(display) + '</span></div>';
}
if (p.hasGps) {
html = '<div class="gps-coords">📍 ' + p.lat.toFixed(6) + ', ' + p.lng.toFixed(6) + '</div>' + html;
} else {
html = '<div class="gps-coords" style="color:var(--amber);">⚠️ No GPS in EXIF</div>' + html;
}
} else {
html += '<div class="empty" style="margin-top:8px;">No EXIF data at all — file may have been stripped</div>';
}
document.getElementById('detailExif').innerHTML = html;
detail.scrollIntoView({ behavior: 'smooth' });
}
async function uploadSelected() {
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
const file = allPhotos[selectedIdx].file;
// Offline: queue for later
if (!navigator.onLine) {
await photoQueue.add(file, file.name || 'photo.jpg');
updateOfflineBanner();
const section = document.getElementById('serverSection');
const div = document.getElementById('serverResults');
section.style.display = 'block';
div.innerHTML = '<div style="text-align:center;padding:12px;">📡 Photo queued — will upload when connection returns</div>';
section.scrollIntoView({ behavior: 'smooth' });
return;
}
const section = document.getElementById('serverSection');
const div = document.getElementById('serverResults');
section.style.display = 'block';
div.innerHTML = '<div style="text-align:center;padding:12px;"><span class="spinner"></span> Uploading & analyzing...</div>';
section.scrollIntoView({ behavior: 'smooth' });
const fd = new FormData();
fd.append('file', file, file.name || 'photo.jpg');
try {
const resp = await fetch('/api/analyze' + getOcrParams(), { method: 'POST', body: fd });
const data = await resp.json();
let html = '';
if (data.duplicate) {
html += '<div style="margin-bottom:8px;display:flex;align-items:center;gap:6px;"><span class="match-badge dup">♻️ Already processed — skipped</span>' +
'<button class="btn btn-xs" style="background:transparent;border:1px solid var(--amber);color:var(--amber);margin-left:auto;" onclick="resetAndReupload(' + (data.photo_id || 'null') + ')">🔄 Reset & Re-process</button></div>';
}
html += '<div class="exif-row"><span class="exif-key">Saved as</span><span class="exif-val">' + esc(data.saved_as) + '</span></div>';
html += '<div class="exif-row"><span class="exif-key">File size</span><span class="exif-val">' + data.file_size_kb + ' KB</span></div>';
const exif = data.exif;
if (exif.gps) {
html = '<div class="gps-coords" style="margin:8px 0;">📍 Server GPS: ' + exif.gps.lat + ', ' + exif.gps.lng + '</div>' + html;
} else if (exif.has_exif) {
html = '<div class="gps-coords" style="color:var(--amber);margin:8px 0;">⚠️ Server found EXIF but no GPS</div>' + html;
} else {
html = '<div class="gps-coords" style="color:var(--red);margin:8px 0;">❌ Server found no EXIF — stripped during upload</div>' + html;
}
if (exif.has_exif) {
for (const [k, v] of Object.entries(exif.tags).slice(0, 8)) {
html += '<div class="exif-row"><span class="exif-key">' + esc(k) + '</span><span class="exif-val">' + esc(v) + '</span></div>';
}
}
// OCR
const ocr = data.ocr;
const engineCls = ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'llm' : 'tesseract';
let ocrBadge = '<span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract');
if (ocr.llm_model) ocrBadge += ' ' + esc(ocr.llm_model);
if (ocr.sticker_color) ocrBadge += ' 🏷️' + esc(ocr.sticker_color);
ocrBadge += '</span>';
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR ' + ocrBadge + '</div>';
if (ocr.raw_text) {
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
}
if (ocr.match_5dash6) {
const mid = ocr.match_5dash6.replace(/[^0-9]/g,'').slice(-5);
html += '<div style="margin-top:4px;color:var(--green);">✅ Matched: <strong>' + esc(ocr.match_5dash6) + '</strong> → machine ID: ' + mid + '</div>';
lookupAsset(mid);
} else if (ocr.match_5plus) {
const mid = ocr.match_5plus.replace(/[^0-9]/g,'').slice(0, 5);
html += '<div style="margin-top:4px;color:var(--amber);">🔍 Digits: <strong>' + esc(ocr.match_5plus) + '</strong> → trying ID <strong>' + mid + '</strong></div>';
lookupAsset(mid);
} else {
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
}
// Re-run controls
html += '<div class="reprocess-row">' +
'<select id="reRunEng">' +
'<option value="tesseract">Tesseract</option>' +
'<option value="llm" ' + (ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'selected' : '') + '>LLM</option>' +
'<option value="google"' + (ocr.engine === 'google' ? 'selected' : '') + '>🌐 Google</option>' +
'</select>' +
'<select id="reRunModel">' +
'<option value="">default</option>' +
'<option value="mimo-v2-omni">mimo-v2-omni</option>' +
'<option value="kimi-k2.5">kimi-k2.5</option>' +
'<option value="glm-5.1">glm-5.1</option>' +
'<option value="gemini-2.5-flash">gemini-2.5-flash</option>' +
'</select>' +
'<label style="font-size:10px;"><input type="checkbox" id="reRunSticker"> 🏷️</label>' +
'<button class="btn btn-primary btn-xs" onclick="reprocessCurrent()">🔄 Re-run</button>' +
'</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>';
// Reset button — always available to start fresh
html += '<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);text-align:center;">' +
'<button class="btn btn-outline btn-xs" onclick="resetAndReupload(' + (data.photo_id || 'null') + ')" style="color:var(--amber);border-color:var(--amber);">🔄 Reset & Re-process from scratch</button>' +
'<div style="font-size:10px;color:var(--text3);margin-top:4px;">Clears DB entry and lets you re-analyze with manual entry</div>' +
'</div>';
div.innerHTML = html;
} catch (e) {
div.innerHTML = '<div class="empty">❌ Upload failed: ' + esc(e.message) + '</div>';
}
}
function manualLookup() {
const input = document.getElementById('manualMid');
const resultDiv = document.getElementById('manualResult');
const val = input.value.trim();
if (!val) { resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">Enter a machine ID</span>'; return; }
resultDiv.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
fetch('/api/lookup?machine_id=' + encodeURIComponent(val))
.then(r => r.json())
.then(data => {
if (data.found) {
const a = data.asset;
resultDiv.innerHTML = '<div class="asset-card" style="margin-top:0;">' +
'<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>';
}
})
.catch(e => {
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
});
}
// 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;
const sticker = document.getElementById('reRunSticker').checked;
// Need the photo_id from the last upload. Get it from server results.
// We'll just re-upload the same file with new params
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
const file = allPhotos[selectedIdx].file;
const div = document.getElementById('serverResults');
div.innerHTML += '<div style="text-align:center;padding:8px;"><span class="spinner"></span> Reprocessing...</div>';
const fd = new FormData();
fd.append('file', file, file.name || 'photo.jpg');
let params = '?ocr_engine=' + eng;
if (model) params += '&ocr_model=' + model;
if (sticker) params += '&sticker_mode=true';
try {
const resp = await fetch('/api/analyze' + params, { method: 'POST', body: fd });
const data = await resp.json();
// Just reload the whole result
uploadSelected();
} catch (e) {
div.innerHTML += '<div class="empty">❌ Re-run failed: ' + esc(e.message) + '</div>';
}
}
async function lookupAsset(machineId) {
const div = document.getElementById('serverResults');
try {
const resp = await fetch('/api/lookup?machine_id=' + encodeURIComponent(machineId));
const data = await resp.json();
if (data.found) {
const a = data.asset;
let extra = '';
if (a.latitude && a.longitude) {
extra += '<span>📍 ' + Number(a.latitude).toFixed(5) + ', ' + Number(a.longitude).toFixed(5) + '</span>';
}
if (a.address) extra += '<span>🏠 ' + esc(a.address) + '</span>';
if (a.building_name) extra += '<span>🏢 ' + esc(a.building_name) + '</span>';
if (a.floor) extra += '<span>📶 Floor ' + esc(a.floor) + '</span>';
if (a.room) extra += '<span>🚪 ' + esc(a.room) + '</span>';
div.innerHTML +=
'<div class="asset-card">' +
'<div class="asset-name">' + esc(a.name) + '</div>' +
'<div class="asset-meta">' +
'<span>🆔 ' + esc(a.machine_id) + '</span>' +
'<span>📦 ' + esc(a.category) + '</span>' +
'<span>' + (a.status === 'active' ? '🟢 Active' : '⚪ ' + esc(a.status)) + '</span>' +
extra +
'</div>' +
'</div>';
} else {
div.innerHTML +=
'<div style="margin-top:6px;color:var(--amber);font-size:12px;">⚠️ No asset found for machine ID <strong>' + esc(machineId) + '</strong></div>';
}
} catch (e) {
div.innerHTML +=
'<div style="margin-top:6px;color:var(--red);font-size:12px;">❌ Lookup error: ' + esc(e.message) + '</div>';
}
}
async function startBulkProcess() {
const gpsPhotos = allPhotos.filter(p => p.hasGps);
if (!gpsPhotos.length) return;
const btn = document.getElementById('bulkBtn');
// Offline: queue all photos for later
if (!navigator.onLine) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Queuing ' + gpsPhotos.length + ' photos...';
for (const p of gpsPhotos) {
await photoQueue.add(p.file, p.file.name || 'photo.jpg');
}
updateOfflineBanner();
btn.disabled = false;
btn.textContent = '🔍 Bulk Process GPS Photos';
document.getElementById('bulkResults').innerHTML =
'<div style="text-align:center;padding:20px;color:var(--amber);">📡 ' + gpsPhotos.length + ' photos queued — will upload when connection returns</div>';
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
const fd = new FormData();
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
try {
const resp = await fetch('/api/bulk-process' + getOcrParams(), { method: 'POST', body: fd });
const data = await resp.json();
bulkData = data.results.map((r, i) => ({
...r,
thumb: gpsPhotos[i]?.thumb || null,
clientLat: gpsPhotos[i]?.lat || null,
clientLng: gpsPhotos[i]?.lng || null,
}));
renderBulkResults(data.summary);
} catch (e) {
document.getElementById('bulkResults').innerHTML =
'<div class="empty">❌ Bulk process failed: ' + esc(e.message) + '</div>';
} finally {
btn.disabled = false;
btn.textContent = '🔍 Bulk Process GPS Photos';
}
}
/* REPLACED BELOW */
async function pushGpsToAsset(idx) {
const item = bulkData[idx];
if (!item || !item.asset || !item.needs_gps) return;
const btn = document.getElementById('pushBtn' + idx);
btn.disabled = true;
btn.textContent = '⏳ Pushing...';
const gps = item.exif.gps;
try {
const resp = await fetch('/api/push-gps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: item.asset.id,
latitude: gps.lat,
longitude: gps.lng,
}),
});
const data = await resp.json();
if (data.updated) {
btn.className = 'btn-push done';
btn.textContent = '✓ Pushed!';
if (bulkMap) {
L.marker([gps.lat, gps.lng], {
icon: L.divIcon({
className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">✓</div>',
iconSize: [24, 24], iconAnchor: [12, 12]
})
}).addTo(bulkMap)
.bindPopup('<b>' + esc(item.asset.name) + '</b><br>📍 Pushed: ' + gps.lat.toFixed(5) + ', ' + gps.lng.toFixed(5))
.openPopup();
}
} else {
btn.textContent = data.reason || 'Failed';
btn.style.background = 'var(--red)';
}
} catch (e) {
btn.textContent = 'Error';
btn.style.background = 'var(--red)';
}
}
function resetAll() {
allPhotos.forEach(p => {
if (p.thumb && p.thumb.startsWith('blob:')) {
URL.revokeObjectURL(p.thumb);
}
});
allPhotos = [];
selectedIdx = -1;
currentFilter = 'all';
// Reset filter chips to default state
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
const allChip = Array.from(document.querySelectorAll('.filter-chip'))
.find(c => c.textContent.trim() === 'All');
if (allChip) allChip.classList.add('active');
bulkData = [];
if (bulkMap) { bulkMap.remove(); bulkMap = null; }
document.getElementById('fileInput').value = '';
document.getElementById('uploadArea').style.display = '';
document.getElementById('gallery').innerHTML = '';
document.getElementById('summary').style.display = 'none';
document.getElementById('filterBar').style.display = 'none';
document.getElementById('detail').style.display = 'none';
document.getElementById('serverSection').style.display = 'none';
document.getElementById('bulkSection').style.display = 'none';
document.getElementById('bulkBtn').style.display = 'none';
document.getElementById('mapContainer').style.display = 'none';
document.getElementById('clearBtn').style.display = 'none';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function onOcrToggle() {
const engine = document.getElementById('ocrEngine').value;
const sticker = document.getElementById('stickerToggle').checked;
const label = document.getElementById('modelLabel');
if (engine === 'llm' && sticker) label.textContent = '🏷️ sticker mode';
else if (engine === 'llm') label.textContent = 'mimo-v2-omni';
else if (engine === 'google' && sticker) label.textContent = '🏷️ gemini sticker';
else if (engine === 'google') label.textContent = 'gemini-2.5-flash';
else label.textContent = 'tesseract';
}
function getOcrParams() {
const engine = document.getElementById('ocrEngine').value;
const sticker = document.getElementById('stickerToggle').checked;
let params = [];
if (engine !== 'tesseract') params.push('ocr_engine=' + engine);
if (sticker) params.push('sticker_mode=true');
return params.length ? '?' + params.join('&') : '';
}
function formatSize(bytes) {
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
// ============================================================
// Feature #4: Export UI
// ============================================================
function showExportModal() {
const d = document.createElement('div'); d.className = 'export-modal-backdrop'; d.id = 'exportModal';
d.onclick = function(e) { if (e.target === this) this.remove(); };
d.innerHTML = '<div class="export-modal"><h3>📊 Export Data</h3><button class="btn btn-primary btn-sm" onclick="doExport(\'csv\')">📊 CSV (Excel)</button><button class="btn btn-sm" style="background:var(--green);color:#000;" onclick="doExport(\'kml\')">🌍 KML (Google Earth)</button><button class="btn btn-outline btn-sm" onclick="doExport(\'clipboard\')">📋 Copy GPS</button><div style="margin-top:8px;font-size:11px;color:var(--text3);" id="exportStatus"></div><button class="btn btn-outline btn-sm" style="margin-top:8px;" onclick="document.getElementById(\'exportModal\').remove()">Cancel</button></div>';
document.body.appendChild(d);
}
async function doExport(f) {
const s = document.getElementById('exportStatus'); s.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span> Exporting...';
try {
if (f === 'clipboard') {
const r = await fetch('/api/export?format=clipboard&limit=5000'); const d = await r.json();
if (d.text) { await navigator.clipboard.writeText(d.text); s.innerHTML = '✅ Copied ' + d.count + ' coordinates!'; }
else { s.innerHTML = '⚠️ No GPS coordinates to copy'; }
} else {
const r = await fetch('/api/export?format=' + f + '&limit=5000'); const b = await r.blob();
const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = 'exif-export-' + new Date().toISOString().slice(0,10) + '.' + f; a.click();
URL.revokeObjectURL(u); s.innerHTML = '✅ Downloaded!';
}
} catch(e) { s.innerHTML = '❌ Error: ' + e.message; }
}
// ============================================================
// Feature #5: Inline Machine ID Edit
// ============================================================
function makeOcrEditable(pid, mid, idx) {
const el = document.getElementById('ocrLabel' + idx); if (!el) return;
const v = mid || '';
el.innerHTML = '<input class="ocr-input" id="ocrInput' + idx + '" value="' + esc(v) + '" placeholder="Type machine ID...">';
const inp = document.getElementById('ocrInput' + idx); inp.focus(); inp.select();
inp.onkeydown = function(e) {
if (e.key === 'Enter') submitMachineId(pid, this.value, idx);
if (e.key === 'Escape') cancelEdit(idx, mid);
};
inp.onblur = function() { if (this.value.trim() !== v.trim()) submitMachineId(pid, this.value, idx); else cancelEdit(idx, mid); };
}
function cancelEdit(idx, mid) {
const el = document.getElementById('ocrLabel' + idx); if (!el) return;
el.innerHTML = '<span class="ocr-edit" onclick="makeOcrEditable(' + el.dataset.pid + ',' + JSON.stringify(mid) + ',' + idx + ')">' + (mid || 'No OCR match') + '</span>';
}
async function submitMachineId(pid, mid, idx) {
if (!mid || !mid.trim()) return;
const el = document.getElementById('ocrLabel' + idx); if (!el) return;
el.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
try {
const r = await fetch('/api/assign-machine-id', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({photo_id: pid, machine_id: mid.trim()}) });
const d = await r.json();
if (d.asset) {
el.innerHTML = esc(d.asset.name) + ' <span style="font-size:10px;color:var(--text3);">🆔 ' + esc(d.asset.machine_id) + '</span>';
if (d.needs_gps) {
const card = el.closest('.bulk-info');
if (card) { const a = card.parentElement.querySelector('.bulk-action'); if (a) a.innerHTML = '<button class="btn-push" onclick="pushGpsToAssetWithId(' + d.asset.id + ',\'' + d.asset.machine_id + '\',' + pid + ')">📤 Push GPS</button>'; }
}
} else { el.innerHTML = '<span style="color:var(--amber);">⚠️ No asset match</span>'; setTimeout(() => { el.innerHTML = '<span class="ocr-edit" onclick="makeOcrEditable(' + pid + ',\'' + mid + '\',' + idx + ')">' + esc(mid) + '</span>'; }, 2000); }
} catch(e) { el.innerHTML = '❌ Error'; }
}
async function pushGpsToAssetWithId(aid, mid, pid) {
if (!aid) return;
const r = await fetch('/api/photos/' + pid); const p = await r.json();
if (!p.gps_lat || !p.gps_lng) { alert('No GPS'); return; }
const btn = event?.target; if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
try {
const r2 = await fetch('/api/push-gps', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({asset_id: aid, latitude: p.gps_lat, longitude: p.gps_lng}) });
const d = await r2.json();
if (btn) { btn.className = d.updated ? 'btn-push done' : 'btn-push'; btn.textContent = d.updated ? '✓ Pushed!' : (d.reason || 'Failed'); }
} catch(e) { if (btn) { btn.textContent = 'Error'; btn.style.background = 'var(--red)'; } }
}
// ============================================================
// Feature #6: GPS Proximity
// ============================================================
let _nearbyCache = {};
async function loadNearby(lat, lng, pid, idx) {
const key = lat + ',' + lng;
if (_nearbyCache[key]) { renderNearby(_nearbyCache[key], idx); return; }
const el = document.getElementById('nearby' + idx); if (!el) return;
el.innerHTML = '<span class="spinner" style="width:10px;height:10px;"></span>';
try {
const r = await fetch('/api/nearby?lat=' + lat + '&lng=' + lng + '&radius=10' + (pid ? '&exclude_photo_id=' + pid : ''));
const d = await r.json(); _nearbyCache[key] = d; renderNearby(d, idx);
if (typeof L !== 'undefined' && bulkMap && d.count > 0) L.circle([lat, lng], {radius: 10, color: '#22c55e', fillOpacity: 0.05, weight: 1}).addTo(bulkMap);
} catch(e) {}
}
function renderNearby(d, idx) {
const el = document.getElementById('nearby' + idx); if (!el) return;
if (d.count === 0) { el.innerHTML = '<span style="color:var(--text3);font-size:10px;">📍 No nearby assets</span>'; return; }
el.innerHTML = '<span class="nearby-line" onclick="toggleNearbyList(' + idx + ')">📍 Nearby: <strong>' + d.count + '</strong> within 10m</span><div class="nearby-list" id="nearbyList' + idx + '" style="display:none;"></div>';
el.dataset.nearbyData = JSON.stringify(d.nearby);
}
function toggleNearbyList(idx) {
const l = document.getElementById('nearbyList' + idx); if (!l) return;
if (l.style.display !== 'none') { l.style.display = 'none'; return; }
l.style.display = 'block'; if (l.dataset.loaded) return; l.dataset.loaded = '1';
const el = document.getElementById('nearby' + idx); if (!el || !el.dataset.nearbyData) return;
l.innerHTML = JSON.parse(el.dataset.nearbyData).map(n => {
const i = n.distance_m <= 5 ? '🟢' : n.distance_m <= 20 ? '🟡' : '🔴';
return '<div>' + i + ' 🆔 ' + esc(n.machine_id) + ' · ' + esc(n.name) + ' · ' + n.distance_m + 'm' + (n.has_gps ? ' · 📶 GPS' : ' · ⚠️ no GPS') + '</div>';
}).join('');
}
// ============================================================
// Feature #10: GPS Drift Check
// ============================================================
function haversineKm(lat1, lng1, lat2, lng2) {
const R = 6371, dLat = (lat2-lat1)*Math.PI/180, dLng = (lng2-lng1)*Math.PI/180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
// ============================================================
// Feature #7: Walk-Path Timeline
// ============================================================
let _walkPathGroup = null;
function toggleWalkPath() {
const en = document.getElementById('walkPathToggle').checked;
if (!bulkMap || !bulkData.length) return;
if (_walkPathGroup) { bulkMap.removeLayer(_walkPathGroup); _walkPathGroup = null; }
const wt = document.getElementById('walkTimeline'); if (wt) wt.remove();
if (!en) return;
const pts = bulkData.map((item,i) => ({lat: item.exif?.gps?.lat || item.clientLat, lng: item.exif?.gps?.lng || item.clientLng, name: item.asset?.name || item.filename || 'Photo '+(i+1), i})).filter(p => p.lat && p.lng);
if (pts.length < 2) { document.getElementById('walkPathToggle').checked = false; return; }
_walkPathGroup = L.layerGroup().addTo(bulkMap);
let th = '<div class="walk-timeline" id="walkTimeline"><strong>🚶 Walk Path</strong><br>';
for (let i = 0; i < pts.length-1; i++) {
const d = Math.round(haversineKm(pts[i].lat, pts[i].lng, pts[i+1].lat, pts[i+1].lng) * 1000);
const c = d < 30 ? '#22c55e' : d < 120 ? '#f59e0b' : '#ef4444';
const ic = d < 30 ? '🟢' : d < 120 ? '🟡' : '🔴';
L.polyline([[pts[i].lat, pts[i].lng], [pts[i+1].lat, pts[i+1].lng]], {color: c, weight: 3, opacity: 0.7}).addTo(_walkPathGroup).bindPopup('Walked ' + d + 'm from ' + esc(pts[i].name) + ' to ' + esc(pts[i+1].name));
th += '<div>' + ic + ' Step ' + (i+1) + '→' + (i+2) + ': ' + d + 'm</div>';
}
th += '</div>';
const con = document.getElementById('bulkResults'); if (con) con.insertAdjacentHTML('beforebegin', th);
}
// ============================================================
// Feature #8: Batch Machine ID Assign
// ============================================================
let _batchMode = false, _batchSelected = {};
function toggleBatchMode() {
_batchMode = !_batchMode; _batchSelected = {};
const bar = document.getElementById('batchBar'), btn = document.getElementById('batchBtn');
if (_batchMode) {
bar.style.display = 'flex'; btn.textContent = '✅ Done Selecting';
document.querySelectorAll('.bulk-card').forEach(c => c.classList.add('batch-mode'));
document.querySelectorAll('.bulk-card').forEach((c, i) => {
if (!c.querySelector('input[type=checkbox]')) {
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.cssText = 'accent-color:var(--accent);margin-right:4px;';
cb.onchange = function() { onBatchSelect(i, this.checked); }; c.insertBefore(cb, c.firstChild);
}
});
} else {
bar.style.display = 'none'; btn.textContent = '📎 Batch Select';
document.querySelectorAll('.bulk-card input[type=checkbox]').forEach(c => c.remove());
document.querySelectorAll('.bulk-card').forEach(c => c.classList.remove('batch-mode'));
document.getElementById('batchMachineId').disabled = true; document.getElementById('batchLookup').disabled = true; document.getElementById('batchPushGps').style.display = 'none';
}
}
function onBatchSelect(idx, checked) {
if (checked) _batchSelected[idx] = true; else delete _batchSelected[idx];
const c = Object.keys(_batchSelected).length;
document.getElementById('batchCount').textContent = '📎 ' + c + ' selected';
document.getElementById('batchMachineId').disabled = c < 2; document.getElementById('batchLookup').disabled = c < 2; document.getElementById('batchPushGps').style.display = 'none';
}
async function batchLookup() {
const mid = document.getElementById('batchMachineId').value.trim(); if (!mid) return;
const s = document.getElementById('batchCount'); s.innerHTML = '<span class="spinner" style="width:10px;height:10px;"></span>';
try {
const r = await fetch('/api/lookup?machine_id=' + encodeURIComponent(mid)); const d = await r.json();
if (d.found) { s.innerHTML = '✅ ' + esc(d.asset.name) + ' — 📤 Push ready'; document.getElementById('batchPushGps').style.display = ''; document.getElementById('batchPushGps').dataset.machineId = mid; }
else { s.innerHTML = '⚠️ No asset match'; document.getElementById('batchPushGps').style.display = 'none'; }
} catch(e) { s.innerHTML = '❌ Error'; }
}
async function batchPushGps() {
const mid = document.getElementById('batchPushGps').dataset.machineId;
const pids = Object.keys(_batchSelected).map(k => bulkData[parseInt(k)]?.photo_id).filter(Boolean);
if (!pids.length || !mid) return;
const s = document.getElementById('batchCount'); s.innerHTML = '<span class="spinner" style="width:10px;height:10px;"></span> Assigning...';
try {
const r = await fetch('/api/bulk-assign', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({photo_ids: pids, machine_id: mid}) });
const d = await r.json(); s.innerHTML = '✅ Updated ' + d.updated + ', GPS pushed: ' + d.gps_pushed + (d.no_gps?.length ? ', no GPS: ' + d.no_gps.length : '');
_batchMode = false; toggleBatchMode(); loadPreviousPhotos();
} catch(e) { s.innerHTML = '❌ Error: ' + e.message; }
}
// ============================================================
// Feature #9: Session Management UI
// ============================================================
let _currentSessionId = null;
async function loadSessions() {
try {
const r = await fetch('/api/sessions'); const d = await r.json();
const sec = document.getElementById('sessionsSection'), lst = document.getElementById('sessionsList');
if (!d.sessions || !d.sessions.length) { if (sec) sec.style.display = 'none'; return; }
if (sec) sec.style.display = 'block';
if (document.getElementById('sessionCount')) document.getElementById('sessionCount').textContent = d.sessions.length;
if (lst) lst.innerHTML = d.sessions.map(s => '<div class="session-card" onclick="toggleSession(' + s.id + ')"><div class="sess-name">' + esc(s.name) + '</div><div class="sess-meta">📍 ' + (s.photo_count||0) + ' photos · ' + (s.matched_count||0) + ' matched · ' + (s.needs_gps_count||0) + ' need GPS · ' + (s.closed_at ? '🔒 Closed' : '🟢 Open') + '</div><div class="sess-photos" id="sessPhotos' + s.id + '"></div></div>').join('');
} catch(e) {}
}
async function toggleSession(sid) {
const pd = document.getElementById('sessPhotos' + sid); if (!pd) return;
const card = pd.closest('.session-card'); if (!card) return;
if (card.classList.contains('open')) { card.classList.remove('open'); return; }
if (!pd.innerHTML) {
try {
const r = await fetch('/api/sessions/' + sid); const d = await r.json();
pd.innerHTML = (d.photos || []).map(p => { const c = p.gps_lat ? '📍 ' + Number(p.gps_lat).toFixed(4) + ', ' + Number(p.gps_lng).toFixed(4) : ''; return '<div style="font-size:10px;padding:2px 0;border-bottom:1px solid var(--border);">' + esc(p.orig_filename) + (p.machine_id ? ' · 🆔 ' + esc(p.machine_id) : '') + (c ? ' · ' + c : '') + '</div>'; }).join('');
} catch(e) { pd.innerHTML = '<span style="color:var(--red);">Failed</span>'; }
}
card.classList.add('open');
}
async function createSessionFromPicker() {
const n = document.getElementById('sessionName').value.trim(), b = document.getElementById('sessionBuilding').value, f = document.getElementById('sessionFloor').value;
if (!n) { alert('Enter a session name first'); return; }
try {
const r = await fetch('/api/sessions', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: n, building: b||undefined, floor: f||undefined}) });
const d = await r.json(); _currentSessionId = d.id; document.getElementById('sessionName').style.borderColor = 'var(--green)'; loadSessions();
} catch(e) { alert('Failed to create session'); }
}
function onSessionChange() {}
// ============================================================
// Feature #11: Coverage Heatmap
// ============================================================
let _heatLayer = null;
function toggleHeatmap() {
const en = document.getElementById('heatmapToggle').checked;
const sl = document.getElementById('heatmapSliders');
if (sl) sl.style.display = en ? 'flex' : 'none';
if (!en) { if (_heatLayer && bulkMap) { bulkMap.removeLayer(_heatLayer); _heatLayer = null; } return; }
updateHeatmap();
}
function updateHeatmap() {
if (_heatLayer && bulkMap) { bulkMap.removeLayer(_heatLayer); _heatLayer = null; }
if (!document.getElementById('heatmapToggle')?.checked) return;
const r = parseInt(document.getElementById('heatRadius').value), inf = parseInt(document.getElementById('heatIntensity').value) / 5;
const pts = bulkData.map(it => { const lt = it.exif?.gps?.lat || it.clientLat, ln = it.exif?.gps?.lng || it.clientLng; return (lt && ln) ? [lt, ln, inf] : null; }).filter(Boolean);
if (pts.length && typeof L !== 'undefined' && bulkMap && L.heatLayer) {
_heatLayer = L.heatLayer(pts, {radius: r, blur: r*0.8, maxZoom: 18, gradient: {0.0:'blue', 0.3:'cyan', 0.5:'lime', 0.8:'yellow', 1.0:'red'}}).addTo(bulkMap);
}
}
// ============================================================
// OVERRIDE: renderBulkResults (new comprehensive version)
// ============================================================
function renderBulkResults(summary) {
const section = document.getElementById('bulkSection');
section.style.display = 'block';
document.getElementById('bulkSummary').textContent =
summary.matched + ' matched, ' + summary.needs_gps + ' need GPS' +
(summary.duplicates ? ', ' + summary.duplicates + ' skipped' : '');
document.getElementById('bulkSummary').className =
'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match');
const mapDiv = document.getElementById('mapContainer');
mapDiv.style.display = 'block';
if (bulkMap) { try { bulkMap.remove(); } catch(e) {} bulkMap = null; }
bulkMap = L.map('mapContainer').setView([28.35, -81.55], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OSM', maxZoom: 19 }).addTo(bulkMap);
const mt = document.getElementById('mapToggles'); if (mt) mt.style.display = 'flex';
const eb = document.getElementById('exportBtn'); if (eb) eb.style.display = '';
const bb = document.getElementById('batchBtn'); if (bb) bb.style.display = '';
const sp = document.getElementById('sessionPicker'); if (sp) sp.style.display = 'flex';
loadSessions();
if (_walkPathGroup) { try { bulkMap.removeLayer(_walkPathGroup); } catch(e) {} _walkPathGroup = null; }
if (_heatLayer) { try { bulkMap.removeLayer(_heatLayer); } catch(e) {} _heatLayer = null; }
const wpt = document.getElementById('walkPathToggle'); if (wpt) wpt.checked = false;
const ht = document.getElementById('heatmapToggle'); if (ht) ht.checked = false;
const hs = document.getElementById('heatmapSliders'); if (hs) hs.style.display = 'none';
const wtim = document.getElementById('walkTimeline'); if (wtim) wtim.remove();
const markers = [];
let html = '';
bulkData.forEach((item, i) => {
const asset = item.asset;
const hasGps = item.exif?.gps;
const gpsLat = hasGps ? item.exif.gps.lat : null;
const gpsLng = hasGps ? item.exif.gps.lng : null;
if (gpsLat && gpsLng) {
markers.push([gpsLat, gpsLng]);
L.marker([gpsLat, gpsLng]).addTo(bulkMap).bindPopup(
(asset ? esc(asset.name) : 'Unknown') + '<br>📸 ' + esc(item.filename||'') + '<br>📍 ' + gpsLat.toFixed(5) + ', ' + gpsLng.toFixed(5));
}
if (asset && asset.latitude && asset.longitude) {
L.marker([asset.latitude, asset.longitude], {
icon: L.divIcon({ className: 'cat-pin-icon', html: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>', iconSize: [24,24], iconAnchor: [12,12] })
}).addTo(bulkMap).bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
}
const isGpsReady = item.needs_gps;
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '" data-idx="' + i + '">';
if (item.thumb) html += '<img class="bulk-thumb" src="' + item.thumb + '" alt="">';
html += '<div class="bulk-info">';
if (item.duplicate) {
html += '<div class="bulk-name">' + esc(item.filename||'Photo') + '</div><div class="bulk-meta"><span class="match-badge dup">♻️ Already processed</span></div>';
} else if (asset) {
html += '<div class="bulk-name">' + esc(asset.name) + '</div><div class="bulk-meta">';
html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> ';
html += '<span class="match-badge ' + (isGpsReady ? 'no-gps' : 'has-gps') + '">';
if (asset.latitude && asset.longitude) html += '📍 Has GPS in DB';
else if (isGpsReady) html += '📍 Photo GPS: ' + gpsLat.toFixed(4) + ', ' + gpsLng.toFixed(4);
else html += '⚠️ No photo GPS';
html += '</span></div>';
// Drift check
if (gpsLat && gpsLng && asset.latitude && asset.longitude) {
const dm = haversineKm(gpsLat, gpsLng, asset.latitude, asset.longitude) * 1000;
let ic = '🟢', lb = 'GPS OK';
if (dm > 50) { ic = '🔴'; lb = '⚠️⚠️ Drift ' + Math.round(dm) + 'm'; }
else if (dm > 10) { ic = '🟡'; lb = '⚠️ Drift ' + Math.round(dm) + 'm'; }
html += '<div style="font-size:10px;margin-top:2px;">' + ic + ' ' + lb + '</div>';
if (typeof L !== 'undefined' && bulkMap) {
const c = dm > 50 ? '#ef4444' : dm > 10 ? '#f59e0b' : '#22c55e';
setTimeout(() => { try { L.polyline([[gpsLat, gpsLng], [asset.latitude, asset.longitude]], {color:c, weight:1, dashArray:'3,3', opacity:0.4}).addTo(bulkMap); } catch(e) {} }, 300);
}
}
if (gpsLat && gpsLng) html += '<div id="nearby' + i + '"></div>';
} else if (item.machine_id) {
html += '<div class="bulk-name">Machine #' + esc(item.machine_id) + '</div><div class="bulk-meta"><span class="match-badge no-match">No DB match</span></div>';
if (gpsLat && gpsLng) html += '<div id="nearby' + i + '"></div>';
} else {
html += '<div class="bulk-name">' + esc(item.filename||'Photo') + '</div><div class="bulk-meta">';
html += '<span class="ocr-edit" id="ocrLabel' + i + '" data-pid="' + (item.photo_id||'') + '" onclick="makeOcrEditable(' + (item.photo_id||'null') + ',' + (item.machine_id ? JSON.stringify(item.machine_id) : 'null') + ',' + i + ')">No OCR match</span>';
html += '</div>';
if (gpsLat && gpsLng) html += '<div id="nearby' + i + '"></div>';
}
html += '</div><div class="bulk-action">';
if (isGpsReady) html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
else if (asset && asset.latitude && asset.longitude) html += '<button class="btn-push done">✓ In DB</button>';
html += '</div></div>';
if (gpsLat && gpsLng) setTimeout(() => loadNearby(gpsLat, gpsLng, item.photo_id, i), 500);
});
document.getElementById('bulkResults').innerHTML = html;
if (_batchMode) {
document.querySelectorAll('.bulk-card').forEach((c, i) => {
if (!c.querySelector('input[type=checkbox]')) {
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.cssText = 'accent-color:var(--accent);margin-right:4px;';
cb.onchange = function() { onBatchSelect(i, this.checked); }; c.insertBefore(cb, c.firstChild);
}
});
}
if (markers.length) bulkMap.fitBounds(L.latLngBounds(markers), {padding:[30,30], maxZoom:15});
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
section.scrollIntoView({behavior:'smooth'});
}
/* ═══════════════════════════════════════════
Offline Queue — IndexedDB Photo Queue Manager
═══════════════════════════════════════════ */
const DB_NAME = 'exif-scanner-queue';
const DB_VERSION = 1;
const STORE_NAME = 'photos-queue';
class PhotoQueue {
constructor() {
this._ready = this._init();
}
async _init() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
}
};
req.onsuccess = (e) => { this._db = e.target.result; resolve(); };
req.onerror = (e) => { console.error('PhotoQueue: DB open failed', e); reject(e); };
});
}
async add(file, filename) {
await this._ready;
return new Promise((resolve, reject) => {
const tx = this._db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.add({ fileBlob: file, filename: filename, timestamp: Date.now(), retries: 0, status: 'queued' });
tx.oncomplete = () => {
if ('serviceWorker' in navigator && navigator.serviceWorker.ready) {
navigator.serviceWorker.ready.then(r => {
try { r.sync.register('sync-photos'); } catch(e) { /* periodicSync not available */ }
});
}
resolve();
};
tx.onerror = (e) => reject(e);
});
}
async getAll() {
await this._ready;
return new Promise((resolve, reject) => {
const tx = this._db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = (e) => reject(e);
});
}
async count() {
await this._ready;
return new Promise((resolve, reject) => {
const tx = this._db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.count();
req.onsuccess = () => resolve(req.result);
req.onerror = (e) => reject(e);
});
}
async remove(id) {
await this._ready;
return new Promise((resolve, reject) => {
const tx = this._db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.delete(id);
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e);
});
}
async updateRetry(id, retries) {
await this._ready;
return new Promise((resolve, reject) => {
const tx = this._db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const getReq = store.get(id);
getReq.onsuccess = () => {
const item = getReq.result;
if (item) {
item.retries = retries;
item.status = retries >= 3 ? 'failed' : 'queued';
store.put(item);
}
};
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e);
});
}
async drain() {
const items = await this.getAll();
const pending = items.filter(i => i.status === 'queued' || i.status === 'uploading');
if (!pending.length) return { uploaded: 0, failed: 0 };
let uploaded = 0, failed = 0, attempt = 0;
for (const item of pending) {
attempt = item.retries || 0;
if (attempt >= 3) { await this.remove(item.id); failed++; continue; }
await this._markStatus(item.id, 'uploading');
try {
const fd = new FormData();
fd.append('files', item.fileBlob, item.filename || 'photo.jpg');
const resp = await fetch('/api/bulk-process' + (window.getOcrParams ? getOcrParams() : ''), { method: 'POST', body: fd });
if (resp.ok) {
await this.remove(item.id);
uploaded++;
} else {
const delay = [2000, 5000, 15000][attempt] || 15000;
await this._bumpRetry(item, delay);
failed++;
}
} catch (e) {
const delay = [2000, 5000, 15000][attempt] || 15000;
await this._bumpRetry(item, delay);
failed++;
}
}
return { uploaded, failed };
}
async _markStatus(id, status) {
const tx = this._db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.get(id);
req.onsuccess = () => { const i = req.result; if (i) { i.status = status; store.put(i); } };
return new Promise(r => tx.oncomplete = () => r());
}
async _bumpRetry(item, delayMs) {
item.retries = (item.retries || 0) + 1;
item.status = item.retries >= 3 ? 'failed' : 'queued';
if (item.retries < 3) {
await new Promise(r => setTimeout(r, delayMs));
}
const tx = this._db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put(item);
return new Promise(r => tx.oncomplete = () => r());
}
}
const photoQueue = new PhotoQueue();
function updateOfflineBanner() {
const banner = document.getElementById('offlineBanner');
const countSpan = document.getElementById('offlineCount');
if (!banner || !countSpan) return;
if (navigator.onLine) {
banner.style.display = 'none';
} else {
photoQueue.count().then(n => {
countSpan.textContent = n;
banner.style.display = n > 0 ? 'block' : 'none';
});
}
}
// Online/offline listeners
window.addEventListener('online', async () => {
updateOfflineBanner();
const countSpan = document.getElementById('offlineCount');
if (countSpan) countSpan.textContent = 'Syncing…';
const result = await photoQueue.drain();
if (result.uploaded > 0) {
if (typeof reloadPreviousPhotos === 'function') reloadPreviousPhotos();
else location.reload();
}
updateOfflineBanner();
});
window.addEventListener('offline', () => {
updateOfflineBanner();
});
// Listen for SW sync messages
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SYNC_TRIGGERED') {
photoQueue.drain().then(() => updateOfflineBanner());
}
});
}
// 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';
}
// ═══════════════════════════════════════════════════════════════════════
// EXIF Photos Map — all GPS-tagged photos plotted on Leaflet
// ═══════════════════════════════════════════════════════════════════════
function loadExifMap() {
document.getElementById('exifMapInfo').textContent = 'Loading...';
fetch('/api/photos?limit=200')
.then(r => r.json())
.then(data => {
const photos = data.photos || [];
updateExifMapCounts(photos);
renderExifMapPins(photos);
})
.catch(err => {
document.getElementById('exifMapInfo').textContent = '❌ Failed to load';
console.warn('EXIF map load error:', err);
});
}
function updateExifMapCounts(photos) {
const total = photos.length;
const withGps = photos.filter(p => p.gps_lat && p.gps_lng).length;
const withoutGps = total - withGps;
document.getElementById('exifTotalPhotos').textContent = total;
document.getElementById('exifGpsPhotos').textContent = withGps;
document.getElementById('exifNoGpsPhotos').textContent = withoutGps;
document.getElementById('exifMapCount').textContent = withGps + ' with GPS';
}
function renderExifMapPins(photos) {
// Get or init the map
const container = document.getElementById('exifMapContainer');
if (!container) return;
// Destroy old map if exists
if (exifMap) {
exifMap.remove();
exifMap = null;
exifMarkers = [];
_exifGpsPhotos = [];
if (exifHeatLayer) {
exifHeatLayer = null;
}
}
const gpsPhotos = photos.filter(p => p.gps_lat && p.gps_lng);
_exifGpsPhotos = gpsPhotos; // store globally for search
if (gpsPhotos.length === 0) {
document.getElementById('exifMapInfo').textContent = 'No GPS photos found — upload some!';
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:13px;">📍 No GPS-tagged photos yet</div>';
return;
}
// Init map
exifMap = L.map(container, { zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(exifMap);
// Add markers
const bounds = [];
const heatData = [];
gpsPhotos.forEach((p) => {
const lat = Number(p.gps_lat);
const lng = Number(p.gps_lng);
if (isNaN(lat) || isNaN(lng)) return;
bounds.push([lat, lng]);
heatData.push([lat, lng, 0.8]);
// Custom icon: colored circle with photo count
const icon = L.divIcon({
className: 'exif-marker-icon',
html: '<div class="exif-marker-dot"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -10],
});
const marker = L.marker([lat, lng], { icon })
.addTo(exifMap)
.bindPopup(`
<div style="min-width:180px;font-family:-apple-system,sans-serif;">
<img src="/api/photos/${p.id}/file" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;margin-bottom:6px;" loading="lazy"
onerror="this.style.display='none'">
<div style="font-weight:700;font-size:13px;margin-bottom:2px;">${esc(p.orig_filename || '')}</div>
<div style="font-size:11px;color:#666;">📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
<div style="font-size:10px;color:#999;margin-top:2px;">
${p.machine_id ? '🆔 ' + esc(p.machine_id) : 'No machine ID'}
${p.created_at ? ' · ' + new Date(p.created_at).toLocaleDateString() : ''}
</div>
${p.ocr_match_5dash6 ? '<div style="font-size:10px;color:#666;margin-top:2px;">🔍 OCR: ' + esc(p.ocr_match_5dash6) + '</div>' : ''}
</div>
`, { maxWidth: 260 });
marker._defaultIcon = icon;
marker.machineId = (p.machine_id || '').toLowerCase();
marker.photoData = p;
exifMarkers.push(marker);
});
// Fit bounds to show all markers
if (bounds.length > 0) {
const group = L.featureGroup(exifMarkers);
exifMap.fitBounds(group.getBounds().pad(0.1));
}
// Invalidate size after render
setTimeout(() => {
if (exifMap) exifMap.invalidateSize();
}, 200);
document.getElementById('exifMapInfo').textContent = gpsPhotos.length + ' photo' + (gpsPhotos.length !== 1 ? 's' : '') + ' on map';
// Pre-compute heatmap data for toggle
exifHeatLayer = L.heatLayer(heatData, {
radius: 20, blur: 15, maxZoom: 17, max: 1.0, gradient: { 0.2: '#3b82f6', 0.5: '#22c55e', 0.8: '#f59e0b', 1.0: '#ef4444' }
});
if (document.getElementById('exifHeatmapToggle').checked) {
exifHeatLayer.addTo(exifMap);
}
}
function toggleExifHeatmap() {
if (!exifMap || !exifHeatLayer) return;
if (document.getElementById('exifHeatmapToggle').checked) {
exifMap.addLayer(exifHeatLayer);
} else {
exifMap.removeLayer(exifHeatLayer);
}
}
function openExifMapFullscreen() {
if (!exifMap) {
loadExifMap();
// Scroll to map after a brief delay
setTimeout(() => {
document.getElementById('exifMapContainer').scrollIntoView({ behavior: 'smooth' });
}, 300);
return;
}
document.getElementById('exifMapContainer').scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
if (exifMap) exifMap.invalidateSize();
}, 400);
}
// ── Machine ID search on EXIF Map ──
function searchExifMapByMachine() {
const input = document.getElementById('exifMachineSearch');
const status = document.getElementById('exifSearchStatus');
const query = (input.value || '').trim().toLowerCase();
if (!query) { status.textContent = '🔍 Enter a machine ID to search'; return; }
if (!exifMap || exifMarkers.length === 0) {
status.textContent = '⚠️ Map not loaded yet — click Refresh';
return;
}
// Restore default icons on any previously highlighted markers
clearExifHighlight();
// Find matching markers
const matched = [];
exifMarkers.forEach((marker) => {
if (marker.machineId && marker.machineId.includes(query)) {
matched.push(marker);
}
});
if (matched.length === 0) {
status.textContent = '❌ No photos found with machine ID matching "' + esc(input.value) + '"';
return;
}
// Highlight matched markers with a larger, pulsing icon
const highlightIcon = L.divIcon({
className: 'exif-marker-icon exif-marker-search',
html: '<div class="exif-marker-dot exif-marker-highlight"></div>',
iconSize: [22, 22],
iconAnchor: [11, 11],
popupAnchor: [0, -14],
});
const highlightGroup = L.featureGroup();
matched.forEach(marker => {
marker._searchHighlight = highlightIcon;
marker.setIcon(highlightIcon);
highlightGroup.addLayer(marker);
});
window._exifSearchGroup = highlightGroup;
exifMap.fitBounds(highlightGroup.getBounds().pad(0.3));
// Build status message
let matchInfo = '🔎 Found <strong>' + matched.length + '</strong> photo' + (matched.length !== 1 ? 's' : '');
const uniqueIds = [...new Set(matched.map(m => m.photoData.machine_id).filter(Boolean))];
if (uniqueIds.length > 0) {
matchInfo += ' • 🆔 ' + uniqueIds.map(id => '<code>' + esc(id) + '</code>').join(', ');
}
status.innerHTML = matchInfo;
}
function clearExifHighlight() {
if (!exifMarkers) return;
exifMarkers.forEach(m => {
if (m._searchHighlight) {
m.setIcon(m._defaultIcon);
m._searchHighlight = null;
}
});
if (window._exifSearchGroup) {
exifMap.removeLayer(window._exifSearchGroup);
window._exifSearchGroup = null;
}
}
function clearExifSearch() {
document.getElementById('exifMachineSearch').value = '';
document.getElementById('exifSearchStatus').textContent = '';
clearExifHighlight();
// Fit all markers back
if (exifMap && exifMarkers.length > 0) {
const group = L.featureGroup(exifMarkers);
exifMap.fitBounds(group.getBounds().pad(0.1));
}
}
// Also re-load EXIF map when new photos are processed (after loadPreviousPhotos completes)
function refreshExifMap() {
setTimeout(loadExifMap, 1000);
}
</script>
</body>
</html>