diff --git a/static/index.html b/static/index.html
index 83b3e51..e236375 100644
--- a/static/index.html
+++ b/static/index.html
@@ -4,6 +4,14 @@
EXIF Scanner β Find GPS Photos
+
@@ -308,6 +316,9 @@
Tap to Select Photos
Pick from gallery β multi-select enabled
+
+ π‘ Offline β 0 photos queued
+
@@ -743,6 +754,18 @@ 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 = 'π‘ Photo queued β will upload when connection returns
';
+ section.scrollIntoView({ behavior: 'smooth' });
+ return;
+ }
+
const section = document.getElementById('serverSection');
const div = document.getElementById('serverResults');
section.style.display = 'block';
@@ -931,6 +954,22 @@ async function startBulkProcess() {
if (!gpsPhotos.length) return;
const btn = document.getElementById('bulkBtn');
+
+ // Offline: queue all photos for later
+ if (!navigator.onLine) {
+ btn.disabled = true;
+ btn.innerHTML = ' 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 =
+ 'π‘ ' + gpsPhotos.length + ' photos queued β will upload when connection returns
';
+ return;
+ }
+
btn.disabled = true;
btn.innerHTML = ' Processing ' + gpsPhotos.length + ' photos...';
@@ -1414,6 +1453,200 @@ function renderBulkResults(summary) {
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);